aboutsummaryrefslogtreecommitdiff
path: root/apps/web/app
diff options
context:
space:
mode:
authorDhravya <[email protected]>2024-05-25 18:41:26 -0500
committerDhravya <[email protected]>2024-05-25 18:41:26 -0500
commit075f45986fd4d198292226e64afb71b3515576b4 (patch)
tree5c728356cd0310f1c1c012fd6618c72a836c314b /apps/web/app
parentadded social material (diff)
downloadsupermemory-075f45986fd4d198292226e64afb71b3515576b4.tar.xz
supermemory-075f45986fd4d198292226e64afb71b3515576b4.zip
refactored UI, with shared components and UI, better rules and million lint
Diffstat (limited to 'apps/web/app')
-rw-r--r--apps/web/app/(auth)/auth-buttons.tsx15
-rw-r--r--apps/web/app/(auth)/signin/page.tsx8
-rw-r--r--apps/web/app/(landing)/Cta.tsx44
-rw-r--r--apps/web/app/(landing)/EmailInput.tsx78
-rw-r--r--apps/web/app/(landing)/FeatureContent.tsx59
-rw-r--r--apps/web/app/(landing)/Features.tsx251
-rw-r--r--apps/web/app/(landing)/Hero.tsx74
-rw-r--r--apps/web/app/(landing)/Navbar.tsx37
-rw-r--r--apps/web/app/(landing)/RotatingIcons.tsx97
-rw-r--r--apps/web/app/(landing)/footer.tsx38
-rw-r--r--apps/web/app/(landing)/formSubmitAction.ts48
-rw-r--r--apps/web/app/(landing)/linkArrow.tsx34
-rw-r--r--apps/web/app/(landing)/page.tsx58
-rw-r--r--apps/web/app/api/[...nextauth]/route.ts2
-rw-r--r--apps/web/app/api/ensureAuth.ts33
-rw-r--r--apps/web/app/api/hello/route.ts22
-rw-r--r--apps/web/app/api/upload_image/route.ts56
-rw-r--r--apps/web/app/globals.css50
-rw-r--r--apps/web/app/helpers/lib/get-theme-button.tsx11
-rw-r--r--apps/web/app/helpers/lib/handle-errors.ts25
-rw-r--r--apps/web/app/helpers/server/auth.ts29
-rw-r--r--apps/web/app/helpers/server/db/index.ts5
-rw-r--r--apps/web/app/helpers/server/db/schema.ts143
-rw-r--r--apps/web/app/layout.tsx71
-rw-r--r--apps/web/app/page-ref.tsx116
-rw-r--r--apps/web/app/upload/file-uploader.tsx315
-rw-r--r--apps/web/app/upload/page.tsx99
-rw-r--r--apps/web/app/upload/uploaded-files-card.tsx93
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>
+ );
+}