diff --git a/.env.example b/.env.example deleted file mode 100644 index e570b8b5..00000000 --- a/.env.example +++ /dev/null @@ -1 +0,0 @@ -OPENAI_API_KEY= diff --git a/README.md b/README.md index bf4703e4..929cd652 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,12 @@ -# [TwitterBio.com](https://www.twitterbio.com/) +# aitools +Generate your Twitter bio with OpenAI and Vercel Edge Functions. -This project generates Twitter bios for you using AI. +## cd to server, run npm i +## add a .env file to server and add DATABASE_URL=[postgresURL] -[![Twitter Bio Generator](./public/screenshot.png)](https://www.twitterbio.com) +## cd to client, add a .env file +OPENAI_API_KEY=[openai_key] +run npm i -## How it works - -This project uses the [OpenAI GPT-3 API](https://openai.com/api/) (specifically, text-davinci-003) and [Vercel Edge functions](https://vercel.com/features/edge-functions) with streaming. It constructs a prompt based on the form and user input, sends it to the GPT-3 API via a Vercel Edge function, then streams the response back to the application. - -Video and blog post coming soon on how to build apps with OpenAI and Vercel Edge functions! - -## Running Locally - -After cloning the repo, go to [OpenAI](https://beta.openai.com/account/api-keys) to make an account and put your API key in a file called `.env`. - -Then, run the application in the command line and it will be available at `http://localhost:3000`. - -```bash -npm run dev -``` - -## One-Click Deploy - -Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=vercel-examples): - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/Nutlope/twitterbio&env=OPENAI_API_KEY&project-name=twitter-bio-generator&repo-name=twitterbio) +run client with npm run dev +run server with nodemon server.js diff --git a/.gitignore b/client/.gitignore similarity index 100% rename from .gitignore rename to client/.gitignore diff --git a/client/README.md b/client/README.md new file mode 100644 index 00000000..bf4703e4 --- /dev/null +++ b/client/README.md @@ -0,0 +1,27 @@ +# [TwitterBio.com](https://www.twitterbio.com/) + +This project generates Twitter bios for you using AI. + +[![Twitter Bio Generator](./public/screenshot.png)](https://www.twitterbio.com) + +## How it works + +This project uses the [OpenAI GPT-3 API](https://openai.com/api/) (specifically, text-davinci-003) and [Vercel Edge functions](https://vercel.com/features/edge-functions) with streaming. It constructs a prompt based on the form and user input, sends it to the GPT-3 API via a Vercel Edge function, then streams the response back to the application. + +Video and blog post coming soon on how to build apps with OpenAI and Vercel Edge functions! + +## Running Locally + +After cloning the repo, go to [OpenAI](https://beta.openai.com/account/api-keys) to make an account and put your API key in a file called `.env`. + +Then, run the application in the command line and it will be available at `http://localhost:3000`. + +```bash +npm run dev +``` + +## One-Click Deploy + +Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=vercel-examples): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/Nutlope/twitterbio&env=OPENAI_API_KEY&project-name=twitter-bio-generator&repo-name=twitterbio) diff --git a/components/DropDown.tsx b/client/components/DropDown.tsx similarity index 100% rename from components/DropDown.tsx rename to client/components/DropDown.tsx diff --git a/client/components/DropDownNew.tsx b/client/components/DropDownNew.tsx new file mode 100644 index 00000000..0a77c028 --- /dev/null +++ b/client/components/DropDownNew.tsx @@ -0,0 +1,113 @@ +// interface DropDownProps { +// value: string | undefined; +// options: { value: string; label: string }[] | undefined; +// onChange: (event: React.ChangeEvent) => void; +// } + +// export default function DropDownNew({ value, options, onChange }: DropDownProps) { +// return ( + +// +// ); +// }; + + +import { Menu, Transition } from "@headlessui/react"; +import { + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "@heroicons/react/20/solid"; +import { Fragment } from "react"; + +function classNames(...classes: string[]) { + return classes.filter(Boolean).join(" "); +} + +// type vibeType = "Professional" | "Casual" | "Funny"; + +// interface DropDownProps { +// vibe: "Professional" | "Casual" | "Funny"; +// setVibe: (vibe: vibeType) => void; +// } + +// let vibes: vibeType[] = ["Professional", "Casual", "Funny"]; + +interface FormData { + [key: string]: string | undefined; + } + + +interface DropDownProps { + value: string | undefined; + name: string; + options: { value: string; label: string }[] | undefined; + formData: FormData + setFormData: (newFormData: FormData) => void; +} + +export default function DropDown({ value, name, options, formData, setFormData }: DropDownProps) { + return ( + +
+ + {value} + +
+ + + +
+ {options && options.map((option) => ( + + {({ active }) => ( + + )} + + ))} +
+
+
+
+ ); +} + + diff --git a/components/Footer.tsx b/client/components/Footer.tsx similarity index 100% rename from components/Footer.tsx rename to client/components/Footer.tsx diff --git a/components/GitHub.tsx b/client/components/GitHub.tsx similarity index 100% rename from components/GitHub.tsx rename to client/components/GitHub.tsx diff --git a/components/Header.tsx b/client/components/Header.tsx similarity index 100% rename from components/Header.tsx rename to client/components/Header.tsx diff --git a/components/LoadingDots.tsx b/client/components/LoadingDots.tsx similarity index 100% rename from components/LoadingDots.tsx rename to client/components/LoadingDots.tsx diff --git a/components/ResizablePanel.tsx b/client/components/ResizablePanel.tsx similarity index 100% rename from components/ResizablePanel.tsx rename to client/components/ResizablePanel.tsx diff --git a/client/components/home/card.tsx b/client/components/home/card.tsx new file mode 100644 index 00000000..665d1aea --- /dev/null +++ b/client/components/home/card.tsx @@ -0,0 +1,54 @@ +import Link from "next/link"; +import { ReactNode } from "react"; +import ReactMarkdown from "react-markdown"; +import Balancer from "react-wrap-balancer"; + +export default function Card({ + displayName, + slug, + large, +}: { + displayName: string; + slug: string; + large?: boolean; +}) { + return ( + +
+
+

+ {displayName} +

+ {/*
+ + ( + + ), + code: ({ node, ...props }) => ( + + ), + }} + > + {description} + + +
*/} +
+
+ + ); +} diff --git a/client/components/home/component-grid.tsx b/client/components/home/component-grid.tsx new file mode 100644 index 00000000..f2106919 --- /dev/null +++ b/client/components/home/component-grid.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { useDemoModal } from "../home/demo-modal"; +import Popover from "../shared/popover"; +import Tooltip from "../shared/tooltip"; +import { ChevronDown } from "lucide-react"; + +export default function ComponentGrid() { + const { DemoModal, setShowDemoModal } = useDemoModal(); + const [openPopover, setOpenPopover] = useState(false); + return ( +
+ + + + + + +
+ } + openPopover={openPopover} + setOpenPopover={setOpenPopover} + > + + + +
+

Tooltip

+
+
+ + ); +} diff --git a/client/components/home/demo-modal.tsx b/client/components/home/demo-modal.tsx new file mode 100644 index 00000000..9ff38752 --- /dev/null +++ b/client/components/home/demo-modal.tsx @@ -0,0 +1,58 @@ +import Modal from "../shared/modal"; +import { + useState, + Dispatch, + SetStateAction, + useCallback, + useMemo, +} from "react"; +import Image from "next/image"; + +const DemoModal = ({ + showDemoModal, + setShowDemoModal, +}: { + showDemoModal: boolean; + setShowDemoModal: Dispatch>; +}) => { + return ( + +
+
+ + Precedent Logo + +

Precedent

+

+ Precedent is an opinionated collection of components, hooks, and + utilities for your Next.js project. +

+
+
+ + ); +}; + +export function useDemoModal() { + const [showDemoModal, setShowDemoModal] = useState(false); + + const DemoModalCallback = useCallback(() => { + return ( + + ); + }, [showDemoModal, setShowDemoModal]); + + return useMemo( + () => ({ setShowDemoModal, DemoModal: DemoModalCallback }), + [setShowDemoModal, DemoModalCallback], + ); +} diff --git a/client/components/home/web-vitals.tsx b/client/components/home/web-vitals.tsx new file mode 100644 index 00000000..864d752c --- /dev/null +++ b/client/components/home/web-vitals.tsx @@ -0,0 +1,37 @@ +import { motion } from "framer-motion"; +import CountingNumbers from "../shared/counting-numbers"; + +export default function WebVitals() { + return ( +
+ + + + +
+ ); +} diff --git a/client/components/layout/index.tsx b/client/components/layout/index.tsx new file mode 100644 index 00000000..85becc96 --- /dev/null +++ b/client/components/layout/index.tsx @@ -0,0 +1,76 @@ +import { FADE_IN_ANIMATION_SETTINGS } from "../../lib/constants"; +import { AnimatePresence, motion } from "framer-motion"; +import { useSession } from "next-auth/react"; + +import Link from "next/link"; +import { ReactNode } from "react"; +import useScroll from "../../lib/hooks/use-scroll"; +import Meta from "./meta"; +import { useSignInModal } from "./sign-in-modal"; +import UserDropdown from "./user-dropdown"; +import { useRouter } from "next/router"; + +export default function Layout({ + meta, + children, +}: { + meta?: { + title?: string; + description?: string; + image?: string; + }; + children: ReactNode; +}) { + const { data: session, status } = useSession(); + const { SignInModal, setShowSignInModal } = useSignInModal(); + const scrolled = useScroll(50); + const router = useRouter() + return ( + <> + + + {/*
*/} +
+
+ + {/* Precedent logo */} +

Toolchest

+ +
+ + {!session && status !== "loading" ? ( + setShowSignInModal(true)} + {...FADE_IN_ANIMATION_SETTINGS} + > + Sign In + + ) : ( + + )} + +
+
+
+
+ {children} +
+
+

© 2023 All rights reserved.

+
+ + ); +} diff --git a/client/components/layout/meta.tsx b/client/components/layout/meta.tsx new file mode 100644 index 00000000..39d81983 --- /dev/null +++ b/client/components/layout/meta.tsx @@ -0,0 +1,36 @@ +import Head from "next/head"; + +const DOMAIN = "https://precedent.dev"; + +export default function Meta({ + title = "Toolchest", + description = "Toolchest is the all-in-one solution for your Next.js project. It includes a design system, authentication, analytics, and more.", + image = `${DOMAIN}/api/og`, +}: { + title?: string; + description?: string; + image?: string; +}) { + return ( + + {title} + + + + + + + + + + + + + + + + + + + ); +} diff --git a/client/components/layout/sign-in-modal.tsx b/client/components/layout/sign-in-modal.tsx new file mode 100644 index 00000000..e397bc14 --- /dev/null +++ b/client/components/layout/sign-in-modal.tsx @@ -0,0 +1,86 @@ +import Modal from "../shared/modal"; +import { signIn } from "next-auth/react"; +import { + useState, + Dispatch, + SetStateAction, + useCallback, + useMemo, +} from "react"; +import { LoadingDots, Google } from "../shared/icons"; +import Image from "next/image"; + +const SignInModal = ({ + showSignInModal, + setShowSignInModal, +}: { + showSignInModal: boolean; + setShowSignInModal: Dispatch>; +}) => { + const [signInClicked, setSignInClicked] = useState(false); + + return ( + +
+
+ + Logo + +

Sign In

+ {/*

+ This is strictly for demo purposes - only your email and profile + picture will be stored. +

*/} +
+ +
+ +
+
+
+ ); +}; + +export function useSignInModal() { + const [showSignInModal, setShowSignInModal] = useState(false); + + const SignInModalCallback = useCallback(() => { + return ( + + ); + }, [showSignInModal, setShowSignInModal]); + + return useMemo( + () => ({ setShowSignInModal, SignInModal: SignInModalCallback }), + [setShowSignInModal, SignInModalCallback], + ); +} diff --git a/client/components/layout/user-dropdown.tsx b/client/components/layout/user-dropdown.tsx new file mode 100644 index 00000000..d36a7eb6 --- /dev/null +++ b/client/components/layout/user-dropdown.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { signOut, useSession } from "next-auth/react"; +import { LayoutDashboard, LogOut } from "lucide-react"; +import Popover from "../shared/popover"; +import Image from "next/image"; +import { motion } from "framer-motion"; +import { FADE_IN_ANIMATION_SETTINGS } from "../../lib/constants"; +import Link from "next/link"; + +export default function UserDropdown() { + const { data: session } = useSession(); + const { email, image } = session?.user || {}; + const [openPopover, setOpenPopover] = useState(false); + + if (!email) return null; + + return ( + + + + +

Dashboard

+ + + +
+ } + align="end" + openPopover={openPopover} + setOpenPopover={setOpenPopover} + > + + + + ); +} diff --git a/client/components/shared/DropDown.tsx b/client/components/shared/DropDown.tsx new file mode 100644 index 00000000..82711e17 --- /dev/null +++ b/client/components/shared/DropDown.tsx @@ -0,0 +1,78 @@ +import { Menu, Transition } from "@headlessui/react"; + +import { + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "@radix-ui/react-icons"; +import { Fragment } from "react"; + +function classNames(...classes: string[]) { + return classes.filter(Boolean).join(" "); +} + +export type VibeType = "Professional" | "Casual" | "Funny"; + +interface DropDownProps { + vibe: VibeType; + setVibe: (vibe: VibeType) => void; +} + +let vibes: VibeType[] = ["Professional", "Casual", "Funny"]; + +export default function DropDown({ vibe, setVibe }: DropDownProps) { + return ( + +
+ + {vibe} + +
+ + + +
+ {vibes.map((vibeItem) => ( + + {({ active }: any) => ( + + )} + + ))} +
+
+
+
+ ); +} diff --git a/client/components/shared/LoadingDots.tsx b/client/components/shared/LoadingDots.tsx new file mode 100644 index 00000000..33e2e6a4 --- /dev/null +++ b/client/components/shared/LoadingDots.tsx @@ -0,0 +1,23 @@ +import styles from "../../styles/loading-dots.module.css"; + +const LoadingDots = ({ + color = "#000", + style = "small", +}: { + color: string; + style: string; +}) => { + return ( + + + + + + ); +}; + +export default LoadingDots; + +LoadingDots.defaultProps = { + style: "small", +}; diff --git a/client/components/shared/counting-numbers.tsx b/client/components/shared/counting-numbers.tsx new file mode 100644 index 00000000..4be1c101 --- /dev/null +++ b/client/components/shared/counting-numbers.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; + +export default function CountingNumbers({ + value, + className, + start = 0, + duration = 800, +}: { + value: number; + className: string; + start?: number; + duration?: number; +}) { + const [count, setCount] = useState(start); + + useEffect(() => { + let startTime: number | undefined; + const animateCount = (timestamp: number) => { + if (!startTime) startTime = timestamp; + const timePassed = timestamp - startTime; + const progress = timePassed / duration; + const currentCount = easeOutQuad(progress, 0, value, 1); + if (currentCount >= value) { + setCount(value); + return; + } + setCount(currentCount); + requestAnimationFrame(animateCount); + }; + requestAnimationFrame(animateCount); + }, [value, duration]); + + return

{Intl.NumberFormat().format(count)}

; +} +const easeOutQuad = (t: number, b: number, c: number, d: number) => { + t /= d; + return Math.round(-c * t * (t - 2) + b); +}; diff --git a/client/components/shared/icons/expanding-arrow.tsx b/client/components/shared/icons/expanding-arrow.tsx new file mode 100644 index 00000000..819fd748 --- /dev/null +++ b/client/components/shared/icons/expanding-arrow.tsx @@ -0,0 +1,36 @@ +export default function ExpandingArrow({ className }: { className?: string }) { + return ( +
+ + + + + + +
+ ); +} diff --git a/client/components/shared/icons/github.tsx b/client/components/shared/icons/github.tsx new file mode 100644 index 00000000..ff13f393 --- /dev/null +++ b/client/components/shared/icons/github.tsx @@ -0,0 +1,14 @@ +export default function Github({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/client/components/shared/icons/google.tsx b/client/components/shared/icons/google.tsx new file mode 100644 index 00000000..660f7b7f --- /dev/null +++ b/client/components/shared/icons/google.tsx @@ -0,0 +1,47 @@ +export default function Google({ className }: { className: string }) { + return ( + + + + + + + + + + + + + + + + + + {" "} + + ); +} diff --git a/client/components/shared/icons/index.tsx b/client/components/shared/icons/index.tsx new file mode 100644 index 00000000..17955047 --- /dev/null +++ b/client/components/shared/icons/index.tsx @@ -0,0 +1,7 @@ +export { default as LoadingDots } from "./loading-dots"; +export { default as LoadingCircle } from "./loading-circle"; +export { default as LoadingSpinner } from "./loading-spinner"; +export { default as ExpandingArrow } from "./expanding-arrow"; +export { default as Github } from "./github"; +export { default as Twitter } from "./twitter"; +export { default as Google } from "./google"; diff --git a/client/components/shared/icons/loading-circle.tsx b/client/components/shared/icons/loading-circle.tsx new file mode 100644 index 00000000..46492754 --- /dev/null +++ b/client/components/shared/icons/loading-circle.tsx @@ -0,0 +1,20 @@ +export default function LoadingCircle() { + return ( + + ); +} diff --git a/client/components/shared/icons/loading-dots.module.css b/client/components/shared/icons/loading-dots.module.css new file mode 100644 index 00000000..3b639020 --- /dev/null +++ b/client/components/shared/icons/loading-dots.module.css @@ -0,0 +1,40 @@ +.loading { + display: inline-flex; + align-items: center; +} + +.loading .spacer { + margin-right: 2px; +} + +.loading span { + animation-name: blink; + animation-duration: 1.4s; + animation-iteration-count: infinite; + animation-fill-mode: both; + width: 5px; + height: 5px; + border-radius: 50%; + display: inline-block; + margin: 0 1px; +} + +.loading span:nth-of-type(2) { + animation-delay: 0.2s; +} + +.loading span:nth-of-type(3) { + animation-delay: 0.4s; +} + +@keyframes blink { + 0% { + opacity: 0.2; + } + 20% { + opacity: 1; + } + 100% { + opacity: 0.2; + } +} diff --git a/client/components/shared/icons/loading-dots.tsx b/client/components/shared/icons/loading-dots.tsx new file mode 100644 index 00000000..23ebed0e --- /dev/null +++ b/client/components/shared/icons/loading-dots.tsx @@ -0,0 +1,13 @@ +import styles from "./loading-dots.module.css"; + +const LoadingDots = ({ color = "#000" }: { color?: string }) => { + return ( + + + + + + ); +}; + +export default LoadingDots; diff --git a/client/components/shared/icons/loading-spinner.module.css b/client/components/shared/icons/loading-spinner.module.css new file mode 100644 index 00000000..8dc5706a --- /dev/null +++ b/client/components/shared/icons/loading-spinner.module.css @@ -0,0 +1,79 @@ +.spinner { + color: gray; + display: inline-block; + position: relative; + width: 80px; + height: 80px; + transform: scale(0.3) translateX(-95px); +} +.spinner div { + transform-origin: 40px 40px; + animation: spinner 1.2s linear infinite; +} +.spinner div:after { + content: " "; + display: block; + position: absolute; + top: 3px; + left: 37px; + width: 6px; + height: 20px; + border-radius: 20%; + background: black; +} +.spinner div:nth-child(1) { + transform: rotate(0deg); + animation-delay: -1.1s; +} +.spinner div:nth-child(2) { + transform: rotate(30deg); + animation-delay: -1s; +} +.spinner div:nth-child(3) { + transform: rotate(60deg); + animation-delay: -0.9s; +} +.spinner div:nth-child(4) { + transform: rotate(90deg); + animation-delay: -0.8s; +} +.spinner div:nth-child(5) { + transform: rotate(120deg); + animation-delay: -0.7s; +} +.spinner div:nth-child(6) { + transform: rotate(150deg); + animation-delay: -0.6s; +} +.spinner div:nth-child(7) { + transform: rotate(180deg); + animation-delay: -0.5s; +} +.spinner div:nth-child(8) { + transform: rotate(210deg); + animation-delay: -0.4s; +} +.spinner div:nth-child(9) { + transform: rotate(240deg); + animation-delay: -0.3s; +} +.spinner div:nth-child(10) { + transform: rotate(270deg); + animation-delay: -0.2s; +} +.spinner div:nth-child(11) { + transform: rotate(300deg); + animation-delay: -0.1s; +} +.spinner div:nth-child(12) { + transform: rotate(330deg); + animation-delay: 0s; +} +@keyframes spinner { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} diff --git a/client/components/shared/icons/loading-spinner.tsx b/client/components/shared/icons/loading-spinner.tsx new file mode 100644 index 00000000..1c8e704a --- /dev/null +++ b/client/components/shared/icons/loading-spinner.tsx @@ -0,0 +1,20 @@ +import styles from "./loading-spinner.module.css"; + +export default function LoadingSpinner() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/client/components/shared/icons/twitter.tsx b/client/components/shared/icons/twitter.tsx new file mode 100644 index 00000000..693309df --- /dev/null +++ b/client/components/shared/icons/twitter.tsx @@ -0,0 +1,14 @@ +export default function Twitter({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/client/components/shared/leaflet.tsx b/client/components/shared/leaflet.tsx new file mode 100644 index 00000000..e5eaa6da --- /dev/null +++ b/client/components/shared/leaflet.tsx @@ -0,0 +1,68 @@ +import { useEffect, useRef, ReactNode, Dispatch, SetStateAction } from "react"; +import { AnimatePresence, motion, useAnimation } from "framer-motion"; + +export default function Leaflet({ + setShow, + children, +}: { + setShow: Dispatch>; + children: ReactNode; +}) { + const leafletRef = useRef(null); + const controls = useAnimation(); + const transitionProps = { type: "spring", stiffness: 500, damping: 30 }; + useEffect(() => { + controls.start({ + y: 20, + transition: transitionProps, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function handleDragEnd(_: any, info: any) { + const offset = info.offset.y; + const velocity = info.velocity.y; + const height = leafletRef.current?.getBoundingClientRect().height || 0; + if (offset > height / 2 || velocity > 800) { + await controls.start({ y: "100%", transition: transitionProps }); + setShow(false); + } else { + controls.start({ y: 0, transition: transitionProps }); + } + } + + return ( + + +
+
+
+
+ {children} + + setShow(false)} + /> + + ); +} diff --git a/client/components/shared/modal.tsx b/client/components/shared/modal.tsx new file mode 100644 index 00000000..ffcd9f65 --- /dev/null +++ b/client/components/shared/modal.tsx @@ -0,0 +1,78 @@ +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useRef, +} from "react"; +import FocusTrap from "focus-trap-react"; +import { AnimatePresence, motion } from "framer-motion"; +import Leaflet from "./leaflet"; +import useWindowSize from "../../lib/hooks/use-window-size"; + +export default function Modal({ + children, + showModal, + setShowModal, +}: { + children: React.ReactNode; + showModal: boolean; + setShowModal: Dispatch>; +}) { + const desktopModalRef = useRef(null); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + setShowModal(false); + } + }, + [setShowModal], + ); + + useEffect(() => { + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [onKeyDown]); + + const { isMobile, isDesktop } = useWindowSize(); + + return ( + + {showModal && ( + <> + {isMobile && {children}} + {isDesktop && ( + <> + + { + if (desktopModalRef.current === e.target) { + setShowModal(false); + } + }} + > + {children} + + + setShowModal(false)} + /> + + )} + + )} + + ); +} diff --git a/client/components/shared/popover.tsx b/client/components/shared/popover.tsx new file mode 100644 index 00000000..e44682bc --- /dev/null +++ b/client/components/shared/popover.tsx @@ -0,0 +1,36 @@ +import { Dispatch, SetStateAction, ReactNode, useRef } from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import useWindowSize from "../../lib/hooks/use-window-size"; +import Leaflet from "./leaflet"; + +export default function Popover({children,content,align = "center",openPopover,setOpenPopover}: { + children: ReactNode; + content: ReactNode | string; + align?: "center" | "start" | "end"; + openPopover: boolean; + setOpenPopover: Dispatch>; +}) { + const { isMobile, isDesktop } = useWindowSize(); + return ( + <> + {isMobile && children} + {openPopover && isMobile && ( + {content} + )} + {isDesktop && ( + + + {children} + + + {content} + + + )} + + ); +} diff --git a/client/components/shared/tooltip.tsx b/client/components/shared/tooltip.tsx new file mode 100644 index 00000000..0424cede --- /dev/null +++ b/client/components/shared/tooltip.tsx @@ -0,0 +1,70 @@ +import { ReactNode, useState } from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { AnimatePresence } from "framer-motion"; +import useWindowSize from "../../lib/hooks/use-window-size"; +import Leaflet from "./leaflet"; + +export default function Tooltip({ + children, + content, + fullWidth, +}: { + children: ReactNode; + content: ReactNode | string; + fullWidth?: boolean; +}) { + const [openTooltip, setOpenTooltip] = useState(false); + + const { isMobile, isDesktop } = useWindowSize(); + + return ( + <> + {isMobile && ( + + )} + {openTooltip && isMobile && ( + + {typeof content === "string" ? ( + + {content} + + ) : ( + content + )} + + )} + {isDesktop && ( + + + + {children} + + + + {typeof content === "string" ? ( +
+ + {content} + +
+ ) : ( + content + )} + +
+
+
+ )} + + ); +} diff --git a/client/lib/constants.ts b/client/lib/constants.ts new file mode 100644 index 00000000..38abc24f --- /dev/null +++ b/client/lib/constants.ts @@ -0,0 +1,18 @@ +export const FADE_IN_ANIMATION_SETTINGS = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + transition: { duration: 0.2 }, +}; + +export const FADE_DOWN_ANIMATION_VARIANTS = { + hidden: { opacity: 0, y: -10 }, + show: { opacity: 1, y: 0, transition: { type: "spring" } }, +}; + +export const FADE_UP_ANIMATION_VARIANTS = { + hidden: { opacity: 0, y: 10 }, + show: { opacity: 1, y: 0, transition: { type: "spring" } }, +}; + +export const DEPLOY_URL = + "https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsteven-tey%2Fprecedent&project-name=precedent&repository-name=precedent&demo-title=Precedent&demo-description=An%20opinionated%20collection%20of%20components%2C%20hooks%2C%20and%20utilities%20for%20your%20Next%20project.&demo-url=https%3A%2F%2Fprecedent.dev&demo-image=https%3A%2F%2Fprecedent.dev%2Fapi%2Fog&env=DATABASE_URL,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,NEXTAUTH_SECRET&envDescription=How%20to%20get%20these%20env%20variables%3A&envLink=https%3A%2F%2Fgithub.com%2Fsteven-tey%2Fprecedent%2Fblob%2Fmain%2F.env.example"; diff --git a/client/lib/hooks/use-intersection-observer.ts b/client/lib/hooks/use-intersection-observer.ts new file mode 100644 index 00000000..516428df --- /dev/null +++ b/client/lib/hooks/use-intersection-observer.ts @@ -0,0 +1,43 @@ +import { RefObject, useEffect, useState } from "react"; + +interface Args extends IntersectionObserverInit { + freezeOnceVisible?: boolean; +} + +function useIntersectionObserver( + elementRef: RefObject, + { + threshold = 0, + root = null, + rootMargin = "0%", + freezeOnceVisible = false, + }: Args, +): IntersectionObserverEntry | undefined { + const [entry, setEntry] = useState(); + + const frozen = entry?.isIntersecting && freezeOnceVisible; + + const updateEntry = ([entry]: IntersectionObserverEntry[]): void => { + setEntry(entry); + }; + + useEffect(() => { + const node = elementRef?.current; // DOM Ref + const hasIOSupport = !!window.IntersectionObserver; + + if (!hasIOSupport || frozen || !node) return; + + const observerParams = { threshold, root, rootMargin }; + const observer = new IntersectionObserver(updateEntry, observerParams); + + observer.observe(node); + + return () => observer.disconnect(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [threshold, root, rootMargin, frozen]); + + return entry; +} + +export default useIntersectionObserver; diff --git a/client/lib/hooks/use-local-storage.ts b/client/lib/hooks/use-local-storage.ts new file mode 100644 index 00000000..aa3984b5 --- /dev/null +++ b/client/lib/hooks/use-local-storage.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +const useLocalStorage = ( + key: string, + initialValue: T, +): [T, (value: T) => void] => { + const [storedValue, setStoredValue] = useState(initialValue); + + useEffect(() => { + // Retrieve from localStorage + const item = window.localStorage.getItem(key); + if (item) { + setStoredValue(JSON.parse(item)); + } + }, [key]); + + const setValue = (value: T) => { + // Save state + setStoredValue(value); + // Save to localStorage + window.localStorage.setItem(key, JSON.stringify(value)); + }; + return [storedValue, setValue]; +}; + +export default useLocalStorage; diff --git a/client/lib/hooks/use-scroll.ts b/client/lib/hooks/use-scroll.ts new file mode 100644 index 00000000..3c8014e3 --- /dev/null +++ b/client/lib/hooks/use-scroll.ts @@ -0,0 +1,16 @@ +import { useCallback, useEffect, useState } from "react"; + +export default function useScroll(threshold: number) { + const [scrolled, setScrolled] = useState(false); + + const onScroll = useCallback(() => { + setScrolled(window.pageYOffset > threshold); + }, [threshold]); + + useEffect(() => { + window.addEventListener("scroll", onScroll); + return () => window.removeEventListener("scroll", onScroll); + }, [onScroll]); + + return scrolled; +} diff --git a/client/lib/hooks/use-window-size.ts b/client/lib/hooks/use-window-size.ts new file mode 100644 index 00000000..53a70c09 --- /dev/null +++ b/client/lib/hooks/use-window-size.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; + +export default function useWindowSize() { + const [windowSize, setWindowSize] = useState<{ + width: number | undefined; + height: number | undefined; + }>({ + width: undefined, + height: undefined, + }); + + useEffect(() => { + // Handler to call on window resize + function handleResize() { + // Set window width/height to state + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + } + + // Add event listener + window.addEventListener("resize", handleResize); + + // Call handler right away so state gets updated with initial window size + handleResize(); + + // Remove event listener on cleanup + return () => window.removeEventListener("resize", handleResize); + }, []); // Empty array ensures that effect is only run on mount + + return { + windowSize, + isMobile: typeof windowSize?.width === "number" && windowSize?.width < 768, + isDesktop: + typeof windowSize?.width === "number" && windowSize?.width >= 768, + }; +} diff --git a/client/lib/prisma.ts b/client/lib/prisma.ts new file mode 100644 index 00000000..de08f2a2 --- /dev/null +++ b/client/lib/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export default prisma; \ No newline at end of file diff --git a/client/lib/util.ts b/client/lib/util.ts new file mode 100644 index 00000000..cbf916d2 --- /dev/null +++ b/client/lib/util.ts @@ -0,0 +1,18 @@ +export function replaceVariables(prompt: string, variables: any) { + let newPrompt = prompt; + for (const key in variables) { + if (variables.hasOwnProperty(key)) { + const value = variables[key]; + if (typeof value === 'string') { + newPrompt = newPrompt.replace(`{{${key}}}`, value); + } + } + } + return newPrompt; +} + +export function validatePrompt(prompt: string) { + const regex = /{{([^}]+)}}/g; + const match = prompt.match(regex); + return !match; +} \ No newline at end of file diff --git a/next.config.js b/client/next.config.js similarity index 60% rename from next.config.js rename to client/next.config.js index 8b61df4e..0fbd3547 100644 --- a/next.config.js +++ b/client/next.config.js @@ -1,4 +1,7 @@ /** @type {import('next').NextConfig} */ module.exports = { reactStrictMode: true, + images: { + domains: ['lh3.googleusercontent.com'] +} } diff --git a/client/package.json b/client/package.json new file mode 100644 index 00000000..5c1193cc --- /dev/null +++ b/client/package.json @@ -0,0 +1,57 @@ +{ + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@headlessui/react": "^1.7.9", + "@headlessui/tailwindcss": "^0.1.2", + "@heroicons/react": "^2.0.15", + "@next-auth/prisma-adapter": "^1.0.5", + "@next/font": "^13.1.6", + "@prisma/client": "^4.8.1", + "@radix-ui/react-icons": "^1.1.1", + "@radix-ui/react-popover": "^1.0.2", + "@radix-ui/react-tooltip": "^1.0.2", + "@types/node": "18.11.18", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.10", + "@vercel/analytics": "^0.1.6", + "@vercel/og": "^0.0.26", + "axios": "^1.3.2", + "classnames": "^2.3.2", + "eslint": "8.31.0", + "eslint-config-next": "13.1.1", + "eventsource": "^2.0.2", + "eventsource-parser": "^0.1.0", + "focus-trap-react": "^10.0.2", + "framer-motion": "^8.4.2", + "lucide-react": "0.105.0-alpha.4", + "ms": "^2.1.3", + "next": "13.1.1", + "next-auth": "^4.19.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-hot-toast": "^2.4.0", + "react-markdown": "^8.0.4", + "react-use-measure": "^2.1.1", + "react-wrap-balancer": "^0.3.0", + "typescript": "4.9.4", + "use-debounce": "^9.0.3" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.3", + "@tailwindcss/line-clamp": "^0.4.2", + "@tailwindcss/typography": "^0.5.9", + "@types/ms": "^0.7.31", + "autoprefixer": "^10.4.13", + "concurrently": "^7.6.0", + "postcss": "^8.4.21", + "prettier": "^2.8.2", + "prettier-plugin-tailwindcss": "^0.2.1", + "prisma": "^4.8.1", + "tailwindcss": "^3.2.4" + } +} diff --git a/client/pages/_app.tsx b/client/pages/_app.tsx new file mode 100644 index 00000000..e1b6ba7a --- /dev/null +++ b/client/pages/_app.tsx @@ -0,0 +1,29 @@ +import { Analytics } from "@vercel/analytics/react"; +import type { AppProps } from "next/app"; +import "../styles/globals.css"; +import type { Session } from "next-auth"; +import { SessionProvider } from "next-auth/react"; +import { Provider as RWBProvider } from "react-wrap-balancer"; +import cx from "classnames"; +import { Inter } from "@next/font/google"; + +const inter = Inter({ + variable: "--font-inter", + subsets: ["latin"], +}); + +export default function MyApp({ + Component, + pageProps: { session, ...pageProps }, +}: AppProps<{ session: Session }>) { + return ( + + +
+ +
+
+ +
+ ); +} diff --git a/pages/_document.tsx b/client/pages/_document.tsx similarity index 100% rename from pages/_document.tsx rename to client/pages/_document.tsx diff --git a/client/pages/api/allTools.ts b/client/pages/api/allTools.ts new file mode 100644 index 00000000..f2ef92bf --- /dev/null +++ b/client/pages/api/allTools.ts @@ -0,0 +1,26 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import prisma from "../../lib/prisma"; + +/*export const config = { + runtime: "edge", +};*/ + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const result = await prisma.tool.findMany({ + select: { + tool_name: true, + } + }); + + if (result.length === 0) { + res.status(400).json({ error: 'Not found' }); + } + + if (!result[0]) { + res.status(400).json({ error: 'No fields found for this tool' }); + } + + return res.json(result); +} + +export default handler; \ No newline at end of file diff --git a/client/pages/api/auth/[...nextauth].ts b/client/pages/api/auth/[...nextauth].ts new file mode 100644 index 00000000..67dfa6a5 --- /dev/null +++ b/client/pages/api/auth/[...nextauth].ts @@ -0,0 +1,18 @@ +import NextAuth, { NextAuthOptions } from "next-auth"; +import { PrismaAdapter } from "@next-auth/prisma-adapter"; +import prisma from "../../../lib/prisma"; +import GoogleProvider from "next-auth/providers/google"; + +export const authOptions: NextAuthOptions = { + adapter: PrismaAdapter(prisma), + providers: [ + GoogleProvider({ + clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID as string, + clientSecret: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_SECRET as string, + + }), + ], + +}; + +export default NextAuth(authOptions); diff --git a/client/pages/api/completion.ts b/client/pages/api/completion.ts new file mode 100644 index 00000000..698029c5 --- /dev/null +++ b/client/pages/api/completion.ts @@ -0,0 +1,47 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + + +export const config = { + runtime: "edge", + }; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { prompt } = req.body; + + if (!prompt) { + return res.status(400).send("No prompt in the request"); + } + + const payload = { + model: "text-davinci-003", + prompt, + temperature: 0.7, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + max_tokens: 200, + stream: true, + n: 1, + }; + + try { + const res = await fetch("https://api.openai.com/v1/completions", { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.OPENAI_API_KEY ?? ""}`, + }, + method: "POST", + body: JSON.stringify(payload), + }); + + const data = res.body; + + return new Response(data, { + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + } catch (err) { + res.status(500).send(err); + } +} + +export default handler; \ No newline at end of file diff --git a/client/pages/api/generate.ts b/client/pages/api/generate.ts new file mode 100644 index 00000000..bd31c84f --- /dev/null +++ b/client/pages/api/generate.ts @@ -0,0 +1,93 @@ + + + +import type { NextApiRequest, NextApiResponse } from "next"; +import prisma from '../../lib/prisma'; +import axios from 'axios'; +import { replaceVariables, validatePrompt } from "../../lib/util"; + +if (!process.env.OPENAI_API_KEY) { + throw new Error("Missing env var from OpenAI"); +} + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { toolName, prompt, formValues } = req.body; + + console.log(formValues); + + // if (!prompt) { + // return new Response("No prompt in the request", { status: 400 }); + // } + + + try { + const modelResponse = await prisma.tool.findMany({ + where: { tool_name: toolName } + }); + + if (modelResponse.length === 0) { + return res.status(400).json({ error: 'Error fetching prompt' }); + } + + console.log("prrsp", modelResponse[0]); + + const model = modelResponse[0]; + console.log("prompt", prompt); + + const promptWithVariables = replaceVariables(prompt, formValues); + console.log("variables", promptWithVariables); + const isValid = validatePrompt(promptWithVariables); + + if (!isValid) { + console.log('invalid'); + return res.status(400).send({ error: 'Error parsing prompt' }); + } + console.log("stop", model.stop); + + try { + const response = await axios.post('https://api.openai.com/v1/completions', { + model: "text-davinci-003", + prompt: promptWithVariables, + temperature: model.temperature, + max_tokens: model.max_tokens, + top_p: model.top_p, + frequency_penalty: model.frequency_penalty, + presence_penalty: model.presence_penalty, + n: model.n_responses, + stop: model.stop! + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, + } + }); + + console.log("response", response.data.choices[0].text) + // const splitResponse = splitList(response.data.choices[0].text) + // console.log("splitList", splitResponse); + // res.send(splitResponse); + + //deletes newline at the end (I think) + //const allOutputs = response.data.choices.map((choice: any) => choice.text.trim().replace(/\\n/g, "\n")); + + const choices = response.data.choices; + let i = 0; + for (const choice of choices) { + choices[i].text = choices[i].text.replaceAll('\\n', ''); + i++; + } + + console.log("all", choices); + return res.status(200).json(choices); + + } catch (error) { + console.error(error); + return res.status(500).send(error); + } + + } catch (error) { + return res.status(500).json({ error: 'Internal error' }); + } +}; + +export default handler; \ No newline at end of file diff --git a/client/pages/api/schema/[tool].ts b/client/pages/api/schema/[tool].ts new file mode 100644 index 00000000..7e6f316a --- /dev/null +++ b/client/pages/api/schema/[tool].ts @@ -0,0 +1,66 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import prisma from "../../../lib/prisma"; + + +const convertData = (data: Array, toolName: any, displayName: any) => { + const schema = { + tool_name: toolName, + display_name: displayName, + fields: new Array(), + prompt: data[0].prompt, + }; + + let newField: any; + + data.forEach(field => { + newField = { + field_name: field.field_name, + type: field.type, + label: field.label, + required: field.required, + command: field.command, + options: new Array(), + placeholder: '', + }; + + if (field.options) { + newField.options = field.options.map((option: any) => { + return { + value: option.value, + label: option.label + }; + }); + } + + if (field.placeholder) { + newField.placeholder = field.placeholder; + } + + schema.fields.push(newField); + }); + + return [schema]; +} + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { tool } = req.query; + + const result: Array = await prisma.$queryRaw`SELECT tool.*, field.* FROM field JOIN tool ON field.tool_id=tool.tool_id where tool.tool_name = ${tool}` + if (result.length === 0) { + return res.status(400).send({ error: 'Error fetching tool information' }); + } + + if (!result[0]) { + return res.status(400).send({ error: 'Error fetching tool information. No tool found' }); + } + + const toolData = convertData(result, tool, result[0].display_name); + + if (!tool) { + return res.status(400).send({ error: 'Invalid tool' }); + } + + return res.json(toolData); +} + +export default handler; \ No newline at end of file diff --git a/client/pages/api/toolModel.ts b/client/pages/api/toolModel.ts new file mode 100644 index 00000000..efaee083 --- /dev/null +++ b/client/pages/api/toolModel.ts @@ -0,0 +1,71 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import prisma from '../../lib/prisma'; + + +export const config = { + runtime: "edge", +}; + +function replaceVariables(prompt: any, variables: Object[]) { + let newPrompt = prompt; + for (const key in variables) { + if (variables.hasOwnProperty(key)) { + const value = variables[key]; + newPrompt = newPrompt.replace(`{{${key}}}`, value); + } + } + return newPrompt; +} + +function validatePrompt(prompt: string) { + const regex = /{{([^}]+)}}/g; + const match = prompt.match(regex); + return !match; +} + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + try { + const { toolName, formData } = req.body; + + const result = await prisma.tool.findMany({ + where: { tool_name: toolName } + }); + + if (result.length == 0) { + return res.status(400).send({ error: 'Error fetching prompt' }); + } + + console.log('prrsp', result[0]); + + const model = result[0]; + const promptWithVariables = model.prompt; + + const newPrompt = replaceVariables(promptWithVariables, formData); + const isValid = validatePrompt(newPrompt); + + console.log("prompt is", newPrompt); + + if (!isValid) { + return res.status(400).send({ error: 'Error parsing prompt' }); + } + + const payload = { + model: "text-davinci-003", + prompt: newPrompt, + temperature: model.temperature, + max_tokens: model.max_tokens, + top_p: model.top_p, + frequency_penalty: model.frequency_penalty, + presence_penalty: model.presence_penalty, + n: 2, + stream: true, + stop: JSON.parse(model.stop!.toString())! + } + + return res.send(JSON.stringify(payload)); + } catch (error) { + return res.status(500).json(error); + } +} + +export default handler; diff --git a/client/pages/dashboard/index.tsx b/client/pages/dashboard/index.tsx new file mode 100644 index 00000000..5d369df2 --- /dev/null +++ b/client/pages/dashboard/index.tsx @@ -0,0 +1,169 @@ +import Link from "next/link"; +import React from "react"; +import Layout from "../../components/layout"; +import { signOut, useSession } from "next-auth/react"; +import Image from "next/image"; +const Dashboard = () => { + const { data: session } = useSession(); + const { name, email, image } = session?.user || {}; + return ( + +
+
+
+ +
+ + + + + + +
+
+ +
+
+

+ Dashboard +

+
+ + + + + + + + + + +
+
+
+
+ {""} +

+ {name} +

+ {/*

{email}

*/} +
+
+
+
+
+
+ ); +}; + +export default Dashboard; diff --git a/client/pages/index.tsx b/client/pages/index.tsx new file mode 100644 index 00000000..612c79fe --- /dev/null +++ b/client/pages/index.tsx @@ -0,0 +1,61 @@ +import Card from "../components/home/card"; +import Layout from "../components/layout"; +import { FADE_DOWN_ANIMATION_VARIANTS } from "../lib/constants"; +import Balancer from "react-wrap-balancer"; +import { motion } from "framer-motion"; + +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +const HomePage = ({ data }: any) => { + + return ( + +
+ + + All Tools + + + {/* here we are animating with Tailwind instead of Framer Motion because Framer Motion messes up the z-index for child components */} +
+ {data?.map( + ({ tool_id, tool_name, display_name, prompt, model }: any) => ( + + ) + )} +
+
+
+ ); +}; + +export default HomePage; + +export async function getStaticProps() { + const data = await prisma.tool.findMany(); + + return { + props: { + data, + }, + }; +} diff --git a/client/pages/tool/[tool].tsx b/client/pages/tool/[tool].tsx new file mode 100644 index 00000000..8f61a4a6 --- /dev/null +++ b/client/pages/tool/[tool].tsx @@ -0,0 +1,474 @@ +import { AnimatePresence, motion } from "framer-motion"; +import type { NextPage } from "next"; +import Head from "next/head"; +import Image from "next/image"; +import { SetStateAction, useState } from "react"; +import { Toaster, toast } from "react-hot-toast"; +import DropDownNew from "../../components/DropDownNew"; +import LoadingDots from "../../components/LoadingDots"; +import ResizablePanel from "../../components/ResizablePanel"; +import prisma from "../../lib/prisma"; +import { useRouter } from "next/router"; + +interface Props { + tool_name: string; + display_name: string; + fields: Array<{ + field_name: string; + type: string; + label: string; + required: boolean; + options: { value: string; label: string }[] | undefined; + placeholder: string | undefined; + command: string; + }>; + prompt: string; +} + +interface FormData { + [key: string]: string | undefined; +} + +const Tool: NextPage = ({ tool_name, display_name, fields, prompt }) => { + const [loading, setLoading] = useState(false); + const [bio, setBio] = useState(""); + const [vibe, setVibe] = useState<"Professional" | "Casual" | "Funny">( + "Professional" + ); + const [generatedBios, setGeneratedBios] = useState(""); + const [generatedBios2, setGeneratedBios2] = useState(""); + const initialFormData = fields.reduce((formData, field) => { + formData[field.field_name] = + field.type === "select" && Array.isArray(field.options) + ? field.options[0].value + : ""; + return formData; + }, {} as FormData); + //TODO is it ok to have it as type string instead of String? + const [generatedResponsesList, setGeneratedResponsesList] = useState< + string[] + >([]); + + const [formData, setFormData] = useState(initialFormData); + + console.log("Streamed response: ", generatedResponsesList); + + const handleSelect = ( + event: React.ChangeEvent, + formDataName: string + ) => { + const { name, value } = event.target; + setFormData({ ...formData, [formDataName]: value }); + }; + + const handleChange = ( + event: React.ChangeEvent, + formDataName: string + ) => { + const { value } = event.target; + setFormData({ ...formData, [formDataName]: value }); + }; + + const router = useRouter(); + const { tool } = router.query; + + // console.log("schema", schema); + console.log("formData", formData); + + const formFields = fields.map((field, index) => { + let input; + if (field.type === "select") { + input = ( + <> +
+ {`${index +

{field.command}

+
+
+ ) => + setFormData(newFormData) + } + /> +
+ + ); + } else { + input = ( + <> +
+ {`${index +

+ {field.command}{" "} + {/* + {/ . /} */} +

+
+