diff options
| author | Dhravya <[email protected]> | 2024-05-25 18:41:26 -0500 |
|---|---|---|
| committer | Dhravya <[email protected]> | 2024-05-25 18:41:26 -0500 |
| commit | 075f45986fd4d198292226e64afb71b3515576b4 (patch) | |
| tree | 5c728356cd0310f1c1c012fd6618c72a836c314b /apps/web/app | |
| parent | added social material (diff) | |
| download | supermemory-075f45986fd4d198292226e64afb71b3515576b4.tar.xz supermemory-075f45986fd4d198292226e64afb71b3515576b4.zip | |
refactored UI, with shared components and UI, better rules and million lint
Diffstat (limited to 'apps/web/app')
28 files changed, 1911 insertions, 0 deletions
diff --git a/apps/web/app/(auth)/auth-buttons.tsx b/apps/web/app/(auth)/auth-buttons.tsx new file mode 100644 index 00000000..9da1f5a5 --- /dev/null +++ b/apps/web/app/(auth)/auth-buttons.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { Button } from "@repo/ui/src/button"; +import React from "react"; +import { signIn } from "../helpers/server/auth"; + +function SignIn() { + return ( + <Button className="mt-4" onClick={async () => await signIn("google")}> + Login with Google + </Button> + ); +} + +export default SignIn; diff --git a/apps/web/app/(auth)/signin/page.tsx b/apps/web/app/(auth)/signin/page.tsx new file mode 100644 index 00000000..44d2b4f4 --- /dev/null +++ b/apps/web/app/(auth)/signin/page.tsx @@ -0,0 +1,8 @@ +import { getThemeToggler } from "../../helpers/lib/get-theme-button"; + +async function Signin() { + const SetThemeButton = getThemeToggler(); + return <SetThemeButton />; +} + +export default Signin; diff --git a/apps/web/app/(landing)/Cta.tsx b/apps/web/app/(landing)/Cta.tsx new file mode 100644 index 00000000..be99bf99 --- /dev/null +++ b/apps/web/app/(landing)/Cta.tsx @@ -0,0 +1,44 @@ +import Image from "next/image"; +import React from "react"; +import EmailInput from "./EmailInput"; + +function Cta() { + return ( + <section + id="try" + className="relative mb-44 mt-60 flex w-full flex-col items-center justify-center gap-8" + > + <div className="absolute left-0 z-[-1] h-full w-full"> + {/* a blue gradient line that's slightly tilted with blur (a lotof blur)*/} + <div className="overflow-hidden"> + <div + className="absolute left-[20%] top-[-165%] h-32 w-full overflow-hidden bg-[#369DFD] bg-opacity-70 blur-[337.4px]" + style={{ transform: "rotate(-30deg)" }} + /> + </div> + </div> + <Image + src="/landing-ui-2.png" + alt="Landing page background" + width={1512} + height={1405} + priority + draggable="false" + className="absolute z-[-2] hidden select-none rounded-3xl bg-black md:block lg:w-[80%]" + /> + <h1 className="z-20 mt-4 text-center text-5xl font-medium tracking-tight text-white"> + Your bookmarks are collecting dust. + </h1> + <div className="text-center text-sm text-zinc-500"> + Launching July 1st, 2024 + </div> + <p className="text-soft-foreground-text z-20 text-center"> + Time to change that. <br /> Sign up for the waitlist and be the first to + try Supermemory + </p> + <EmailInput /> + </section> + ); +} + +export default Cta; diff --git a/apps/web/app/(landing)/EmailInput.tsx b/apps/web/app/(landing)/EmailInput.tsx new file mode 100644 index 00000000..7e7ee5e7 --- /dev/null +++ b/apps/web/app/(landing)/EmailInput.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import formSubmitAction from "./formSubmitAction"; +import { useToast } from "@repo/ui/src/shadcn/use-toast"; + +function EmailInput() { + const [email, setEmail] = useState(""); + const { toast } = useToast(); + + return ( + <form + onSubmit={async (e: FormEvent<HTMLFormElement>) => { + e.preventDefault(); + + const value = await formSubmitAction(email, "token" as string); + + if (value.success) { + setEmail(""); + toast({ + title: "You are now on the waitlist! 🎉", + description: + "We will notify you when we launch. Check your inbox and spam folder for a surprise! 🎁", + }); + } else { + console.error("email submission failed: ", value.value); + toast({ + variant: "destructive", + title: "Uh oh! Something went wrong.", + description: `${value.value}`, + }); + } + }} + className="flex w-full items-center justify-center gap-2" + > + <div + className={`transition-width z-20 rounded-2xl bg-gradient-to-br from-gray-200/70 to-transparent p-[0.7px] duration-300 ease-in-out ${email ? "w-[90%] md:w-[42%]" : "w-full md:w-1/2"}`} + > + <input + type="email" + name="email" + className={`transition-width flex w-full items-center rounded-2xl bg-[#37485E] px-4 py-2 outline-none duration-300 focus:outline-none`} + placeholder="Enter your email" + value={email} + required + onChange={(e) => setEmail(e.target.value)} + /> + </div> + <div + className="cf-turnstile" + data-sitekey="0x4AAAAAAAakohhUeXc99J7E" + ></div> + {email && ( + <button + type="submit" + className="transition-width rounded-xl bg-gray-700 p-2 text-white duration-300" + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth={1.5} + stroke="currentColor" + className="h-6 w-6" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" + /> + </svg> + </button> + )} + </form> + ); +} + +export default EmailInput; diff --git a/apps/web/app/(landing)/FeatureContent.tsx b/apps/web/app/(landing)/FeatureContent.tsx new file mode 100644 index 00000000..7de64d53 --- /dev/null +++ b/apps/web/app/(landing)/FeatureContent.tsx @@ -0,0 +1,59 @@ +export const features = [ + { + title: "For Researchers", + description: + "Add content to collections and use it as a knowledge base for your research, link multiple sources together to get a better understanding of the topic.", + svg: <ResearchSvg />, + }, + { + title: "For Content writers", + description: + "Save time and use the writing assistant to generate content based on your own saved collections and sources.", + svg: <ContentSvg />, + }, + { + title: "For Developers", + description: + "Talk to documentation websites, code snippets, etc. so you never have to google the same thing a hundred times.", + svg: <DeveloperSvg />, + }, +]; + +function ResearchSvg() { + return ( + <svg + className="mr-3 shrink-0 fill-zinc-400" + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + > + <path d="m7.951 14.537 6.296-7.196 1.506 1.318-7.704 8.804-3.756-3.756 1.414-1.414 2.244 2.244Zm11.296-7.196 1.506 1.318-7.704 8.804-1.756-1.756 1.414-1.414.244.244 6.296-7.196Z" /> + </svg> + ); +} + +function ContentSvg() { + return ( + <svg + className="mr-3 shrink-0 fill-zinc-400" + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + > + <path d="m16.997 19.056-1.78-.912A13.91 13.91 0 0 0 16.75 11.8c0-2.206-.526-4.38-1.533-6.344l1.78-.912A15.91 15.91 0 0 1 18.75 11.8c0 2.524-.602 5.01-1.753 7.256Zm-3.616-1.701-1.77-.93A9.944 9.944 0 0 0 12.75 11.8c0-1.611-.39-3.199-1.14-4.625l1.771-.93c.9 1.714 1.37 3.62 1.369 5.555 0 1.935-.47 3.841-1.369 5.555Zm-3.626-1.693-1.75-.968c.49-.885.746-1.881.745-2.895a5.97 5.97 0 0 0-.745-2.893l1.75-.968a7.968 7.968 0 0 1 .995 3.861 7.97 7.97 0 0 1-.995 3.863Zm-3.673-1.65-1.664-1.11c.217-.325.333-.709.332-1.103 0-.392-.115-.776-.332-1.102L6.082 9.59c.437.655.67 1.425.668 2.21a3.981 3.981 0 0 1-.668 2.212Z" /> + </svg> + ); +} + +function DeveloperSvg() { + return ( + <svg + className="mr-3 shrink-0 fill-zinc-400" + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + > + <path d="m11.293 5.293 1.414 1.414-8 8-1.414-1.414 8-8Zm7-1 1.414 1.414-8 8-1.414-1.414 8-8Zm0 6 1.414 1.414-8 8-1.414-1.414 8-8Z" /> + </svg> + ); +} diff --git a/apps/web/app/(landing)/Features.tsx b/apps/web/app/(landing)/Features.tsx new file mode 100644 index 00000000..dd60ce8f --- /dev/null +++ b/apps/web/app/(landing)/Features.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import Image from "next/image"; +import { X } from "@repo/ui/src/components/icons"; + +import { features } from "./FeatureContent"; +import { CardClick } from "@repo/ui/src/components/cardClick"; + +export default function Features() { + const [tab, setTab] = useState<number>(0); + + const tabs = useRef<HTMLDivElement>(null); + + const heightFix = () => { + if (tabs.current && tabs.current.parentElement) + tabs.current.parentElement.style.height = `${tabs.current.clientHeight}px`; + }; + + function handleClickIndex(tab: number) { + setTab(tab); + } + + useEffect(() => { + heightFix(); + }, []); + + return ( + <section className="relative w-full overflow-hidden max-lg:after:hidden"> + <div className="py-12 md:pb-32"> + {/* Carousel */} + <div + id="use-cases" + className="mx-auto max-w-xl px-4 sm:px-6 md:pt-40 lg:max-w-6xl" + > + <div className="space-y-12 lg:flex lg:space-x-12 lg:space-y-0 xl:space-x-24"> + {/* Content */} + <div className="lg:min-w-[524px] lg:max-w-none"> + <div className="mb-8"> + <div className="mb-4 inline-flex rounded-full border border-transparent px-4 py-0.5 text-sm font-medium text-zinc-400 [background:linear-gradient(theme(colors.zinc.800),theme(colors.zinc.800))_padding-box,linear-gradient(120deg,theme(colors.zinc.700),theme(colors.zinc.700/0),theme(colors.zinc.700))_border-box]"> + Use cases + </div> + <h3 className="font-inter-tight mb-4 text-3xl font-bold text-zinc-200"> + Save time and keep things organised + </h3> + <p className="text-lg text-zinc-500"> + With Supermemory, it's really easy to save information from + all over the internet, while training your own AI to help you + do more with it. + </p> + </div> + {/* Tabs buttons */} + <div className="mb-8 space-y-2 md:mb-0"> + <CardClick + tab={tab} + items={features} + handleClickIndex={handleClickIndex} + /> + </div> + </div> + + {/* Tabs items */} + <div className="relative lg:max-w-none"> + <div className="relative flex flex-col" ref={tabs}> + {/* Item 1 */} + <div + className="transition-all duration-700 transform order-first" + style={{ + height: tab === 0 ? "auto" : 0, + opacity: tab === 0 ? 1 : 0, + // transform: tab === 0 ? 'translateY(0)' : 'translateY(4rem)', + }} + > + <div> + <Image + className="mx-auto rounded-lg shadow-2xl lg:max-w-none" + src={"/images/carousel-illustration-01.png"} + width={700} + height={520} + alt="Carousel 01" + /> + </div> + </div> + {/* Item 2 */} + <div + className="transition-all duration-700 transform order-first" + style={{ + height: tab === 1 ? "auto" : 0, + opacity: tab === 1 ? 1 : 0, + transform: tab === 1 ? "translateY(0)" : "translateY(4rem)", + }} + > + <div> + <Image + className="mx-auto rounded-lg shadow-2xl lg:max-w-none" + src={"/images/carousel-illustration-01.png"} + width={700} + height={520} + alt="Carousel 02" + /> + </div> + </div> + {/* Item 3 */} + <div + className="transition-all duration-700 transform order-first" + style={{ + height: tab === 2 ? "auto" : 0, + opacity: tab === 2 ? 1 : 0, + transform: tab === 2 ? "translateY(0)" : "translateY(4rem)", + }} + > + <div> + <Image + className="mx-auto rounded-lg shadow-2xl lg:max-w-none" + src={"/images/carousel-illustration-01.png"} + width={700} + height={520} + alt="Carousel 03" + /> + </div> + </div> + </div> + </div> + </div> + </div> + + {/* Features blocks */} + <div + id="features" + className="mx-auto mt-24 max-w-6xl px-4 sm:px-6 md:pt-40" + > + <div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 lg:gap-16"> + {/* Block #1 */} + <div> + <div className="mb-1 flex items-center"> + <X className="mr-2" /> + <h3 className="font-inter-tight font-semibold text-zinc-200"> + Import all your Twitter bookmarks + </h3> + </div> + <p className="text-sm text-zinc-500"> + Use all the knowledge you've saved on Twitter to train your own + supermemory. + </p> + </div> + {/* Block #2 */} + <div> + <div className="mb-1 flex items-center"> + <svg + className="mr-2 fill-zinc-400" + xmlns="http://www.w3.org/2000/svg" + width="16" + height="16" + > + <path d="M13 16c-.153 0-.306-.035-.447-.105l-3.851-1.926c-.231.02-.465.031-.702.031-4.411 0-8-3.14-8-7s3.589-7 8-7 8 3.14 8 7c0 1.723-.707 3.351-2 4.63V15a1.003 1.003 0 0 1-1 1Zm-4.108-4.054c.155 0 .308.036.447.105L12 13.382v-2.187c0-.288.125-.562.341-.752C13.411 9.506 14 8.284 14 7c0-2.757-2.691-5-6-5S2 4.243 2 7s2.691 5 6 5c.266 0 .526-.02.783-.048a1.01 1.01 0 0 1 .109-.006Z" /> + </svg> + <h3 className="font-inter-tight font-semibold text-zinc-200"> + Chat with collections + </h3> + </div> + <p className="text-sm text-zinc-500"> + Use collections to talk to specific knowledgebases like 'My + twitter bookmarks', or 'Learning web development' + </p> + </div> + {/* Block #3 */} + <div> + <div className="mb-1 flex items-center"> + <svg + className="mr-2 fill-zinc-400" + xmlns="http://www.w3.org/2000/svg" + width="16" + height="16" + > + <path d="M7 14c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7ZM7 2C4.243 2 2 4.243 2 7s2.243 5 5 5 5-2.243 5-5-2.243-5-5-5Zm8.707 12.293a.999.999 0 1 1-1.414 1.414L11.9 13.314a8.019 8.019 0 0 0 1.414-1.414l2.393 2.393Z" /> + </svg> + <h3 className="font-inter-tight font-semibold text-zinc-200"> + Powerful search + </h3> + </div> + <p className="text-sm text-zinc-500"> + Look up anything you've saved in your supermemory, and get the + information you need in seconds. + </p> + </div> + {/* Block #4 */} + <div> + <div className="mb-1 flex items-center"> + <svg + className="mr-2 fill-zinc-400" + xmlns="http://www.w3.org/2000/svg" + width="14" + height="16" + > + <path d="M13 0H1C.4 0 0 .4 0 1v14c0 .6.4 1 1 1h8l5-5V1c0-.6-.4-1-1-1ZM2 2h10v8H8v4H2V2Z" /> + </svg> + <h3 className="font-inter-tight font-semibold text-zinc-200"> + Knowledge canvas + </h3> + </div> + <p className="text-sm text-zinc-500"> + Arrange your saved information in a way that makes sense to you + in a 2d canvas. + </p> + </div> + {/* Block #5 */} + <div> + <div className="mb-1 flex items-center"> + <svg + className="mr-2 fill-zinc-400" + xmlns="http://www.w3.org/2000/svg" + width="16" + height="16" + > + <path d="M14.6.085 8 2.885 1.4.085c-.5-.2-1.4-.1-1.4.9v11c0 .4.2.8.6.9l7 3c.3.1.5.1.8 0l7-3c.4-.2.6-.5.6-.9v-11c0-1-.9-1.1-1.4-.9ZM2 2.485l5 2.1v8.8l-5-2.1v-8.8Zm12 8.8-5 2.1v-8.7l5-2.1v8.7Z" /> + </svg> + <h3 className="font-inter-tight font-semibold text-zinc-200"> + Just... bookmarks + </h3> + </div> + <p className="text-sm text-zinc-500"> + AI is cool, but sometimes you just need a place to save your + stuff. Supermemory is that place. + </p> + </div> + {/* Block #6 */} + <div> + <div className="mb-1 flex items-center"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + fill="currentColor" + className="mr-2 h-5 w-5 fill-zinc-400" + > + <path d="m2.695 14.762-1.262 3.155a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.886L17.5 5.501a2.121 2.121 0 0 0-3-3L3.58 13.419a4 4 0 0 0-.885 1.343Z" /> + </svg> + <h3 className="font-inter-tight font-semibold text-zinc-200"> + Writing assistant + </h3> + </div> + <p className="text-sm text-zinc-500"> + Use our markdown editor to write content based on your saved + data, with the help of AI. + </p> + </div> + </div> + </div> + </div> + </section> + ); +} diff --git a/apps/web/app/(landing)/Hero.tsx b/apps/web/app/(landing)/Hero.tsx new file mode 100644 index 00000000..d571cc26 --- /dev/null +++ b/apps/web/app/(landing)/Hero.tsx @@ -0,0 +1,74 @@ +"use client"; +import React from "react"; +import { motion } from "framer-motion"; +import { Twitter } from "@repo/ui/src/components/icons"; +import EmailInput from "./EmailInput"; +import LinkArrow from "./linkArrow"; + +const slap = { + initial: { + opacity: 0, + scale: 1.1, + }, + whileInView: { opacity: 1, scale: 1 }, + transition: { + duration: 0.5, + ease: "easeInOut", + }, + viewport: { once: true }, +}; + +function Hero() { + return ( + <> + <section className="mt-24 flex max-w-xl flex-col items-center justify-center gap-10 md:mt-56"> + <a + className="group/anchor flex items-center justify-center gap-4 rounded-full bg-white/10 py-2 pl-10 pr-6 text-sm text-white/80" + href="https://twitter.com/supermemoryai" + target="_blank" + > + <Twitter className="h-4 w-4" /> + <div className="flex items-center"> + {" "} + Follow us on Twitter{" "} + <LinkArrow classname="group-hover/anchor:opacity-100 opacity-0 transition" /> + </div> + </a> + <motion.h1 + {...{ + ...slap, + transition: { ...slap.transition, delay: 0.2 }, + }} + className="text-center text-4xl font-semibold tracking-tight text-white/95 md:text-5xl" + > + Build your own second brain with Supermemory + </motion.h1> + <motion.p + {...{ + ...slap, + transition: { ...slap.transition, delay: 0.3 }, + }} + className="text-soft-foreground-text text-center" + > + Bring saved information from all over the internet into one place + where you can connect it, and ask AI about it + </motion.p> + <EmailInput /> + </section> + <motion.img + {...{ + ...slap, + transition: { ...slap.transition, delay: 0.35 }, + }} + src="/landing-ui.svg" + alt="Landing page background" + width={1512} + height={1405} + draggable="false" + className="z-[-2] mt-28 h-full w-[80%] select-none" + /> + </> + ); +} + +export default Hero; diff --git a/apps/web/app/(landing)/Navbar.tsx b/apps/web/app/(landing)/Navbar.tsx new file mode 100644 index 00000000..7d0e3225 --- /dev/null +++ b/apps/web/app/(landing)/Navbar.tsx @@ -0,0 +1,37 @@ +import Logo from "../../public/logo.svg"; +import { Github } from "@repo/ui/src/components/icons"; +import Image from "next/image"; +import Link from "next/link"; +import React from "react"; + +function Navbar() { + return ( + <nav className="fixed top-0 z-[99999] mt-12 hidden w-full px-24 text-sm md:flex"> + <div className="flex w-full flex-row justify-between rounded-2xl bg-white/10 shadow-[0px_2px_3px_-1px_rgba(0,0,0,0.1),0px_1px_0px_0px_rgba(25,28,33,0.02),0px_0px_0px_1px_rgba(25,28,33,0.08)] backdrop-blur-lg backdrop-filter"> + <Link href={"/"} className="flex items-center p-3 opacity-50"> + <Image src={Logo} alt="Supermemory logo" width={40} height={40} /> + </Link> + <div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-8 p-3"> + <Link href={"#use-cases"} className="text-soft-foreground-text"> + Use cases + </Link> + <Link href={"#features"} className="text-soft-foreground-text"> + Features + </Link> + <Link href={"#try"} className="text-soft-foreground-text"> + Try supermemory + </Link> + </div> + <Link + href="https://git.new/memory" + className="m-2 flex items-center gap-3 rounded-xl bg-white/20 px-4 text-center text-white" + > + <Github className="h-4 w-4" /> + Open source + </Link> + </div> + </nav> + ); +} + +export default Navbar; diff --git a/apps/web/app/(landing)/RotatingIcons.tsx b/apps/web/app/(landing)/RotatingIcons.tsx new file mode 100644 index 00000000..fafe9f0b --- /dev/null +++ b/apps/web/app/(landing)/RotatingIcons.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { motion } from "framer-motion"; +import { + Github, + Medium, + Notion, + Reddit, + Twitter, +} from "@repo/ui/src/components/icons"; +import Image from "next/image"; + +const icons = [ + <div className="rounded-full bg-purple-600/20 p-4"> + <Github className="h-8 w-8 text-purple-500" /> + </div>, + <div className="rounded-full bg-blue-800/20 p-4"> + <Twitter className="h-8 w-8 text-blue-500" /> + </div>, + <div className="rounded-full bg-green-800/20 p-4"> + <Medium className="h-8 w-8 text-green-500" /> + </div>, + <div className="rounded-full bg-red-800/20 p-4"> + <Reddit className="h-8 w-8 text-red-500" /> + </div>, + <div className="rounded-full bg-white/20 p-4"> + <Notion className="h-8 w-8 text-white" /> + </div>, +]; + +const RotatingIcons: React.FC = () => { + return ( + <div className="relative flex w-full flex-col items-center justify-center gap-8 px-4 sm:px-6"> + <h3 className="font-inter-tight mb-4 mt-8 text-center text-3xl font-bold text-zinc-200"> + Collect data from <br />{" "} + <span className="bg-gradient-to-r from-blue-500 to-blue-300 bg-clip-text italic text-transparent "> + any website{" "} + </span>{" "} + on the internet + </h3> + <div className="flex items-center justify-center"> + <div className="relative m-2 mx-auto h-96 w-96 scale-[70%] md:scale-100 "> + <div className="relative h-full w-full rounded-full border border-gray-800"> + {icons.map((icon, index) => ( + <motion.div + key={index} + className="absolute top-1/2 -translate-x-10 transform" + style={{ + originX: "200px", + originY: "-8px", + }} + animate={{ + rotate: [0, 360], + }} + transition={{ + repeat: Infinity, + duration: 5, + ease: "linear", + delay: index, + }} + > + <motion.div + style={{ + rotate: index * 72, + }} + animate={{ + rotate: [0, -360], + }} + transition={{ + repeat: Infinity, + duration: 5, + ease: "linear", + delay: index, + }} + > + {icon} + </motion.div> + </motion.div> + ))} + <Image + src="/logo.svg" + alt="Supermemory logo" + width={80} + height={80} + className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform text-white" + /> + </div> + </div> + </div> + <p className="text-center text-sm text-zinc-500"> + ... and bring it into your second brain + </p> + </div> + ); +}; + +export default RotatingIcons; diff --git a/apps/web/app/(landing)/footer.tsx b/apps/web/app/(landing)/footer.tsx new file mode 100644 index 00000000..4ebfca0b --- /dev/null +++ b/apps/web/app/(landing)/footer.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import LinkArrow from "./linkArrow"; + +function Footer() { + return ( + <footer className="mt-20 flex w-full items-center justify-between gap-4 px-8 py-8 text-sm text-zinc-500"> + <p>© 2024 Supermemory.ai</p> + <div className="flex gap-5"> + <a + className="group/mail flex items-center" + target="_blank" + href="mailto:[email protected]" + > + Contact + <LinkArrow classname="group-hover/mail:opacity-100 opacity-0 transition" /> + </a> + <a + className="group/twit flex items-center" + target="_blank" + href="https://twitter.com/supermemoryai" + > + Twitter{" "} + <LinkArrow classname="group-hover/twit:opacity-100 opacity-0 transition" /> + </a> + <a + className="group/git flex items-center" + target="_blank" + href="https://github.com/dhravya/supermemory" + > + Github{" "} + <LinkArrow classname="group-hover/git:opacity-100 opacity-0 transition" /> + </a> + </div> + </footer> + ); +} + +export default Footer; diff --git a/apps/web/app/(landing)/formSubmitAction.ts b/apps/web/app/(landing)/formSubmitAction.ts new file mode 100644 index 00000000..9c2eefff --- /dev/null +++ b/apps/web/app/(landing)/formSubmitAction.ts @@ -0,0 +1,48 @@ +"use server"; +import { headers } from "next/headers"; + +const formSubmitAction = async (email: string, token: string) => { + console.log("email submitted:", email); + const formBody = `email=${encodeURIComponent(email)}`; + const h = await headers(); + const ip = h.get("cf-connecting-ip"); + + if (ip) { + if (process.env.RATELIMITER) { + // @ts-ignore + const { success } = await process.env.RATELIMITER.limit({ + key: `waitlist-${ip}`, + }); + + if (!success) { + console.error("rate limit exceeded"); + return { value: "Rate limit exceeded", success: false }; + } + } else { + console.info("RATELIMITER not found in env"); + } + } else { + console.info("cf-connecting-ip not found in headers"); + } + + const resp = await fetch( + "https://app.loops.so/api/newsletter-form/clwcn8dde0059m6hobbdw2rwe", + { + method: "POST", + body: formBody, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ); + + if (resp.ok) { + console.log("email submitted successfully"); + return { value: await resp.json(), success: true }; + } else { + console.error("email submission failed"); + return { value: await resp.text(), success: false }; + } +}; + +export default formSubmitAction; diff --git a/apps/web/app/(landing)/linkArrow.tsx b/apps/web/app/(landing)/linkArrow.tsx new file mode 100644 index 00000000..def37e91 --- /dev/null +++ b/apps/web/app/(landing)/linkArrow.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +function LinkArrow({ classname }: { classname?: string }) { + return ( + <svg + className={classname} + width="24px" + height="24px" + viewBox="-2.4 -2.4 28.80 28.80" + fill="none" + xmlns="http://www.w3.org/2000/svg" + transform="matrix(1, 0, 0, 1, 0, 0)rotate(0)" + > + <g id="SVGRepo_bgCarrier" strokeWidth="0"></g> + <g + id="SVGRepo_tracerCarrier" + strokeLinecap="round" + strokeLinejoin="round" + ></g> + <g id="SVGRepo_iconCarrier"> + {" "} + <path + d="M7 17L17 7M17 7H8M17 7V16" + stroke="currentColor" + strokeWidth="0.792" + strokeLinecap="round" + strokeLinejoin="round" + ></path>{" "} + </g> + </svg> + ); +} + +export default LinkArrow; diff --git a/apps/web/app/(landing)/page.tsx b/apps/web/app/(landing)/page.tsx new file mode 100644 index 00000000..36f0ce8d --- /dev/null +++ b/apps/web/app/(landing)/page.tsx @@ -0,0 +1,58 @@ +import RotatingIcons from "./RotatingIcons"; +import Hero from "./Hero"; +import Navbar from "./Navbar"; +import Cta from "./Cta"; +import { Toaster } from "@repo/ui/src/shadcn/toaster"; +import Features from "./Features"; +import Footer from "./footer"; +import { auth } from "../helpers/server/auth"; + +export const runtime = "edge"; + +export default async function Home() { + const user = await auth(); + + console.log(user); + + return ( + <main className="dark flex min-h-screen flex-col items-center overflow-x-hidden px-2 md:px-0"> + <Navbar /> + + {/* Background gradients */} + <div className="absolute left-0 top-0 z-[-1] h-full w-full"> + <div className="overflow-x-hidden"> + <div + className="absolute left-0 h-32 w-[95%] overflow-x-hidden bg-[#369DFD] bg-opacity-70 blur-[337.4px]" + style={{ transform: "rotate(-30deg)" }} + /> + </div> + + {/* a blue gradient line that's slightly tilted with blur (a lotof blur)*/} + <div className="overflow-x-hidden"> + <div + className="absolute left-0 top-[100%] h-32 w-[90%] overflow-x-hidden bg-[#369DFD] bg-opacity-40 blur-[337.4px]" + style={{ transform: "rotate(-30deg)" }} + /> + </div> + + <div className="overflow-x-hidden"> + <div className="absolute right-0 top-[145%] h-40 w-[17%] overflow-x-hidden bg-[#369DFD] bg-opacity-20 blur-[110px]" /> + </div> + </div> + + {/* Hero section */} + <Hero /> + + {/* Features section */} + <Features /> + + <RotatingIcons /> + + <Cta /> + + <Toaster /> + + <Footer /> + </main> + ); +} diff --git a/apps/web/app/api/[...nextauth]/route.ts b/apps/web/app/api/[...nextauth]/route.ts new file mode 100644 index 00000000..50807ab1 --- /dev/null +++ b/apps/web/app/api/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +export { GET, POST } from "../../helpers/server/auth"; +export const runtime = "edge"; diff --git a/apps/web/app/api/ensureAuth.ts b/apps/web/app/api/ensureAuth.ts new file mode 100644 index 00000000..a1401a07 --- /dev/null +++ b/apps/web/app/api/ensureAuth.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; +import { db } from "../helpers/server/db"; +import { sessions, users } from "../helpers/server/db/schema"; +import { eq } from "drizzle-orm"; + +export async function ensureAuth(req: NextRequest) { + // A helper function to protect routes + + const token = + req.cookies.get("next-auth.session-token")?.value ?? + req.cookies.get("__Secure-authjs.session-token")?.value ?? + req.cookies.get("authjs.session-token")?.value ?? + req.headers.get("Authorization")?.replace("Bearer ", ""); + + if (!token) { + return undefined; + } + + const sessionData = await db + .select() + .from(sessions) + .innerJoin(users, eq(users.id, sessions.userId)) + .where(eq(sessions.sessionToken, token!)); + + if (!sessionData || sessionData.length < 0) { + return undefined; + } + + return { + user: sessionData[0]!.user, + session: sessionData[0]!, + }; +} diff --git a/apps/web/app/api/hello/route.ts b/apps/web/app/api/hello/route.ts new file mode 100644 index 00000000..363d0704 --- /dev/null +++ b/apps/web/app/api/hello/route.ts @@ -0,0 +1,22 @@ +import type { NextRequest } from "next/server"; +import { getRequestContext } from "@cloudflare/next-on-pages"; + +export const runtime = "edge"; + +export async function GET(request: NextRequest) { + let responseText = "Hello World"; + + // In the edge runtime you can use Bindings that are available in your application + // (for more details see: + // - https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/#use-bindings-in-your-nextjs-application + // - https://developers.cloudflare.com/pages/functions/bindings/ + // ) + // + // KV Example: + // const myKv = getRequestContext().env.MY_KV_NAMESPACE + // await myKv.put('suffix', ' from a KV store!') + // const suffix = await myKv.get('suffix') + // responseText += suffix + + return new Response(responseText); +} diff --git a/apps/web/app/api/upload_image/route.ts b/apps/web/app/api/upload_image/route.ts new file mode 100644 index 00000000..0d93c5b0 --- /dev/null +++ b/apps/web/app/api/upload_image/route.ts @@ -0,0 +1,56 @@ +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +import type { NextRequest } from "next/server"; +import { ensureAuth } from "../ensureAuth"; + +export const runtime = "edge"; + +export async function PUT(request: NextRequest) { + const d = await ensureAuth(request); + + if (!d) { + return new Response("Unauthorized", { status: 401 }); + } + + const reqUrl = new URL(request.url); + const filename = reqUrl.searchParams.get("filename"); + + if (!filename) { + return new Response("Missing filename", { status: 400 }); + } + + if ( + !process.env.R2_ENDPOINT || + !process.env.R2_ACCESS_ID || + !process.env.R2_SECRET_KEY || + !process.env.R2_BUCKET_NAME + ) { + return new Response( + "Missing one or more R2 env variables: R2_ENDPOINT, R2_ACCESS_ID, R2_SECRET_KEY, R2_BUCKET_NAME. To get them, go to the R2 console, create and paste keys in a `.dev.vars` file in the root of this project.", + { status: 500 }, + ); + } + + const s3 = new S3Client({ + region: "auto", + endpoint: process.env.R2_ENDPOINT, + credentials: { + accessKeyId: process.env.R2_ACCESS_ID, + secretAccessKey: process.env.R2_SECRET_KEY, + }, + }); + + const url = await getSignedUrl( + s3, + new PutObjectCommand({ Bucket: process.env.R2_BUCKET_NAME, Key: filename }), + { expiresIn: 3600 }, + ); + + return new Response(JSON.stringify({ url }), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 00000000..8eee6cbd --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,50 @@ +:root { + --max-width: 1100px; + --border-radius: 12px; + --font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", + "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", + "Fira Mono", "Droid Sans Mono", "Courier New", monospace; + + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + + --callout-rgb: 20, 20, 20; + --callout-border-rgb: 108, 108, 108; + --card-rgb: 100, 100, 100; + --card-border-rgb: 200, 200, 200; + + --glow-conic: conic-gradient( + from 180deg at 50% 50%, + #2a8af6 0deg, + #a853ba 180deg, + #e92a67 360deg + ); +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); +} + +a { + color: inherit; + text-decoration: none; +} diff --git a/apps/web/app/helpers/lib/get-theme-button.tsx b/apps/web/app/helpers/lib/get-theme-button.tsx new file mode 100644 index 00000000..41720927 --- /dev/null +++ b/apps/web/app/helpers/lib/get-theme-button.tsx @@ -0,0 +1,11 @@ +// Theming that works perfectly with app router (no flicker, jumps etc!) + +import dynamic from "next/dynamic"; + +// Don't SSR the toggle since the value on the server will be different than the client +export const getThemeToggler = () => + dynamic(() => import("@repo/ui/src/shadcn/theme-toggle"), { + ssr: false, + // Make sure to code a placeholder so the UI doesn't jump when the component loads + loading: () => <div className="w-6 h-6" />, + }); diff --git a/apps/web/app/helpers/lib/handle-errors.ts b/apps/web/app/helpers/lib/handle-errors.ts new file mode 100644 index 00000000..42cae589 --- /dev/null +++ b/apps/web/app/helpers/lib/handle-errors.ts @@ -0,0 +1,25 @@ +import { isRedirectError } from "next/dist/client/components/redirect"; +import { toast } from "sonner"; +import { z } from "zod"; + +export function getErrorMessage(err: unknown) { + const unknownError = "Something went wrong, please try again later."; + + if (err instanceof z.ZodError) { + const errors = err.issues.map((issue) => { + return issue.message; + }); + return errors.join("\n"); + } else if (err instanceof Error) { + return err.message; + } else if (isRedirectError(err)) { + throw err; + } else { + return unknownError; + } +} + +export function showErrorToast(err: unknown) { + const errorMessage = getErrorMessage(err); + return toast.error(errorMessage); +} diff --git a/apps/web/app/helpers/server/auth.ts b/apps/web/app/helpers/server/auth.ts new file mode 100644 index 00000000..e2817cf0 --- /dev/null +++ b/apps/web/app/helpers/server/auth.ts @@ -0,0 +1,29 @@ +import NextAuth, { NextAuthResult } from "next-auth"; +import Google from "next-auth/providers/google"; +import { DrizzleAdapter } from "@auth/drizzle-adapter"; +import { db } from "./db"; + +export const { + handlers: { GET, POST }, + signIn, + signOut, + auth, +} = NextAuth({ + secret: process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET, + callbacks: { + session: ({ session, token, user }) => ({ + ...session, + user: { + ...session.user, + id: user.id, + }, + }), + }, + adapter: DrizzleAdapter(db), + providers: [ + Google({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }), + ], +}); diff --git a/apps/web/app/helpers/server/db/index.ts b/apps/web/app/helpers/server/db/index.ts new file mode 100644 index 00000000..4d671bea --- /dev/null +++ b/apps/web/app/helpers/server/db/index.ts @@ -0,0 +1,5 @@ +import { drizzle } from "drizzle-orm/d1"; + +import * as schema from "./schema"; + +export const db = drizzle(process.env.DATABASE, { schema, logger: true }); diff --git a/apps/web/app/helpers/server/db/schema.ts b/apps/web/app/helpers/server/db/schema.ts new file mode 100644 index 00000000..c4616eb2 --- /dev/null +++ b/apps/web/app/helpers/server/db/schema.ts @@ -0,0 +1,143 @@ +import { relations, sql } from "drizzle-orm"; +import { + index, + int, + primaryKey, + sqliteTableCreator, + text, + integer, +} from "drizzle-orm/sqlite-core"; + +export const createTable = sqliteTableCreator((name) => `${name}`); + +export const users = createTable("user", { + id: text("id", { length: 255 }).notNull().primaryKey(), + name: text("name", { length: 255 }), + email: text("email", { length: 255 }).notNull(), + emailVerified: int("emailVerified", { mode: "timestamp" }).default( + sql`CURRENT_TIMESTAMP`, + ), + image: text("image", { length: 255 }), +}); + +export type User = typeof users.$inferSelect; + +export const usersRelations = relations(users, ({ many }) => ({ + accounts: many(accounts), + sessions: many(sessions), +})); + +export const accounts = createTable( + "account", + { + id: integer("id").notNull().primaryKey({ autoIncrement: true }), + userId: text("userId", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + type: text("type", { length: 255 }).notNull(), + provider: text("provider", { length: 255 }).notNull(), + providerAccountId: text("providerAccountId", { length: 255 }).notNull(), + refresh_token: text("refresh_token"), + access_token: text("access_token"), + expires_at: int("expires_at"), + token_type: text("token_type", { length: 255 }), + scope: text("scope", { length: 255 }), + id_token: text("id_token"), + session_state: text("session_state", { length: 255 }), + oauth_token_secret: text("oauth_token_secret"), + oauth_token: text("oauth_token"), + }, + (account) => ({ + userIdIdx: index("account_userId_idx").on(account.userId), + }), +); + +export const sessions = createTable( + "session", + { + id: integer("id").notNull().primaryKey({ autoIncrement: true }), + sessionToken: text("sessionToken", { length: 255 }).notNull(), + userId: text("userId", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expires: int("expires", { mode: "timestamp" }).notNull(), + }, + (session) => ({ + userIdIdx: index("session_userId_idx").on(session.userId), + }), +); + +export const verificationTokens = createTable( + "verificationToken", + { + identifier: text("identifier", { length: 255 }).notNull(), + token: text("token", { length: 255 }).notNull(), + expires: int("expires", { mode: "timestamp" }).notNull(), + }, + (vt) => ({ + compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), + }), +); + +export const storedContent = createTable( + "storedContent", + { + id: integer("id").notNull().primaryKey({ autoIncrement: true }), + content: text("content").notNull(), + title: text("title", { length: 255 }), + description: text("description", { length: 255 }), + url: text("url").notNull(), + savedAt: int("savedAt", { mode: "timestamp" }).notNull(), + baseUrl: text("baseUrl", { length: 255 }), + ogImage: text("ogImage", { length: 255 }), + type: text("type", { enum: ["note", "page", "twitter-bookmark"] }).default( + "page", + ), + image: text("image", { length: 255 }), + user: text("user", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + }, + (sc) => ({ + urlIdx: index("storedContent_url_idx").on(sc.url), + savedAtIdx: index("storedContent_savedAt_idx").on(sc.savedAt), + titleInx: index("storedContent_title_idx").on(sc.title), + userIdx: index("storedContent_user_idx").on(sc.user), + }), +); + +export const contentToSpace = createTable( + "contentToSpace", + { + contentId: integer("contentId") + .notNull() + .references(() => storedContent.id, { onDelete: "cascade" }), + spaceId: integer("spaceId") + .notNull() + .references(() => space.id, { onDelete: "cascade" }), + }, + (cts) => ({ + compoundKey: primaryKey({ columns: [cts.contentId, cts.spaceId] }), + }), +); + +export const space = createTable( + "space", + { + id: integer("id").notNull().primaryKey({ autoIncrement: true }), + name: text("name").notNull().unique().default("none"), + user: text("user", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + }, + (space) => ({ + nameIdx: index("spaces_name_idx").on(space.name), + userIdx: index("spaces_user_idx").on(space.user), + }), +); + +export type StoredContent = Omit<typeof storedContent.$inferSelect, "user">; +export type StoredSpace = typeof space.$inferSelect; +export type ChachedSpaceContent = StoredContent & { + space: number; +}; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 00000000..035b5827 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,71 @@ +import "@repo/tailwind-config/globals.css"; + +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import { ThemeScript } from "next-app-theme/theme-script"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Supermemory - Your personal second brain.", + description: + "Bring saved information from all over the internet into one place where you can connect it, and ask AI about it", + openGraph: { + images: [ + { + url: "https://supermemory.ai/og-image.png", + width: 1200, + height: 627, + alt: "Supermemory - Your personal second brain.", + }, + ], + }, + metadataBase: { + host: "https://supermemory.ai", + href: "/", + origin: "https://supermemory.ai", + password: "supermemory", + hash: "supermemory", + pathname: "/", + search: "", + username: "supermemoryai", + hostname: "supermemory.ai", + port: "", + protocol: "https:", + searchParams: new URLSearchParams(""), + toString: () => "https://supermemory.ai/", + toJSON: () => "https://supermemory.ai/", + }, + twitter: { + card: "summary_large_image", + site: "https://supermemory.ai", + creator: "https://supermemory.ai", + title: "Supermemory - Your personal second brain.", + description: + "Bring saved information from all over the internet into one place where you can connect it, and ask AI about it", + images: [ + { + url: "https://supermemory.ai/og-image.png", + width: 1200, + height: 627, + alt: "Supermemory - Your personal second brain.", + }, + ], + }, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}): JSX.Element { + return ( + <html lang="en"> + <head> + <ThemeScript /> + </head> + {/* TODO: when lightmode support is added, remove the 'dark' class from the body tag */} + <body className={`${inter.className} dark`}>{children}</body> + </html> + ); +} diff --git a/apps/web/app/page-ref.tsx b/apps/web/app/page-ref.tsx new file mode 100644 index 00000000..2d4c9cc3 --- /dev/null +++ b/apps/web/app/page-ref.tsx @@ -0,0 +1,116 @@ +import { Button } from "@repo/ui/src/shadcn/button"; +import { auth, signIn, signOut } from "./helpers/server/auth"; +import { db } from "./helpers/server/db"; +import { sql } from "drizzle-orm"; +import { users } from "./helpers/server/db/schema"; +import { getThemeToggler } from "./helpers/lib/get-theme-button"; + +export const runtime = "edge"; + +export default async function Page() { + const usr = await auth(); + + const userCount = await db + .select({ + count: sql<number>`count(*)`.mapWith(Number), + }) + .from(users); + + const SetThemeButton = getThemeToggler(); + + return ( + <main className="flex flex-col items-center justify-center min-h-screen"> + <div className="flex max-w-2xl justify-between w-full"> + <SetThemeButton /> + + <div className="flex gap-2 items-center justify-center"> + {" "} + <svg + viewBox="0 0 256 116" + xmlns="http://www.w3.org/2000/svg" + width="45px" + height="45px" + preserveAspectRatio="xMidYMid" + > + <path + fill="#FFF" + d="m202.357 49.394-5.311-2.124C172.085 103.434 72.786 69.289 66.81 85.997c-.996 11.286 54.227 2.146 93.706 4.059 12.039.583 18.076 9.671 12.964 24.484l10.069.031c11.615-36.209 48.683-17.73 50.232-29.68-2.545-7.857-42.601 0-31.425-35.497Z" + /> + <path + fill="#F4811F" + d="M176.332 108.348c1.593-5.31 1.062-10.622-1.593-13.809-2.656-3.187-6.374-5.31-11.154-5.842L71.17 87.634c-.531 0-1.062-.53-1.593-.53-.531-.532-.531-1.063 0-1.594.531-1.062 1.062-1.594 2.124-1.594l92.946-1.062c11.154-.53 22.839-9.56 27.087-20.182l5.312-13.809c0-.532.531-1.063 0-1.594C191.203 20.182 166.772 0 138.091 0 111.535 0 88.697 16.995 80.73 40.896c-5.311-3.718-11.684-5.843-19.12-5.31-12.747 1.061-22.838 11.683-24.432 24.43-.531 3.187 0 6.374.532 9.56C16.996 70.107 0 87.103 0 108.348c0 2.124 0 3.718.531 5.842 0 1.063 1.062 1.594 1.594 1.594h170.489c1.062 0 2.125-.53 2.125-1.594l1.593-5.842Z" + /> + <path + fill="#FAAD3F" + d="M205.544 48.863h-2.656c-.531 0-1.062.53-1.593 1.062l-3.718 12.747c-1.593 5.31-1.062 10.623 1.594 13.809 2.655 3.187 6.373 5.31 11.153 5.843l19.652 1.062c.53 0 1.062.53 1.593.53.53.532.53 1.063 0 1.594-.531 1.063-1.062 1.594-2.125 1.594l-20.182 1.062c-11.154.53-22.838 9.56-27.087 20.182l-1.063 4.78c-.531.532 0 1.594 1.063 1.594h70.108c1.062 0 1.593-.531 1.593-1.593 1.062-4.25 2.124-9.03 2.124-13.81 0-27.618-22.838-50.456-50.456-50.456" + /> + </svg> + <span className="italic">Cloudflare Next Saas Starter</span> + </div> + + <div className="border border-black dark:border-white rounded-2xl p-2 flex items-center"> + Start by editing apps/web/page.tsx + </div> + </div> + + <div className="max-w-2xl text-start w-full mt-16"> + Welcome to Cloudflare Next Saas Starter. <br /> Built a full stack app + using production-ready tools and frameworks, host on Cloudflare + instantly. + <br /> + An opinionated, batteries-included framework with{" "} + <a + className="text-transparent bg-clip-text bg-gradient-to-r from-[#a93d64] to-[#275ba9]" + href="https://turbo.build" + > + Turborepo + </a>{" "} + and Nextjs. Fully Typesafe. Best practices followed by default. + <br /> <br /> + Here's what the stack includes: + <ul className="list-disc mt-4 prose dark:prose-invert"> + <li> + Authentication with <code>next-auth</code> + </li> + <li>Database using Cloudflare's D1 serverless databases</li> + <li>Drizzle ORM, already connected to your database and auth ⚡</li> + <li>Light/darkmode theming that works with server components (!)</li> + <li>Styling using TailwindCSS and ShadcnUI</li> + <li>Turborepo with a landing page and shared components</li> + <li>Cloudflare wrangler for quick functions on the edge</li> + <li> + ... best part: everything's already set up for you. Just code! + </li> + </ul> + <div className="mt-4 flex flex-col gap-2"> + <span>Number of users in database: {userCount[0]!.count}</span> + </div> + {usr?.user?.email ? ( + <> + <div className="mt-4 flex flex-col gap-2"> + <span>Hello {usr.user.name} 👋</span> + <span>{usr.user.email}</span> + </div> + <form + action={async () => { + "use server"; + await signOut(); + }} + > + <Button className="mt-4">Sign out</Button> + </form> + </> + ) : ( + <form + action={async () => { + "use server"; + await signIn("google"); + }} + > + <Button className="mt-4">Login with Google</Button> + </form> + )} + </div> + </main> + ); +} diff --git a/apps/web/app/upload/file-uploader.tsx b/apps/web/app/upload/file-uploader.tsx new file mode 100644 index 00000000..2404ce7c --- /dev/null +++ b/apps/web/app/upload/file-uploader.tsx @@ -0,0 +1,315 @@ +"use client"; + +import * as React from "react"; +import Image from "next/image"; +import { Cross2Icon, UploadIcon } from "@radix-ui/react-icons"; +import Dropzone, { + type DropzoneProps, + type FileRejection, +} from "react-dropzone"; +import { toast } from "sonner"; + +import { cn, formatBytes } from "@repo/ui/lib/utils"; +import { useControllableState } from "@repo/ui/hooks/use-controllable-state"; +import { Button } from "@repo/ui/src/button"; +import { Progress } from "@repo/ui/src/progress"; +import { ScrollArea } from "@repo/ui/src/scroll-area"; + +interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> { + /** + * Value of the uploader. + * @type File[] + * @default undefined + * @example value={files} + */ + value?: File[]; + + /** + * Function to be called when the value changes. + * @type React.Dispatch<React.SetStateAction<File[]>> + * @default undefined + * @example onValueChange={(files) => setFiles(files)} + */ + onValueChange?: React.Dispatch<React.SetStateAction<File[]>>; + + /** + * Function to be called when files are uploaded. + * @type (files: File[]) => Promise<void> + * @default undefined + * @example onUpload={(files) => uploadFiles(files)} + */ + onUpload?: (files: File[]) => Promise<void>; + + /** + * Progress of the uploaded files. + * @type Record<string, number> | undefined + * @default undefined + * @example progresses={{ "file1.png": 50 }} + */ + progresses?: Record<string, number>; + + /** + * Accepted file types for the uploader. + * @type { [key: string]: string[]} + * @default + * ```ts + * { "image/*": [] } + * ``` + * @example accept={["image/png", "image/jpeg"]} + */ + accept?: DropzoneProps["accept"]; + + /** + * Maximum file size for the uploader. + * @type number | undefined + * @default 1024 * 1024 * 2 // 2MB + * @example maxSize={1024 * 1024 * 2} // 2MB + */ + maxSize?: DropzoneProps["maxSize"]; + + /** + * Maximum number of files for the uploader. + * @type number | undefined + * @default 1 + * @example maxFiles={5} + */ + maxFiles?: DropzoneProps["maxFiles"]; + + /** + * Whether the uploader should accept multiple files. + * @type boolean + * @default false + * @example multiple + */ + multiple?: boolean; + + /** + * Whether the uploader is disabled. + * @type boolean + * @default false + * @example disabled + */ + disabled?: boolean; +} + +export function FileUploader(props: FileUploaderProps) { + const { + value: valueProp, + onValueChange, + onUpload, + progresses, + accept = { "image/*": [] }, + maxSize = 1024 * 1024 * 2, + maxFiles = 1, + multiple = false, + disabled = false, + className, + ...dropzoneProps + } = props; + + const [files, setFiles] = useControllableState({ + prop: valueProp, + onChange: onValueChange, + }); + + const onDrop = React.useCallback( + (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { + if (!multiple && maxFiles === 1 && acceptedFiles.length > 1) { + toast.error("Cannot upload more than 1 file at a time"); + return; + } + + if ((files?.length ?? 0) + acceptedFiles.length > maxFiles) { + toast.error(`Cannot upload more than ${maxFiles} files`); + return; + } + + const newFiles = acceptedFiles.map((file) => + Object.assign(file, { + preview: URL.createObjectURL(file), + }), + ); + + const updatedFiles = files ? [...files, ...newFiles] : newFiles; + + setFiles(updatedFiles); + + if (rejectedFiles.length > 0) { + rejectedFiles.forEach(({ file }) => { + toast.error(`File ${file.name} was rejected`); + }); + } + + if ( + onUpload && + updatedFiles.length > 0 && + updatedFiles.length <= maxFiles + ) { + const target = + updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`; + + toast.promise(onUpload(updatedFiles), { + loading: `Uploading ${target}...`, + success: () => { + setFiles([]); + return `${target} uploaded`; + }, + error: `Failed to upload ${target}`, + }); + } + }, + + [files, maxFiles, multiple, onUpload, setFiles], + ); + + function onRemove(index: number) { + if (!files) return; + const newFiles = files.filter((_, i) => i !== index); + setFiles(newFiles); + onValueChange?.(newFiles); + } + + // Revoke preview url when component unmounts + React.useEffect(() => { + return () => { + if (!files) return; + files.forEach((file) => { + if (isFileWithPreview(file)) { + URL.revokeObjectURL(file.preview); + } + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const isDisabled = disabled || (files?.length ?? 0) >= maxFiles; + + return ( + <div className="relative flex flex-col gap-6 overflow-hidden"> + <Dropzone + onDrop={onDrop} + accept={accept} + maxSize={maxSize} + maxFiles={maxFiles} + multiple={maxFiles > 1 || multiple} + disabled={isDisabled} + > + {({ getRootProps, getInputProps, isDragActive }) => ( + <div + {...getRootProps()} + className={cn( + "group relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-muted-foreground/25 px-5 py-2.5 text-center transition hover:bg-muted/25", + "ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + isDragActive && "border-muted-foreground/50", + isDisabled && "pointer-events-none opacity-60", + className, + )} + {...dropzoneProps} + > + <input {...getInputProps()} /> + {isDragActive ? ( + <div className="flex flex-col items-center justify-center gap-4 sm:px-5"> + <div className="rounded-full border border-dashed p-3"> + <UploadIcon + className="size-7 text-muted-foreground" + aria-hidden="true" + /> + </div> + <p className="font-medium text-muted-foreground"> + Drop the files here + </p> + </div> + ) : ( + <div className="flex flex-col items-center justify-center gap-4 sm:px-5"> + <div className="rounded-full border border-dashed p-3"> + <UploadIcon + className="size-7 text-muted-foreground" + aria-hidden="true" + /> + </div> + <div className="space-y-px"> + <p className="font-medium text-muted-foreground"> + Drag {`'n'`} drop files here, or click to select files + </p> + <p className="text-sm text-muted-foreground/70"> + You can upload + {maxFiles > 1 + ? ` ${maxFiles === Infinity ? "multiple" : maxFiles} + files (up to ${formatBytes(maxSize)} each)` + : ` a file with ${formatBytes(maxSize)}`} + </p> + </div> + </div> + )} + </div> + )} + </Dropzone> + {files?.length ? ( + <ScrollArea className="h-fit w-full px-3"> + <div className="max-h-48 space-y-4"> + {files?.map((file, index) => ( + <FileCard + key={index} + file={file} + onRemove={() => onRemove(index)} + progress={progresses?.[file.name]} + /> + ))} + </div> + </ScrollArea> + ) : null} + </div> + ); +} + +interface FileCardProps { + file: File; + onRemove: () => void; + progress?: number; +} + +function FileCard({ file, progress, onRemove }: FileCardProps) { + return ( + <div className="relative flex items-center space-x-4"> + <div className="flex flex-1 space-x-4"> + {isFileWithPreview(file) ? ( + <Image + src={file.preview} + alt={file.name} + width={48} + height={48} + loading="lazy" + className="aspect-square shrink-0 rounded-md object-cover" + /> + ) : null} + <div className="flex w-full flex-col gap-2"> + <div className="space-y-px"> + <p className="line-clamp-1 text-sm font-medium text-foreground/80"> + {file.name} + </p> + <p className="text-xs text-muted-foreground"> + {formatBytes(file.size)} + </p> + </div> + {progress ? <Progress value={progress} /> : null} + </div> + </div> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + size="icon" + className="size-7" + onClick={onRemove} + > + <Cross2Icon className="size-4 " aria-hidden="true" /> + <span className="sr-only">Remove file</span> + </Button> + </div> + </div> + ); +} + +function isFileWithPreview(file: File): file is File & { preview: string } { + return "preview" in file && typeof file.preview === "string"; +} diff --git a/apps/web/app/upload/page.tsx b/apps/web/app/upload/page.tsx new file mode 100644 index 00000000..4899d695 --- /dev/null +++ b/apps/web/app/upload/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import * as React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { getErrorMessage } from "../helpers/lib/handle-errors"; +import { Button } from "@repo/ui/src/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@repo/ui/src/form"; +import { FileUploader } from "./file-uploader"; + +import { UploadedFilesCard } from "./uploaded-files-card"; +import { useUploadFile } from "@repo/ui/hooks/use-upload-file"; + +const schema = z.object({ + images: z.array(z.instanceof(File)), +}); + +type Schema = z.infer<typeof schema>; + +export default function ReactHookFormDemo() { + const [loading, setLoading] = React.useState(false); + const { uploadFiles, uploadedFiles, isUploading } = useUploadFile( + "imageUploader", + { defaultUploadedFiles: [] }, + ); + const form = useForm<Schema>({ + resolver: zodResolver(schema), + defaultValues: { + images: [], + }, + }); + + function onSubmit(input: Schema) { + setLoading(true); + + toast.promise(uploadFiles(input.images), { + loading: "Uploading images...", + success: () => { + form.reset(); + setLoading(false); + return "Images uploaded"; + }, + error: (err) => { + setLoading(false); + return getErrorMessage(err); + }, + }); + } + + return ( + <div className="flex flex-col w-full min-h-screen items-center justify-center"> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex max-w-2xl flex-col gap-6 w-full" + > + <FormField + control={form.control} + name="images" + render={({ field }) => ( + <div className="space-y-6"> + <FormItem className="w-full"> + <FormLabel>Images</FormLabel> + <FormControl> + <FileUploader + value={field.value} + onValueChange={field.onChange} + maxFiles={4} + maxSize={4 * 1024 * 1024} + onUpload={uploadFiles} + disabled={isUploading} + /> + </FormControl> + <FormMessage /> + </FormItem> + {uploadedFiles.length > 0 ? ( + <UploadedFilesCard uploadedFiles={uploadedFiles} /> + ) : null} + </div> + )} + /> + <Button className="w-fit" disabled={loading}> + Save + </Button> + </form> + </Form> + </div> + ); +} diff --git a/apps/web/app/upload/uploaded-files-card.tsx b/apps/web/app/upload/uploaded-files-card.tsx new file mode 100644 index 00000000..fc9b7d5b --- /dev/null +++ b/apps/web/app/upload/uploaded-files-card.tsx @@ -0,0 +1,93 @@ +import Image from "next/image"; +import type { UploadedFile } from "@repo/shared-types"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@repo/ui/src/card"; +import { ScrollArea, ScrollBar } from "@repo/ui/src/scroll-area"; + +interface UploadedFilesCardProps { + uploadedFiles: UploadedFile[]; +} + +import { ImageIcon } from "@radix-ui/react-icons"; + +import { cn } from "@repo/ui/lib/utils"; + +interface EmptyCardProps extends React.ComponentPropsWithoutRef<typeof Card> { + title: string; + description?: string; + action?: React.ReactNode; + icon?: React.ComponentType<{ className?: string }>; + className?: string; +} + +function EmptyCard({ + title, + description, + icon: Icon = ImageIcon, + action, + className, + ...props +}: EmptyCardProps) { + return ( + <Card + className={cn( + "flex w-full flex-col items-center justify-center space-y-6 bg-transparent p-16", + className, + )} + {...props} + > + <div className="mr-4 shrink-0 rounded-full border border-dashed p-4"> + <Icon className="size-8 text-muted-foreground" aria-hidden="true" /> + </div> + <div className="flex flex-col items-center gap-1.5 text-center"> + <CardTitle>{title}</CardTitle> + {description ? <CardDescription>{description}</CardDescription> : null} + </div> + {action ? action : null} + </Card> + ); +} + +export function UploadedFilesCard({ uploadedFiles }: UploadedFilesCardProps) { + return ( + <Card> + <CardHeader> + <CardTitle>Uploaded files</CardTitle> + <CardDescription>View the uploaded files here</CardDescription> + </CardHeader> + <CardContent> + {uploadedFiles.length > 0 ? ( + <ScrollArea className="pb-4"> + <div className="flex w-max space-x-2.5"> + {uploadedFiles.map((file) => ( + <div key={file.key} className="relative aspect-video w-64"> + <Image + src={file.url} + alt={file.name} + fill + sizes="(min-width: 640px) 640px, 100vw" + loading="lazy" + className="rounded-md object-cover" + /> + </div> + ))} + </div> + <ScrollBar orientation="horizontal" /> + </ScrollArea> + ) : ( + <EmptyCard + title="No files uploaded" + description="Upload some files to see them here" + className="w-full" + /> + )} + </CardContent> + </Card> + ); +} |