diff options
| author | Kinfe Michael Tariku <[email protected]> | 2024-06-25 19:56:54 +0300 |
|---|---|---|
| committer | GitHub <[email protected]> | 2024-06-25 19:56:54 +0300 |
| commit | f46e42c2dfd1b223d4ad701a86d05fc0bb380e45 (patch) | |
| tree | f17fdfadf3bec08eee7f02da33af952796657254 /apps/web/app | |
| parent | fix: import using absolute path (diff) | |
| parent | dev and prod databases (diff) | |
| download | supermemory-f46e42c2dfd1b223d4ad701a86d05fc0bb380e45.tar.xz supermemory-f46e42c2dfd1b223d4ad701a86d05fc0bb380e45.zip | |
Merge branch 'main' into feat/landing_revamp
Diffstat (limited to 'apps/web/app')
43 files changed, 2434 insertions, 651 deletions
diff --git a/apps/web/app/(auth)/auth-buttons.tsx b/apps/web/app/(auth)/auth-buttons.tsx index 0e99213e..5b0ad06e 100644 --- a/apps/web/app/(auth)/auth-buttons.tsx +++ b/apps/web/app/(auth)/auth-buttons.tsx @@ -2,7 +2,7 @@ import { Button } from "@repo/ui/shadcn/button"; import React from "react"; -import { signIn } from "../helpers/server/auth"; +import { signIn } from "../../server/auth"; function SignIn() { return ( diff --git a/apps/web/app/(canvas)/canvas/[id]/page.tsx b/apps/web/app/(canvas)/canvas/[id]/page.tsx new file mode 100644 index 00000000..6efb6cf4 --- /dev/null +++ b/apps/web/app/(canvas)/canvas/[id]/page.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { Canvas } from "@repo/ui/components/canvas/components/canvas"; +import React, { useState } from "react"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { SettingsIcon, DragIcon } from "@repo/ui/icons"; +import DraggableComponentsContainer from "@repo/ui/components/canvas/components/draggableComponent"; +import { AutocompleteIcon, blockIcon } from "@repo/ui/icons"; +import Image from "next/image"; +import { Switch } from "@repo/ui/shadcn/switch"; +import { Label } from "@repo/ui/shadcn/label"; +import { useRouter } from "next/router"; + +function page() { + const [fullScreen, setFullScreen] = useState(false); + const [visible, setVisible] = useState(true); + + const router = useRouter(); + router.push("/home"); + + return ( + <div + className={`h-screen w-full ${!fullScreen ? "px-4 py-6" : "bg-[#1F2428]"} transition-all`} + > + <div> + <PanelGroup + onLayout={(l) => { + l[0]! < 20 ? setVisible(false) : setVisible(true); + }} + className={` ${fullScreen ? "w-[calc(100vw-2rem)]" : "w-screen"} transition-all`} + direction="horizontal" + > + <Panel + onExpand={() => { + setTimeout(() => setFullScreen(false), 50); + }} + onCollapse={() => { + setTimeout(() => setFullScreen(true), 50); + }} + defaultSize={30} + collapsible={true} + > + <div + className={`flex transition-all rounded-2xl ${fullScreen ? "h-screen" : "h-[calc(100vh-3rem)]"} w-full flex-col overflow-hidden bg-[#1F2428]`} + > + <div className="flex items-center justify-between bg-[#2C3439] px-4 py-2 text-lg font-medium text-[#989EA4]"> + Change Filters + <Image src={SettingsIcon} alt="setting-icon" /> + </div> + {visible ? ( + <SidePanel /> + ) : ( + <h1 className="text-center py-10 text-xl"> + Need more space to show! + </h1> + )} + </div> + </Panel> + <PanelResizeHandle + className={`relative flex items-center transition-all justify-center ${!fullScreen && "px-1"}`} + > + <div + className={`rounded-lg bg-[#2F363B] ${!fullScreen && "px-1"} transition-all py-2`} + > + <Image src={DragIcon} alt="drag-icon" /> + </div> + </PanelResizeHandle> + <Panel className="relative" defaultSize={70} minSize={60}> + <div + className={`absolute overflow-hidden transition-all inset-0 ${fullScreen ? "h-screen " : "h-[calc(100vh-3rem)] rounded-2xl"} w-full`} + > + <Canvas /> + </div> + </Panel> + </PanelGroup> + </div> + </div> + ); +} + +function SidePanel() { + const [value, setValue] = useState(""); + const [dragAsText, setDragAsText] = useState(false); + return ( + <> + <div className="px-3 py-5"> + <input + placeholder="search..." + onChange={(e) => { + setValue(e.target.value); + }} + value={value} + // rows={1} + className="w-full resize-none rounded-xl bg-[#151515] px-3 py-4 text-xl text-[#989EA4] outline-none focus:outline-none sm:max-h-52" + /> + </div> + <div className="flex items-center justify-end px-3 py-4"> + <Switch + className="bg-[#151515] data-[state=unchecked]:bg-red-400 data-[state=checked]:bg-blue-400" + onCheckedChange={(e) => setDragAsText(e)} + id="drag-text-mode" + /> + <Label htmlFor="drag-text-mode">Drag as Text</Label> + </div> + <DraggableComponentsContainer content={content} /> + </> + ); +} + +export default page; + +const content = [ + { + content: + "Regional growth patterns diverge, with strong performance in the United States and several emerging markets, contrasted by weaker prospects in many advanced economies, particularly in Europe (World Economic Forum) (OECD). The rapid adoption of artificial intelligence (AI) is expected to drive productivity growth, especially in advanced economies, potentially mitigating labor shortages and boosting income levels in emerging markets (World Economic Forum) (OECD). However, ongoing geopolitical tensions and economic fragmentation are likely to maintain a level of uncertainty and volatility in the global economy (World Economic Forum.", + icon: AutocompleteIcon, + iconAlt: "Autocomplete", + extraInfo: + "Page Url: https://chatgpt.com/c/762cd44e-1752-495b-967a-aa3c23c6024a", + }, + { + content: + "As of mid-2024, the global economy is experiencing modest growth with significant regional disparities. Global GDP growth is projected to be around 3.1% in 2024, rising slightly to 3.2% in 2025. This performance, although below the pre-pandemic average, reflects resilience despite various economic pressures, including tight monetary conditions and geopolitical tensions (IMF)(OECD) Inflation is moderating faster than expected, with global headline inflation projected to fall to 5.8% in 2024 and 4.4% in 2025, contributing to improving real incomes and positive trade growth (IMF) (OECD)", + icon: blockIcon, + iconAlt: "Autocomplete", + extraInfo: + "Page Url: https://www.cnbc.com/2024/05/23/nvidia-keeps-hitting-records-can-investors-still-buy-the-stock.html?&qsearchterm=nvidia", + }, +]; diff --git a/apps/web/app/(canvas)/canvas/layout.tsx b/apps/web/app/(canvas)/canvas/layout.tsx new file mode 100644 index 00000000..9bc3b6d7 --- /dev/null +++ b/apps/web/app/(canvas)/canvas/layout.tsx @@ -0,0 +1,13 @@ +import "../canvasStyles.css"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <div lang="en" className="bg-[#151515]"> + <div>{children}</div> + </div> + ); +} diff --git a/apps/web/app/(canvas)/canvas/page.tsx b/apps/web/app/(canvas)/canvas/page.tsx new file mode 100644 index 00000000..8b5252af --- /dev/null +++ b/apps/web/app/(canvas)/canvas/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from "next/navigation"; +import React from "react"; + +function page() { + redirect("/signin"); + return <div>page</div>; +} + +export default page; diff --git a/apps/web/app/(canvas)/canvasStyles.css b/apps/web/app/(canvas)/canvasStyles.css new file mode 100644 index 00000000..04da2054 --- /dev/null +++ b/apps/web/app/(canvas)/canvasStyles.css @@ -0,0 +1,28 @@ +.tl-background { + background: #1F2428 !important; +} + +.tlui-style-panel.tlui-style-panel__wrapper, .tlui-navigation-panel::before ,.tlui-menu-zone, .tlui-toolbar__tools, .tlui-popover__content, .tlui-menu, .tlui-button__help, .tlui-help-menu, .tlui-dialog__content { + background: #2C3439 !important; + border-top: #2C3439 !important; + border-right: #2C3439 !important; + border-bottom: #2C3439 !important; + border-left: #2C3439 !important; +} + +.tlui-navigation-panel::before { + border-top: #2C3439 !important; + border-right: #2C3439 !important; +} + +.tlui-minimap { + background: #2C3439 !important; +} + +.tlui-minimap__canvas { + background: #1F2428 !important; +} + +.tlui-dialog__overlay { + position: fixed; +}
\ No newline at end of file diff --git a/apps/web/app/(dash)/actions.ts b/apps/web/app/(dash)/actions.ts deleted file mode 100644 index 70c2a567..00000000 --- a/apps/web/app/(dash)/actions.ts +++ /dev/null @@ -1,48 +0,0 @@ -"use server"; - -import { cookies, headers } from "next/headers"; -import { db } from "../helpers/server/db"; -import { sessions, users, space } from "../helpers/server/db/schema"; -import { eq } from "drizzle-orm"; -import { redirect } from "next/navigation"; - -export async function ensureAuth() { - const token = - cookies().get("next-auth.session-token")?.value ?? - cookies().get("__Secure-authjs.session-token")?.value ?? - cookies().get("authjs.session-token")?.value ?? - 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]!, - }; -} - -export async function getSpaces() { - const data = await ensureAuth(); - if (!data) { - redirect("/signin"); - } - - const sp = await db - .select() - .from(space) - .where(eq(space.user, data.user.email)); - - return sp; -} diff --git a/apps/web/app/(dash)/chat/CodeBlock.tsx b/apps/web/app/(dash)/chat/CodeBlock.tsx new file mode 100644 index 00000000..0bb6a19d --- /dev/null +++ b/apps/web/app/(dash)/chat/CodeBlock.tsx @@ -0,0 +1,90 @@ +import React, { useRef, useState } from "react"; + +const CodeBlock = ({ + lang, + codeChildren, +}: { + lang: string; + codeChildren: React.ReactNode & React.ReactNode[]; +}) => { + const codeRef = useRef<HTMLElement>(null); + + return ( + <div className="bg-black rounded-md"> + <CodeBar lang={lang} codeRef={codeRef} /> + <div className="p-4 overflow-y-auto"> + <code ref={codeRef} className={`!whitespace-pre hljs language-${lang}`}> + {codeChildren} + </code> + </div> + </div> + ); +}; + +const CodeBar = React.memo( + ({ + lang, + codeRef, + }: { + lang: string; + codeRef: React.RefObject<HTMLElement>; + }) => { + const [isCopied, setIsCopied] = useState<boolean>(false); + return ( + <div className="flex items-center relative text-gray-200 bg-gray-800 px-4 py-2 text-xs font-sans"> + <span className="">{lang}</span> + <button + className="flex ml-auto gap-2" + aria-label="copy codeblock" + onClick={async () => { + const codeString = codeRef.current?.textContent; + if (codeString) + navigator.clipboard.writeText(codeString).then(() => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), 3000); + }); + }} + > + {isCopied ? ( + <> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth={1.5} + stroke="currentColor" + className="size-4" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0 1 18 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3 1.5 1.5 3-3.75" + /> + </svg> + Copied! + </> + ) : ( + <> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth={1.5} + stroke="currentColor" + className="size-4" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" + /> + </svg> + Copy code + </> + )} + </button> + </div> + ); + }, +); +export default CodeBlock; diff --git a/apps/web/app/(dash)/chat/[chatid]/page.tsx b/apps/web/app/(dash)/chat/[chatid]/page.tsx new file mode 100644 index 00000000..e37ae07e --- /dev/null +++ b/apps/web/app/(dash)/chat/[chatid]/page.tsx @@ -0,0 +1,38 @@ +import { getFullChatThread } from "@/app/actions/fetchers"; +import { chatSearchParamsCache } from "@/lib/searchParams"; +import ChatWindow from "../chatWindow"; + +async function Page({ + params, + searchParams, +}: { + params: { chatid: string }; + searchParams: Record<string, string | string[] | undefined>; +}) { + const { firstTime, q, spaces } = chatSearchParamsCache.parse(searchParams); + + let chat: Awaited<ReturnType<typeof getFullChatThread>>; + + try { + chat = await getFullChatThread(params.chatid); + } catch (e) { + const error = e as Error; + return <div>This page errored out: {error.message}</div>; + } + + if (!chat.success || !chat.data) { + console.error(chat.error); + return <div>Chat not found. Check the console for more details.</div>; + } + + return ( + <ChatWindow + q={q} + spaces={spaces} + initialChat={chat.data.length > 0 ? chat.data : undefined} + threadId={params.chatid} + /> + ); +} + +export default Page; diff --git a/apps/web/app/(dash)/chat/actions.ts b/apps/web/app/(dash)/chat/actions.ts deleted file mode 100644 index 908fe79e..00000000 --- a/apps/web/app/(dash)/chat/actions.ts +++ /dev/null @@ -1 +0,0 @@ -"use server"; diff --git a/apps/web/app/(dash)/chat/chatWindow.tsx b/apps/web/app/(dash)/chat/chatWindow.tsx index 43c337ee..9a18cfe7 100644 --- a/apps/web/app/(dash)/chat/chatWindow.tsx +++ b/apps/web/app/(dash)/chat/chatWindow.tsx @@ -1,51 +1,438 @@ "use client"; import { AnimatePresence } from "framer-motion"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import QueryInput from "../home/queryinput"; import { cn } from "@repo/ui/lib/utils"; import { motion } from "framer-motion"; import { useRouter } from "next/navigation"; +import { ChatHistory, sourcesZod } from "@repo/shared-types"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@repo/ui/shadcn/accordion"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import rehypeHighlight from "rehype-highlight"; +import { code, p } from "./markdownRenderHelpers"; +import { codeLanguageSubset } from "@/lib/constants"; +import { toast } from "sonner"; +import Link from "next/link"; +import { createChatObject } from "@/app/actions/doers"; +import { ClipboardIcon } from "@heroicons/react/24/outline"; +import { SendIcon } from "lucide-react"; -function ChatWindow({ q }: { q: string }) { - const [layout, setLayout] = useState<"chat" | "initial">("initial"); +function ChatWindow({ + q, + spaces, + initialChat = [ + { + question: q, + answer: { + parts: [], + sources: [], + }, + }, + ], + threadId, +}: { + q: string; + spaces: { id: string; name: string }[]; + initialChat?: ChatHistory[]; + threadId: string; +}) { + const [layout, setLayout] = useState<"chat" | "initial">( + initialChat.length > 1 ? "chat" : "initial", + ); + const [chatHistory, setChatHistory] = useState<ChatHistory[]>(initialChat); + + const removeJustificationFromText = (text: string) => { + // remove everything after the first "<justification>" word + const justificationLine = text.indexOf("<justification>"); + if (justificationLine !== -1) { + // Add that justification to the last chat message + const lastChatMessage = chatHistory[chatHistory.length - 1]; + if (lastChatMessage) { + lastChatMessage.answer.justification = text.slice(justificationLine); + } + return text.slice(0, justificationLine); + } + return text; + }; const router = useRouter(); + const getAnswer = async (query: string, spaces: string[]) => { + const sourcesFetch = await fetch( + `/api/chat?q=${query}&spaces=${spaces}&sourcesOnly=true&threadId=${threadId}`, + { + method: "POST", + body: JSON.stringify({ chatHistory }), + }, + ); + + // TODO: handle this properly + const sources = await sourcesFetch.json(); + + const sourcesParsed = sourcesZod.safeParse(sources); + + if (!sourcesParsed.success) { + console.error(sourcesParsed.error); + toast.error("Something went wrong while getting the sources"); + return; + } + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: "smooth", + }); + + const updateChatHistoryAndFetch = async () => { + // Step 1: Update chat history with the assistant's response + await new Promise((resolve) => { + setChatHistory((prevChatHistory) => { + const newChatHistory = [...prevChatHistory]; + const lastAnswer = newChatHistory[newChatHistory.length - 1]; + if (!lastAnswer) { + resolve(undefined); + return prevChatHistory; + } + + const filteredSourceUrls = new Set( + sourcesParsed.data.metadata.map((source) => source.url), + ); + const uniqueSources = sourcesParsed.data.metadata.filter((source) => { + if (filteredSourceUrls.has(source.url)) { + filteredSourceUrls.delete(source.url); + return true; + } + return false; + }); + + lastAnswer.answer.sources = uniqueSources.map((source) => ({ + title: source.title ?? "Untitled", + type: source.type ?? "page", + source: source.url ?? "https://supermemory.ai", + content: source.description ?? "No content available", + numChunks: sourcesParsed.data.metadata.filter( + (f) => f.url === source.url, + ).length, + })); + + resolve(newChatHistory); + return newChatHistory; + }); + }); + + // Step 2: Fetch data from the API + const resp = await fetch( + `/api/chat?q=${query}&spaces=${spaces}&threadId=${threadId}`, + { + method: "POST", + body: JSON.stringify({ chatHistory, sources: sourcesParsed.data }), + }, + ); + + // Step 3: Read the response stream and update the chat history + const reader = resp.body?.getReader(); + let done = false; + while (!done && reader) { + const { value, done: d } = await reader.read(); + if (d) { + setChatHistory((prevChatHistory) => { + createChatObject(threadId, prevChatHistory); + return prevChatHistory; + }); + } + done = d; + + const txt = new TextDecoder().decode(value); + setChatHistory((prevChatHistory) => { + const newChatHistory = [...prevChatHistory]; + const lastAnswer = newChatHistory[newChatHistory.length - 1]; + if (!lastAnswer) return prevChatHistory; + + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: "smooth", + }); + + lastAnswer.answer.parts.push({ text: txt }); + return newChatHistory; + }); + } + }; + + updateChatHistoryAndFetch(); + }; + useEffect(() => { - if (q !== "") { - setTimeout(() => { - setLayout("chat"); - }, 300); + if (q.trim().length > 0 || chatHistory.length > 0) { + setLayout("chat"); + const lastChat = chatHistory.length > 0 ? chatHistory.length - 1 : 0; + const startGenerating = chatHistory[lastChat]?.answer.parts[0]?.text + ? false + : true; + if (startGenerating) { + getAnswer( + q, + spaces.map((s) => `${s}`), + ); + } } else { router.push("/home"); } - }, [q]); + }, []); + return ( - <div> + <div className="h-full"> <AnimatePresence mode="popLayout"> {layout === "initial" ? ( <motion.div exit={{ opacity: 0 }} key="initial" - className="max-w-3xl flex mx-auto w-full flex-col" + className="max-w-3xl h-full justify-center items-center flex mx-auto w-full flex-col" > <div className="w-full h-96"> - <QueryInput initialQuery={q} initialSpaces={[]} disabled /> + <QueryInput + handleSubmit={() => {}} + initialQuery={q} + initialSpaces={[]} + disabled + /> </div> </motion.div> ) : ( <div - className="max-w-3xl flex mx-auto w-full flex-col mt-8" + className="max-w-3xl z-10 mx-auto relative h-full overflow-y-auto no-scrollbar" key="chat" > - <h2 - className={cn( - "transition-all transform translate-y-0 opacity-100 duration-500 ease-in-out font-semibold text-2xl", - )} - > - {q} - </h2> + <div className="w-full pt-24 mb-40"> + {chatHistory.map((chat, idx) => ( + <div key={idx} className="space-y-16"> + <div + className={`mt-8 ${idx != chatHistory.length - 1 ? "pb-2 border-b border-b-gray-400" : ""}`} + > + <h2 + className={cn( + "text-white transition-all transform translate-y-0 opacity-100 duration-500 ease-in-out font-semibold text-xl", + )} + > + {chat.question} + </h2> + + <div className="flex flex-col"> + {/* Related memories */} + <div + className={`space-y-4 ${chat.answer.sources.length > 0 || chat.answer.parts.length === 0 ? "flex" : "hidden"}`} + > + <Accordion + defaultValue={ + idx === chatHistory.length - 1 ? "memories" : "" + } + type="single" + collapsible + > + <AccordionItem value="memories"> + <AccordionTrigger className="text-foreground-menu"> + Related Memories + </AccordionTrigger> + {/* TODO: fade out content on the right side, the fade goes away when the user scrolls */} + <AccordionContent + className="flex items-center no-scrollbar overflow-auto gap-4 relative max-w-3xl no-scrollbar" + defaultChecked + > + {/* Loading state */} + {chat.answer.sources.length > 0 || + (chat.answer.parts.length === 0 && ( + <> + {[1, 2, 3, 4].map((_, idx) => ( + <div + key={`loadingState-${idx}`} + className="w-[350px] shrink-0 p-4 gap-2 rounded-2xl flex flex-col bg-secondary animate-pulse" + > + <div className="bg-slate-700 h-2 rounded-full w-1/2"></div> + <div className="bg-slate-700 h-2 rounded-full w-full"></div> + </div> + ))} + </> + ))} + {chat.answer.sources.map((source, idx) => ( + <Link + href={source.source} + key={idx} + className="w-[350px] shrink-0 p-4 gap-2 rounded-2xl flex flex-col bg-secondary" + > + <div className="flex justify-between text-foreground-menu text-sm"> + <span>{source.type}</span> + + {source.numChunks > 1 && ( + <span>{source.numChunks} chunks</span> + )} + </div> + <div className="text-base"> + {source.title} + </div> + <div className="text-xs line-clamp-2"> + {source.content.length > 100 + ? source.content.slice(0, 100) + "..." + : source.content} + </div> + </Link> + ))} + </AccordionContent> + </AccordionItem> + </Accordion> + </div> + + {/* Summary */} + <div> + <div className="text-foreground-menu py-2">Summary</div> + <div className="text-base"> + {/* Loading state */} + {(chat.answer.parts.length === 0 || + chat.answer.parts.join("").length === 0) && ( + <div className="animate-pulse flex space-x-4"> + <div className="flex-1 space-y-3 py-1"> + <div className="h-2 bg-slate-700 rounded"></div> + <div className="h-2 bg-slate-700 rounded"></div> + </div> + </div> + )} + + <Markdown + remarkPlugins={[remarkGfm, [remarkMath]]} + rehypePlugins={[ + rehypeKatex, + [ + rehypeHighlight, + { + detect: true, + ignoreMissing: true, + subset: codeLanguageSubset, + }, + ], + ]} + components={{ + code: code as any, + p: p as any, + }} + className="flex flex-col gap-2 text-base" + > + {removeJustificationFromText( + chat.answer.parts + .map((part) => part.text) + .join(""), + )} + </Markdown> + + <div className="mt-3 relative -left-2 flex items-center gap-1"> + {/* TODO: speak response */} + {/* <button className="group h-8 w-8 flex justify-center items-center active:scale-75 duration-200"> + <SpeakerWaveIcon className="size-[18px] group-hover:text-primary" /> + </button> */} + {/* copy response */} + <button + onClick={() => + navigator.clipboard.writeText( + chat.answer.parts + .map((part) => part.text) + .join(""), + ) + } + className="group h-8 w-8 flex justify-center items-center active:scale-75 duration-200" + > + <ClipboardIcon className="size-[18px] group-hover:text-primary" /> + </button> + <button + onClick={async () => { + const isWebShareSupported = + navigator.share !== undefined; + if (isWebShareSupported) { + try { + await navigator.share({ + title: "Your Share Title", + text: "Your share text or description", + url: "https://your-url-to-share.com", + }); + } catch (e) { + console.error("Error sharing:", e); + } + } else { + console.error("web share is not supported!"); + } + }} + className="group h-8 w-8 flex justify-center items-center active:scale-75 duration-200" + > + <SendIcon className="size-[18px] group-hover:text-primary" /> + </button> + </div> + </div> + </div> + {/* Justification */} + {chat.answer.justification && + chat.answer.justification.length && ( + <div + className={`${chat.answer.justification && chat.answer.justification.length > 0 ? "flex" : "hidden"}`} + > + <Accordion + defaultValue={""} + type="single" + collapsible + > + <AccordionItem value="justification"> + <AccordionTrigger className="text-foreground-menu"> + Justification + </AccordionTrigger> + <AccordionContent + className="relative flex gap-2 max-w-3xl overflow-auto no-scrollbar" + defaultChecked + > + {chat.answer.justification.length > 0 + ? chat.answer.justification + .replaceAll("<justification>", "") + .replaceAll("</justification>", "") + : "No justification provided."} + </AccordionContent> + </AccordionItem> + </Accordion> + </div> + )} + </div> + </div> + </div> + ))} + </div> + + <div className="fixed bottom-4 w-full max-w-3xl"> + <QueryInput + mini + className="w-full shadow-md" + initialQuery={""} + initialSpaces={[]} + handleSubmit={async (q, spaces) => { + setChatHistory((prevChatHistory) => { + return [ + ...prevChatHistory, + { + question: q, + answer: { + parts: [], + sources: [], + }, + }, + ]; + }); + await getAnswer( + q, + spaces.map((s) => `${s.id}`), + ); + }} + /> + </div> </div> )} </AnimatePresence> diff --git a/apps/web/app/(dash)/chat/markdownRenderHelpers.tsx b/apps/web/app/(dash)/chat/markdownRenderHelpers.tsx new file mode 100644 index 00000000..747d4fca --- /dev/null +++ b/apps/web/app/(dash)/chat/markdownRenderHelpers.tsx @@ -0,0 +1,25 @@ +import { DetailedHTMLProps, HTMLAttributes, memo } from "react"; +import { ExtraProps } from "react-markdown"; +import CodeBlock from "./CodeBlock"; + +export const code = memo((props: JSX.IntrinsicElements["code"]) => { + const { className, children } = props; + const match = /language-(\w+)/.exec(className || ""); + const lang = match && match[1]; + + return <CodeBlock lang={lang || "text"} codeChildren={children as any} />; +}); + +export const p = memo( + ( + props?: Omit< + DetailedHTMLProps< + HTMLAttributes<HTMLParagraphElement>, + HTMLParagraphElement + >, + "ref" + >, + ) => { + return <p className="whitespace-pre-wrap">{props?.children}</p>; + }, +); diff --git a/apps/web/app/(dash)/chat/page.tsx b/apps/web/app/(dash)/chat/page.tsx deleted file mode 100644 index 9e28fda7..00000000 --- a/apps/web/app/(dash)/chat/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import ChatWindow from "./chatWindow"; -import { chatSearchParamsCache } from "../../helpers/lib/searchParams"; - -function Page({ - searchParams, -}: { - searchParams: Record<string, string | string[] | undefined>; -}) { - const { firstTime, q, spaces } = chatSearchParamsCache.parse(searchParams); - - console.log(spaces); - - return <ChatWindow q={q} />; -} - -export default Page; diff --git a/apps/web/app/(dash)/dynamicisland.tsx b/apps/web/app/(dash)/dynamicisland.tsx new file mode 100644 index 00000000..8b1b4633 --- /dev/null +++ b/apps/web/app/(dash)/dynamicisland.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { AddIcon } from "@repo/ui/icons"; +import Image from "next/image"; + +import { AnimatePresence, useMotionValueEvent, useScroll } from "framer-motion"; +import { useActionState, useEffect, useRef, useState } from "react"; +import { motion } from "framer-motion"; +import { Label } from "@repo/ui/shadcn/label"; +import { Input } from "@repo/ui/shadcn/input"; +import { Textarea } from "@repo/ui/shadcn/textarea"; +import { createMemory, createSpace } from "../actions/doers"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/shadcn/select"; +import { Space } from "../actions/types"; +import { getSpaces } from "../actions/fetchers"; +import { toast } from "sonner"; +import { useFormStatus } from "react-dom"; + +export function DynamicIsland() { + const { scrollYProgress } = useScroll(); + const [visible, setVisible] = useState(true); + + useMotionValueEvent(scrollYProgress, "change", (current) => { + if (typeof current === "number") { + let direction = current! - scrollYProgress.getPrevious()!; + + if (direction < 0 || direction === 1) { + setVisible(true); + } else { + setVisible(false); + } + } + }); + + return ( + <div className=""> + <AnimatePresence mode="wait"> + <motion.div + initial={{ + opacity: 1, + y: -150, + }} + animate={{ + y: visible ? 0 : -150, + opacity: visible ? 1 : 0, + }} + transition={{ + duration: 0.2, + }} + className="flex flex-col items-center" + > + <DynamicIslandContent /> + </motion.div> + </AnimatePresence> + </div> + ); +} + +export default DynamicIsland; + +function DynamicIslandContent() { + const [show, setshow] = useState(true); + function cancelfn() { + setshow(true); + } + + const lastBtn = useRef<string>(); + + useEffect(() => { + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + setshow(true); + } + + if (e.key === "a" && lastBtn.current === "Alt") { + setshow(false); + } + lastBtn.current = e.key; + }); + }, []); + return ( + <> + {show ? ( + <button + onClick={() => setshow(!show)} + className="bg-secondary p-2 text-[#989EA4] rounded-full flex items-center justify-between gap-2 px-4 h-10 pr-5 z-[999] shadow-md" + > + <Image src={AddIcon} alt="add icon" /> + Add content + </button> + ) : ( + <ToolBar cancelfn={cancelfn} /> + )} + </> + ); +} + +const fakeitems = ["page", "spaces"]; + +function ToolBar({ cancelfn }: { cancelfn: () => void }) { + const [spaces, setSpaces] = useState<Space[]>([]); + + const [index, setIndex] = useState(0); + + useEffect(() => { + (async () => { + let spaces = await getSpaces(); + + if (!spaces.success || !spaces.data) { + toast.warning("Unable to get spaces", { + richColors: true, + }); + setSpaces([]); + return; + } + setSpaces(spaces.data); + })(); + }, []); + + return ( + <AnimatePresence mode="wait"> + <motion.div + initial={{ + opacity: 0, + y: 20, + }} + animate={{ + y: 0, + opacity: 1, + }} + exit={{ + opacity: 0, + y: 20, + }} + transition={{ + duration: 0.2, + }} + className="flex flex-col items-center" + > + <div className="bg-secondary py-[.35rem] px-[.6rem] rounded-2xl"> + <HoverEffect + items={fakeitems} + index={index} + indexFn={(i) => setIndex(i)} + /> + </div> + {index === 1 ? ( + <SpaceForm cancelfn={cancelfn} /> + ) : ( + <PageForm cancelfn={cancelfn} spaces={spaces} /> + )} + </motion.div> + </AnimatePresence> + ); +} + +export const HoverEffect = ({ + items, + index, + indexFn, +}: { + items: string[]; + index: number; + indexFn: (i: number) => void; +}) => { + return ( + <div className={"flex"}> + {items.map((item, idx) => ( + <button + key={idx} + className="relative block h-full w-full px-2 py-1" + onClick={() => indexFn(idx)} + > + <AnimatePresence> + {index === idx && ( + <motion.span + className="absolute inset-0 block h-full w-full rounded-xl bg-[#2B3237]" + layoutId="hoverBackground" + initial={{ opacity: 0 }} + animate={{ + opacity: 1, + transition: { duration: 0.15 }, + }} + exit={{ + opacity: 0, + transition: { duration: 0.15, delay: 0.2 }, + }} + /> + )} + </AnimatePresence> + <h3 className="text-[#858B92] z-50 relative">{item}</h3> + </button> + ))} + </div> + ); +}; + +function SpaceForm({ cancelfn }: { cancelfn: () => void }) { + return ( + <form + action={createSpace} + className="bg-secondary border border-muted-foreground px-4 py-3 rounded-2xl mt-2 flex flex-col gap-3" + > + <div> + <Label className="text-[#858B92]" htmlFor="name"> + Name + </Label> + <Input + className="bg-[#2B3237] focus-visible:ring-0 border-none focus-visible:ring-offset-0" + id="name" + name="name" + /> + </div> + <div className="flex justify-between"> + {/* <a className="text-blue-500" href=""> + pull from store + </a> */} + {/* <div + onClick={cancelfn} + className="bg-[#2B3237] px-2 py-1 rounded-xl cursor-pointer" + > + cancel + </div> */} + <button + type="submit" + className="bg-[#2B3237] px-2 py-1 rounded-xl cursor-pointer" + > + Submit + </button> + </div> + </form> + ); +} + +function PageForm({ + cancelfn, + spaces, +}: { + cancelfn: () => void; + spaces: Space[]; +}) { + const [loading, setLoading] = useState(false); + + const { pending } = useFormStatus(); + return ( + <form + action={async (e: FormData) => { + const content = e.get("content")?.toString(); + const space = e.get("space")?.toString(); + + toast.info("Creating memory..."); + + if (!content) { + toast.error("Content is required"); + return; + } + cancelfn(); + const cont = await createMemory({ + content: content, + spaces: space ? [space] : undefined, + }); + + if (cont.success) { + toast.success("Memory created"); + } else { + toast.error("Memory creation failed"); + } + }} + className="bg-secondary border border-muted-foreground px-4 py-3 rounded-2xl mt-2 flex flex-col gap-3 w-[100vw] md:w-[400px]" + > + <div> + <Label className="text-[#858B92]" htmlFor="space"> + Space + </Label> + <Select name="space"> + <SelectTrigger> + <SelectValue placeholder="Space" /> + </SelectTrigger> + <SelectContent className="bg-secondary text-white"> + {spaces.map((space) => ( + <SelectItem key={space.id} value={space.id.toString()}> + {space.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div> + <Label className="text-[#858B92]" htmlFor="name"> + Resource (URL or content) + </Label> + <Textarea + className="bg-[#2B3237] focus-visible:ring-0 border-none focus-visible:ring-offset-0" + id="input" + name="content" + placeholder="Start typing a note or paste a URL here. I'll remember it." + /> + </div> + <div className="flex justify-end"> + <button + type="submit" + className="bg-[#2B3237] px-2 py-1 rounded-xl cursor-pointer" + > + Submit + </button> + </div> + </form> + ); +} diff --git a/apps/web/app/(dash)/header.tsx b/apps/web/app/(dash)/header.tsx index 104c63bc..c8c71be2 100644 --- a/apps/web/app/(dash)/header.tsx +++ b/apps/web/app/(dash)/header.tsx @@ -3,13 +3,22 @@ import Image from "next/image"; import Link from "next/link"; import Logo from "../../public/logo.svg"; import { AddIcon, ChatIcon } from "@repo/ui/icons"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/shadcn/tabs"; -function Header() { +import DynamicIsland from "./dynamicisland"; +import { db } from "@/server/db"; +import { getChatHistory } from "../actions/fetchers"; + +async function Header() { + const chatThreads = await getChatHistory(); + + if (!chatThreads.success || !chatThreads.data) { + return <div>Error fetching chat threads</div>; + } + return ( - <div> - <div className="flex items-center justify-between relative z-10"> - <Link href="/"> + <div className="p-4 relative z-30 h-16 flex items-center"> + <div className="w-full flex items-center justify-between"> + <Link className="" href="/home"> <Image src={Logo} alt="SuperMemory logo" @@ -17,37 +26,37 @@ function Header() { /> </Link> - <Tabs - className="absolute flex flex-col justify-center items-center w-full -z-10 group top-0 transition-transform duration-1000 ease-out" - defaultValue="account" - > - <div className="bg-secondary all-center h-11 rounded-full p-2 min-w-14"> - <button className="p-2 group-hover:hidden transition duration-500 ease-in-out"> - <Image src={AddIcon} alt="Add icon" /> + <div className="fixed z-30 left-1/2 -translate-x-1/2 top-5"> + <DynamicIsland /> + </div> + + <div className="flex items-center gap-2"> + <button className="flex duration-200 items-center text-[#7D8994] hover:bg-[#1F2429] text-[13px] gap-2 px-3 py-2 rounded-xl"> + <Image src={ChatIcon} alt="Chat icon" className="w-5" /> + Start new chat + </button> + + <div className="relative group"> + <button className="flex duration-200 items-center text-[#7D8994] hover:bg-[#1F2429] text-[13px] gap-2 px-3 py-2 rounded-xl"> + History </button> - <div className="hidden group-hover:flex inset-0 transition-opacity duration-500 ease-in-out"> - <TabsList className="p-2"> - <TabsTrigger value="account">Account</TabsTrigger> - <TabsTrigger value="password">Password</TabsTrigger> - </TabsList> + <div className="absolute p-4 hidden group-hover:block right-0 w-full md:w-[400px] max-h-[70vh] overflow-auto"> + <div className="bg-[#1F2429] rounded-xl p-2 flex flex-col shadow-lg"> + {chatThreads.data.map((thread) => ( + <Link + prefetch={false} + href={`/chat/${thread.id}`} + key={thread.id} + className="p-2 rounded-md hover:bg-secondary" + > + {thread.firstMessage} + </Link> + ))} + </div> </div> </div> - - <div className="bg-secondary all-center rounded-full p-2 mt-4 min-w-14 hidden group-hover:block"> - <TabsContent value="account"> - Make changes to your account here. - </TabsContent> - <TabsContent value="password"> - Change your password here. - </TabsContent> - </div> - </Tabs> - - <button className="flex shrink-0 duration-200 items-center gap-2 px-2 py-1.5 rounded-xl hover:bg-secondary"> - <Image src={ChatIcon} alt="Chat icon" /> - Start new chat - </button> + </div> </div> </div> ); diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx index 7a6bb94f..a78301fb 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -1,27 +1,70 @@ -import React from "react"; -import Menu from "../menu"; -import Header from "../header"; +"use client"; + +import React, { useEffect, useState } from "react"; import QueryInput from "./queryinput"; -import { homeSearchParamsCache } from "@/app/helpers/lib/searchParams"; -import { getSpaces } from "../actions"; +import { homeSearchParamsCache } from "@/lib/searchParams"; +import { getSpaces } from "@/app/actions/fetchers"; +import { useRouter } from "next/navigation"; +import { createChatThread, linkTelegramToUser } from "@/app/actions/doers"; +import { toast } from "sonner"; +import { useSession } from "next-auth/react"; -async function Page({ +function Page({ searchParams, }: { searchParams: Record<string, string | string[] | undefined>; }) { // TODO: use this to show a welcome page/modal - const { firstTime } = homeSearchParamsCache.parse(searchParams); + // const { firstTime } = homeSearchParamsCache.parse(searchParams); + + const [telegramUser, setTelegramUser] = useState<string | undefined>( + searchParams.telegramUser as string, + ); + + const { push } = useRouter(); + + const [spaces, setSpaces] = useState<{ id: number; name: string }[]>([]); - const spaces = await getSpaces(); + useEffect(() => { + if (telegramUser) { + const linkTelegram = async () => { + const response = await linkTelegramToUser(telegramUser); + + if (response.success) { + toast.success("Your telegram has been linked successfully."); + } else { + toast.error("Failed to link telegram. Please try again."); + } + }; + + linkTelegram(); + } + + getSpaces().then((res) => { + if (res.success && res.data) { + setSpaces(res.data); + return; + } + // TODO: HANDLE ERROR + }); + }, []); return ( - <div className="max-w-3xl flex mx-auto w-full flex-col"> + <div className="max-w-3xl h-full justify-center flex mx-auto w-full flex-col"> {/* all content goes here */} {/* <div className="">hi {firstTime ? 'first time' : ''}</div> */} - <div className="w-full h-96"> - <QueryInput initialSpaces={spaces} /> + <div className="w-full pb-20"> + <QueryInput + handleSubmit={async (q, spaces) => { + const threadid = await createChatThread(q); + + push( + `/chat/${threadid.data}?spaces=${JSON.stringify(spaces)}&q=${q}`, + ); + }} + initialSpaces={spaces} + /> </div> </div> ); diff --git a/apps/web/app/(dash)/home/queryinput.tsx b/apps/web/app/(dash)/home/queryinput.tsx index d098fda8..99476e40 100644 --- a/apps/web/app/(dash)/home/queryinput.tsx +++ b/apps/web/app/(dash)/home/queryinput.tsx @@ -2,65 +2,80 @@ import { ArrowRightIcon } from "@repo/ui/icons"; import Image from "next/image"; -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import Divider from "@repo/ui/shadcn/divider"; import { MultipleSelector, Option } from "@repo/ui/shadcn/combobox"; import { useRouter } from "next/navigation"; +import { getSpaces } from "@/app/actions/fetchers"; function QueryInput({ initialQuery = "", initialSpaces = [], disabled = false, + className, + mini = false, + handleSubmit, }: { initialQuery?: string; - initialSpaces?: { user: string | null; id: number; name: string }[]; + initialSpaces?: { + id: number; + name: string; + }[]; disabled?: boolean; + className?: string; + mini?: boolean; + handleSubmit: (q: string, spaces: { id: number; name: string }[]) => void; }) { const [q, setQ] = useState(initialQuery); const [selectedSpaces, setSelectedSpaces] = useState<number[]>([]); - const { push } = useRouter(); - - const parseQ = () => { - // preparedSpaces is list of spaces selected by user, with id and name - const preparedSpaces = initialSpaces - .filter((x) => selectedSpaces.includes(x.id)) - .map((x) => { - return { - id: x.id, - name: x.name, - }; - }); - - const newQ = - "/chat?q=" + - encodeURI(q) + - (selectedSpaces ? "&spaces=" + JSON.stringify(preparedSpaces) : ""); - - return newQ; - }; + const options = useMemo( + () => + initialSpaces.map((x) => ({ + label: x.name, + value: x.id.toString(), + })), + [initialSpaces], + ); - const options = initialSpaces.map((x) => ({ - label: x.name, - value: x.id.toString(), - })); + const preparedSpaces = useMemo( + () => + initialSpaces + .filter((x) => selectedSpaces.includes(x.id)) + .map((x) => { + return { + id: x.id, + name: x.name, + }; + }), + [selectedSpaces, initialSpaces], + ); return ( - <div> - <div className="bg-secondary rounded-t-[24px] w-full mt-40"> + <div className={className}> + <div + className={`bg-secondary ${!mini ? "rounded-t-3xl" : "rounded-3xl"}`} + > {/* input and action button */} - <form action={async () => push(parseQ())} className="flex gap-4 p-3"> + <form + action={async () => { + handleSubmit(q, preparedSpaces); + setQ(""); + }} + className="flex gap-4 p-3" + > <textarea name="q" cols={30} - rows={4} - className="bg-transparent pt-2.5 text-base text-[#989EA4] focus:text-foreground duration-200 tracking-[3%] outline-none resize-none w-full p-4" + rows={mini ? 2 : 4} + className="bg-transparent pt-2.5 text-base placeholder:text-[#5D6165] text-[#9DA0A4] focus:text-gray-200 duration-200 tracking-[3%] outline-none resize-none w-full p-4" placeholder="Ask your second brain..." onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); - if (!e.shiftKey) push(parseQ()); + handleSubmit(q, preparedSpaces); + setQ(""); } }} onChange={(e) => setQ(e.target.value)} @@ -70,29 +85,39 @@ function QueryInput({ <button type="submit" + onClick={(e) => { + e.preventDefault(); + handleSubmit(q, preparedSpaces); + }} disabled={disabled} className="h-12 w-12 rounded-[14px] bg-[#21303D] all-center shrink-0 hover:brightness-125 duration-200 outline-none focus:outline focus:outline-primary active:scale-90" > <Image src={ArrowRightIcon} alt="Right arrow icon" /> </button> </form> - - <Divider /> </div> {/* selected sources */} - <div className="flex items-center gap-6 p-2 h-auto bg-secondary rounded-b-[24px]"> - <MultipleSelector - disabled={disabled} - defaultOptions={options} - onChange={(e) => setSelectedSpaces(e.map((x) => parseInt(x.value)))} - placeholder="Focus on specific spaces..." - emptyIndicator={ - <p className="text-center text-lg leading-10 text-gray-600 dark:text-gray-400"> - no results found. - </p> - } - /> - </div> + {!mini && ( + <> + <Divider /> + <div className="flex items-center gap-6 p-2 h-auto bg-secondary rounded-b-3xl"> + <MultipleSelector + key={options.length} + disabled={disabled} + defaultOptions={options} + onChange={(e) => + setSelectedSpaces(e.map((x) => parseInt(x.value))) + } + placeholder="Focus on specific spaces..." + emptyIndicator={ + <p className="text-center text-lg leading-10 text-gray-600 dark:text-gray-400"> + no results found. + </p> + } + /> + </div> + </> + )} </div> ); } diff --git a/apps/web/app/(dash)/layout.tsx b/apps/web/app/(dash)/layout.tsx index dffa27fa..3ae4e76d 100644 --- a/apps/web/app/(dash)/layout.tsx +++ b/apps/web/app/(dash)/layout.tsx @@ -1,22 +1,27 @@ import Header from "./header"; import Menu from "./menu"; -import { ensureAuth } from "./actions"; import { redirect } from "next/navigation"; +import { auth } from "../../server/auth"; +import { Toaster } from "@repo/ui/shadcn/sonner"; async function Layout({ children }: { children: React.ReactNode }) { - const info = await ensureAuth(); + const info = await auth(); if (!info) { return redirect("/signin"); } return ( - <main className="h-screen flex flex-col p-4 relative"> - <Header /> + <main className="h-screen flex flex-col"> + <div className="fixed top-0 left-0 w-full"> + <Header /> + </div> <Menu /> - {children} + <div className="w-full h-full px-2 md:px-0">{children}</div> + + <Toaster /> </main> ); } diff --git a/apps/web/app/(dash)/memories/page.tsx b/apps/web/app/(dash)/memories/page.tsx new file mode 100644 index 00000000..ff746d1d --- /dev/null +++ b/apps/web/app/(dash)/memories/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { getAllUserMemoriesAndSpaces } from "@/app/actions/fetchers"; +import { Space } from "@/app/actions/types"; +import { Content } from "@/server/db/schema"; +import { NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons"; +import Image from "next/image"; +import React, { useEffect, useState } from "react"; + +function Page() { + const [filter, setFilter] = useState("All"); + const setFilterfn = (i: string) => setFilter(i); + + const [search, setSearch] = useState(""); + + const [memoriesAndSpaces, setMemoriesAndSpaces] = useState<{ + memories: Content[]; + spaces: Space[]; + }>({ memories: [], spaces: [] }); + + useEffect(() => { + (async () => { + const { success, data } = await getAllUserMemoriesAndSpaces(); + if (!success ?? !data) return; + setMemoriesAndSpaces({ memories: data.memories, spaces: data.spaces }); + })(); + }, []); + + return ( + <div className="max-w-3xl min-w-3xl py-36 h-full flex mx-auto w-full flex-col gap-12"> + <h2 className="text-white w-full font-medium text-2xl text-left"> + My Memories + </h2> + + <div className="flex flex-col gap-4"> + <div className="w-full relative"> + <input + type="text" + className=" w-full py-3 rounded-md text-lg pl-8 bg-[#1F2428] outline-none" + placeholder="search here..." + /> + <Image + className="absolute top-1/2 -translate-y-1/2 left-2" + src={SearchIcon} + alt="Search icon" + /> + </div> + + <Filters filter={filter} setFilter={setFilterfn} /> + </div> + <div> + <div className="text-[#B3BCC5]">Spaces</div> + {memoriesAndSpaces.spaces.map((space) => ( + <TabComponent title={space.name} description={space.id.toString()} /> + ))} + </div> + + <div> + <div className="text-[#B3BCC5]">Pages</div> + {memoriesAndSpaces.memories.map((memory) => ( + <LinkComponent title={memory.title ?? "No title"} url={memory.url} /> + ))} + </div> + </div> + ); +} + +function TabComponent({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( + <div className="flex items-center my-6"> + <div> + <div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md"> + {title.slice(0, 2).toUpperCase()} + </div> + </div> + <div className="grow px-4"> + <div className="text-lg text-[#fff]">{title}</div> + <div>{description}</div> + </div> + <div> + <Image src={NextIcon} alt="Search icon" /> + </div> + </div> + ); +} + +function LinkComponent({ title, url }: { title: string; url: string }) { + return ( + <div className="flex items-center my-6"> + <div> + <div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md"> + <Image src={UrlIcon} alt="Url icon" /> + </div> + </div> + <div className="grow px-4"> + <div className="text-lg text-[#fff]">{title}</div> + <div>{url}</div> + </div> + </div> + ); +} + +const FilterMethods = ["All", "Spaces", "Pages", "Notes"]; +function Filters({ + setFilter, + filter, +}: { + setFilter: (i: string) => void; + filter: string; +}) { + return ( + <div className="flex gap-4"> + {FilterMethods.map((i) => { + return ( + <div + onClick={() => setFilter(i)} + className={`transition px-6 py-2 rounded-xl ${i === filter ? "bg-[#21303D] text-[#369DFD]" : "text-[#B3BCC5] bg-[#1F2428] hover:bg-[#1f262d] hover:text-[#76a3cc]"}`} + > + {i} + </div> + ); + })} + </div> + ); +} + +export default Page; diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx index 1177bca6..b7ea6c1c 100644 --- a/apps/web/app/(dash)/menu.tsx +++ b/apps/web/app/(dash)/menu.tsx @@ -1,48 +1,90 @@ +"use client"; + import React from "react"; import Image from "next/image"; -import { MemoriesIcon, ExploreIcon, HistoryIcon } from "@repo/ui/icons"; +import Link from "next/link"; +import { MemoriesIcon, ExploreIcon, CanvasIcon } from "@repo/ui/icons"; function Menu() { const menuItems = [ { icon: MemoriesIcon, text: "Memories", - url: "/", + url: "/memories", + disabled: false, }, { icon: ExploreIcon, text: "Explore", url: "/explore", + disabled: true, }, { - icon: HistoryIcon, - text: "History", - url: "/history", + icon: CanvasIcon, + text: "Canvas", + url: "/canvas", + disabled: true, }, ]; return ( - <div className="absolute h-full p-4 flex items-center top-0 left-0"> - <div className=""> - <div className="hover:rounded-2x group inline-flex w-14 text-foreground-menu text-[15px] font-medium flex-col items-start gap-6 overflow-hidden rounded-[28px] bg-secondary px-3 py-4 duration-200 hover:w-40"> + <> + {/* Desktop Menu */} + <div className="hidden lg:flex fixed h-screen pb-20 w-full p-4 items-center justify-start top-0 left-0 pointer-events-none"> + <div className="pointer-events-auto group flex w-14 text-foreground-menu text-[15px] font-medium flex-col items-start gap-6 overflow-hidden rounded-[28px] bg-secondary px-3 py-4 duration-200 hover:w-40"> {menuItems.map((item) => ( - <div + <Link + aria-disabled={item.disabled} + href={item.disabled ? "#" : item.url} key={item.url} - className="flex w-full cursor-pointer items-center gap-3 px-1 duration-200 hover:scale-105 hover:brightness-150 active:scale-90" + className={`flex w-full ${ + item.disabled + ? "cursor-not-allowed opacity-50" + : "text-[#777E87] brightness-75 hover:brightness-125 cursor-pointer" + } items-center gap-3 px-1 duration-200 hover:scale-105 active:scale-90 justify-start`} > <Image src={item.icon} alt={`${item.text} icon`} + width={24} + height={24} className="hover:brightness-125 duration-200" /> <p className="opacity-0 duration-200 group-hover:opacity-100"> {item.text} </p> - </div> + </Link> + ))} + </div> + </div> + + {/* Mobile Menu */} + <div className="lg:hidden fixed bottom-0 left-0 w-full p-4 bg-secondary"> + <div className="flex justify-around items-center"> + {menuItems.map((item) => ( + <Link + aria-disabled={item.disabled} + href={item.disabled ? "#" : item.url} + key={item.url} + className={`flex flex-col items-center ${ + item.disabled + ? "opacity-50 cursor-not-allowed" + : "cursor-pointer" + }`} + onClick={(e) => item.disabled && e.preventDefault()} + > + <Image + src={item.icon} + alt={`${item.text} icon`} + width={24} + height={24} + /> + <p className="text-xs text-foreground-menu mt-2">{item.text}</p> + </Link> ))} </div> </div> - </div> + </> ); } diff --git a/apps/web/app/(landing)/twitterLink.tsx b/apps/web/app/(landing)/twitterLink.tsx index 12d7d4d9..08a61003 100644 --- a/apps/web/app/(landing)/twitterLink.tsx +++ b/apps/web/app/(landing)/twitterLink.tsx @@ -58,7 +58,7 @@ export function HoverBorderGradient({ if (!directions[nextIndex]) { return directions[0]!; } - return directions[nextIndex]; + return directions[nextIndex]!; }; const movingMap: Record<Direction, string> = { diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts new file mode 100644 index 00000000..99a1b719 --- /dev/null +++ b/apps/web/app/actions/doers.ts @@ -0,0 +1,383 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { db } from "../../server/db"; +import { + chatHistory, + chatThreads, + contentToSpace, + space, + storedContent, + users, +} from "../../server/db/schema"; +import { ServerActionReturnType } from "./types"; +import { auth } from "../../server/auth"; +import { Tweet } from "react-tweet/api"; +import { getMetaData } from "@/lib/get-metadata"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { LIMITS } from "@/lib/constants"; +import { z } from "zod"; +import { ChatHistory } from "@repo/shared-types"; +import { decipher } from "@/server/encrypt"; +import { redirect } from "next/navigation"; + +export const createSpace = async ( + input: string | FormData, +): ServerActionReturnType<number> => { + const data = await auth(); + + if (!data || !data.user) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + if (typeof input === "object") { + input = (input as FormData).get("name") as string; + } + + try { + const resp = await db + .insert(space) + .values({ name: input, user: data.user.id }); + + revalidatePath("/home"); + return { success: true, data: 1 }; + } catch (e: unknown) { + const error = e as Error; + if ( + error.message.includes("D1_ERROR: UNIQUE constraint failed: space.name") + ) { + return { success: false, data: 0, error: "Space already exists" }; + } else { + return { + success: false, + data: 0, + error: "Failed to create space with error: " + error.message, + }; + } + } +}; + +const typeDecider = (content: string) => { + // if the content is a URL, then it's a page. if its a URL with https://x.com/user/status/123, then it's a tweet. else, it's a note. + // do strict checking with regex + if (content.match(/https?:\/\/[\w\.]+\/[\w]+\/[\w]+\/[\d]+/)) { + return "tweet"; + } else if (content.match(/https?:\/\/[\w\.]+/)) { + return "page"; + } else { + return "note"; + } +}; + +export const limit = async (userId: string, type = "page") => { + const count = await db + .select({ + count: sql<number>`count(*)`.mapWith(Number), + }) + .from(storedContent) + .where(and(eq(storedContent.userId, userId), eq(storedContent.type, type))); + + if (count[0]!.count > LIMITS[type as keyof typeof LIMITS]) { + return false; + } + + return true; +}; + +const getTweetData = async (tweetID: string) => { + const url = `https://cdn.syndication.twimg.com/tweet-result?id=${tweetID}&lang=en&features=tfw_timeline_list%3A%3Btfw_follower_count_sunset%3Atrue%3Btfw_tweet_edit_backend%3Aon%3Btfw_refsrc_session%3Aon%3Btfw_fosnr_soft_interventions_enabled%3Aon%3Btfw_show_birdwatch_pivots_enabled%3Aon%3Btfw_show_business_verified_badge%3Aon%3Btfw_duplicate_scribes_to_settings%3Aon%3Btfw_use_profile_image_shape_enabled%3Aon%3Btfw_show_blue_verified_badge%3Aon%3Btfw_legacy_timeline_sunset%3Atrue%3Btfw_show_gov_verified_badge%3Aon%3Btfw_show_business_affiliate_badge%3Aon%3Btfw_tweet_edit_frontend%3Aon&token=4c2mmul6mnh`; + + const resp = await fetch(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3", + Accept: "application/json", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br", + Connection: "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Cache-Control": "max-age=0", + TE: "Trailers", + }, + }); + console.log(resp.status); + const data = (await resp.json()) as Tweet; + + return data; +}; + +export const createMemory = async (input: { + content: string; + spaces?: string[]; +}): ServerActionReturnType<number> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + const type = typeDecider(input.content); + + let pageContent = input.content; + let metadata: Awaited<ReturnType<typeof getMetaData>>; + + if (!(await limit(data.user.id, type))) { + return { + success: false, + data: 0, + error: `You have exceeded the limit of ${LIMITS[type as keyof typeof LIMITS]} ${type}s.`, + }; + } + + if (type === "page") { + const response = await fetch("https://md.dhr.wtf/?url=" + input.content, { + headers: { + Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY, + }, + }); + pageContent = await response.text(); + metadata = await getMetaData(input.content); + } else if (type === "tweet") { + const tweet = await getTweetData(input.content.split("/").pop() as string); + pageContent = JSON.stringify(tweet); + metadata = { + baseUrl: input.content, + description: tweet.text, + image: tweet.user.profile_image_url_https, + title: `Tweet by ${tweet.user.name}`, + }; + } else if (type === "note") { + pageContent = input.content; + const noteId = new Date().getTime(); + metadata = { + baseUrl: `https://supermemory.ai/note/${noteId}`, + description: `Note created at ${new Date().toLocaleString()}`, + image: "https://supermemory.ai/logo.png", + title: `${pageContent.slice(0, 20)} ${pageContent.length > 20 ? "..." : ""}`, + }; + } else { + return { + success: false, + data: 0, + error: "Invalid type", + }; + } + + let storeToSpaces = input.spaces; + + if (!storeToSpaces) { + storeToSpaces = []; + } + + const vectorSaveResponse = await fetch( + `${process.env.BACKEND_BASE_URL}/api/add`, + { + method: "POST", + body: JSON.stringify({ + pageContent, + title: metadata.title, + description: metadata.description, + url: metadata.baseUrl, + spaces: storeToSpaces, + user: data.user.id, + type, + }), + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY, + }, + }, + ); + + if (!vectorSaveResponse.ok) { + const errorData = await vectorSaveResponse.text(); + console.error(errorData); + return { + success: false, + data: 0, + error: `Failed to save to vector store. Backend returned error: ${errorData}`, + }; + } + + // Insert into database + const insertResponse = await db + .insert(storedContent) + .values({ + content: pageContent, + title: metadata.title, + description: metadata.description, + url: input.content, + baseUrl: metadata.baseUrl, + image: metadata.image, + savedAt: new Date(), + userId: data.user.id, + type, + }) + .returning({ id: storedContent.id }); + + const contentId = insertResponse[0]?.id; + if (!contentId) { + return { + success: false, + data: 0, + error: "Something went wrong while saving the document to the database", + }; + } + + if (storeToSpaces.length > 0) { + // Adding the many-to-many relationship between content and spaces + const spaceData = await db + .select() + .from(space) + .where( + and( + inArray( + space.id, + storeToSpaces.map((s) => parseInt(s)), + ), + eq(space.user, data.user.id), + ), + ) + .all(); + + await Promise.all( + spaceData.map(async (space) => { + await db + .insert(contentToSpace) + .values({ contentId: contentId, spaceId: space.id }); + }), + ); + } + + try { + const response = await vectorSaveResponse.json(); + + const expectedResponse = z.object({ status: z.literal("ok") }); + + const parsedResponse = expectedResponse.safeParse(response); + + if (!parsedResponse.success) { + return { + success: false, + data: 0, + error: `Failed to save to vector store. Backend returned error: ${parsedResponse.error.message}`, + }; + } + + return { + success: true, + data: 1, + }; + } catch (e) { + return { + success: false, + data: 0, + error: `Failed to save to vector store. Backend returned error: ${e}`, + }; + } +}; + +export const createChatThread = async ( + firstMessage: string, +): ServerActionReturnType<string> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + const thread = await db + .insert(chatThreads) + .values({ + firstMessage, + userId: data.user.id, + }) + .returning({ id: chatThreads.id }) + .execute(); + + console.log(thread); + + if (!thread[0]) { + return { + success: false, + error: "Failed to create chat thread", + }; + } + + return { success: true, data: thread[0].id }; +}; + +export const createChatObject = async ( + threadId: string, + chatHistorySoFar: ChatHistory[], +): ServerActionReturnType<boolean> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + const lastChat = chatHistorySoFar[chatHistorySoFar.length - 1]; + if (!lastChat) { + return { + success: false, + data: false, + error: "No chat object found", + }; + } + console.log("sources: ", lastChat.answer.sources); + + const saved = await db.insert(chatHistory).values({ + question: lastChat.question, + answer: lastChat.answer.parts.map((part) => part.text).join(""), + answerSources: JSON.stringify(lastChat.answer.sources), + threadId, + }); + + if (!saved) { + return { + success: false, + data: false, + error: "Failed to save chat object", + }; + } + + return { + success: true, + data: true, + }; +}; + +export const linkTelegramToUser = async ( + telegramUser: string, +): ServerActionReturnType<boolean> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + const user = await db + .update(users) + .set({ telegramId: decipher(telegramUser) }) + .where(eq(users.id, data.user.id)) + .execute(); + + if (!user) { + return { + success: false, + data: false, + error: "Failed to link telegram to user", + }; + } + + return { + success: true, + data: true, + }; +}; diff --git a/apps/web/app/actions/fetchers.ts b/apps/web/app/actions/fetchers.ts new file mode 100644 index 00000000..664c20ac --- /dev/null +++ b/apps/web/app/actions/fetchers.ts @@ -0,0 +1,191 @@ +"use server"; + +import { and, asc, eq, inArray, not, sql } from "drizzle-orm"; +import { db } from "../../server/db"; +import { + chatHistory, + ChatThread, + chatThreads, + Content, + contentToSpace, + storedContent, + users, +} from "../../server/db/schema"; +import { ServerActionReturnType, Space } from "./types"; +import { auth } from "../../server/auth"; +import { ChatHistory, SourceZod } from "@repo/shared-types"; +import { ChatHistory as ChatHistoryType } from "../../server/db/schema"; +import { z } from "zod"; +import { redirect } from "next/navigation"; + +export const getSpaces = async (): ServerActionReturnType<Space[]> => { + const data = await auth(); + + if (!data || !data.user) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + const spaces = await db.query.space.findMany({ + where: eq(users, data.user.id), + }); + + const spacesWithoutUser = spaces.map((space) => { + return { ...space, user: undefined }; + }); + + return { success: true, data: spacesWithoutUser }; +}; + +export const getAllMemories = async ( + freeMemoriesOnly: boolean = false, +): ServerActionReturnType<Content[]> => { + const data = await auth(); + + if (!data || !data.user) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + if (!freeMemoriesOnly) { + // Returns all memories, no matter the space. + const memories = await db.query.storedContent.findMany({ + where: eq(users, data.user.id), + }); + + return { success: true, data: memories }; + } + + // This only returns memories that are not a part of any space. + // This is useful for home page where we want to show a list of spaces and memories. + const contentNotInAnySpace = await db + .select() + .from(storedContent) + .where( + not( + eq( + storedContent.id, + db + .select({ contentId: contentToSpace.contentId }) + .from(contentToSpace), + ), + ), + ) + .execute(); + + return { success: true, data: contentNotInAnySpace }; +}; + +export const getAllUserMemoriesAndSpaces = async (): ServerActionReturnType<{ + spaces: Space[]; + memories: Content[]; +}> => { + const data = await auth(); + + if (!data || !data.user) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + const spaces = await db.query.space.findMany({ + where: eq(users, data.user.id), + }); + + const memories = await db.query.storedContent.findMany({ + where: eq(users, data.user.id), + }); + + return { + success: true, + data: { spaces: spaces, memories: memories }, + }; +}; + +export const getFullChatThread = async ( + threadId: string, +): ServerActionReturnType<ChatHistory[]> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + const thread = await db.query.chatThreads.findFirst({ + where: and( + eq(chatThreads.id, threadId), + eq(chatThreads.userId, data.user.id), + ), + }); + + if (!thread) { + return { error: "Thread not found", success: false }; + } + + const allChatsInThisThread = await db.query.chatHistory + .findMany({ + where: and(eq(chatHistory.threadId, threadId)), + orderBy: asc(chatHistory.id), + }) + .execute(); + + const accumulatedChatHistory: ChatHistory[] = allChatsInThisThread.map( + (chat) => { + console.log("answer sources", chat.answerSources); + const sourceCheck = z + .array(SourceZod) + .safeParse(JSON.parse(chat.answerSources ?? "[]")); + + if (!sourceCheck.success || !sourceCheck.data) { + console.error("sourceCheck.error", sourceCheck.error); + throw new Error("Invalid source data"); + } + + const sources = sourceCheck.data; + + return { + question: chat.question, + answer: { + parts: [ + { + text: chat.answer ?? undefined, + }, + ], + sources: sources ?? [], + }, + }; + }, + ); + + return { + success: true, + data: accumulatedChatHistory, + }; +}; + +export const getChatHistory = async (): ServerActionReturnType< + ChatThread[] +> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + try { + const chatHistorys = await db.query.chatThreads.findMany({ + where: eq(chatThreads.userId, data.user.id), + }); + + return { + success: true, + data: chatHistorys, + }; + } catch (e) { + return { + success: false, + error: (e as Error).message, + }; + } +}; diff --git a/apps/web/app/actions/types.ts b/apps/web/app/actions/types.ts new file mode 100644 index 00000000..5c5afc5c --- /dev/null +++ b/apps/web/app/actions/types.ts @@ -0,0 +1,11 @@ +export type Space = { + id: number; + name: string; + numberOfMemories?: number; +}; + +export type ServerActionReturnType<T> = Promise<{ + error?: string; + success: boolean; + data?: T; +}>; diff --git a/apps/web/app/api/[...nextauth]/route.ts b/apps/web/app/api/[...nextauth]/route.ts index 50807ab1..e19cc16e 100644 --- a/apps/web/app/api/[...nextauth]/route.ts +++ b/apps/web/app/api/[...nextauth]/route.ts @@ -1,2 +1,2 @@ -export { GET, POST } from "../../helpers/server/auth"; +export { GET, POST } from "../../../server/auth"; export const runtime = "edge"; diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index 34099848..d1730baa 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -1,6 +1,12 @@ import { type NextRequest } from "next/server"; -import { ChatHistory } from "@repo/shared-types"; +import { + ChatHistory, + ChatHistoryZod, + convertChatHistoryList, + SourcesFromApi, +} from "@repo/shared-types"; import { ensureAuth } from "../ensureAuth"; +import { z } from "zod"; export const runtime = "edge"; @@ -15,59 +21,67 @@ export async function POST(req: NextRequest) { return new Response("Missing BACKEND_SECURITY_KEY", { status: 500 }); } - const query = new URL(req.url).searchParams.get("q"); - const spaces = new URL(req.url).searchParams.get("spaces"); + const url = new URL(req.url); - const sourcesOnly = - new URL(req.url).searchParams.get("sourcesOnly") ?? "false"; + const query = url.searchParams.get("q"); + const spaces = url.searchParams.get("spaces"); - const chatHistory = (await req.json()) as { + const sourcesOnly = url.searchParams.get("sourcesOnly") ?? "false"; + + const jsonRequest = (await req.json()) as { chatHistory: ChatHistory[]; + sources: SourcesFromApi[] | undefined; }; + const { chatHistory, sources } = jsonRequest; - console.log("CHathistory", chatHistory); - - if (!query) { + if (!query || query.trim.length < 0) { return new Response(JSON.stringify({ message: "Invalid query" }), { status: 400, }); } - try { - const resp = await fetch( - `https://cf-ai-backend.dhravya.workers.dev/chat?q=${query}&user=${session.user.email ?? session.user.name}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`, - { - headers: { - "X-Custom-Auth-Key": process.env.BACKEND_SECURITY_KEY!, - }, - method: "POST", - body: JSON.stringify({ - chatHistory: chatHistory.chatHistory ?? [], - }), + const validated = z.array(ChatHistoryZod).safeParse(chatHistory ?? []); + + if (!validated.success) { + return new Response( + JSON.stringify({ + message: "Invalid chat history", + error: validated.error, + }), + { status: 400 }, + ); + } + + const modelCompatible = await convertChatHistoryList(validated.data); + + const resp = await fetch( + `${process.env.BACKEND_BASE_URL}/api/chat?query=${query}&user=${session.user.id}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_SECURITY_KEY}`, + "Content-Type": "application/json", }, + method: "POST", + body: JSON.stringify({ + chatHistory: modelCompatible, + sources, + }), + }, + ); + + if (sourcesOnly == "true") { + const data = (await resp.json()) as SourcesFromApi; + return new Response(JSON.stringify(data), { status: 200 }); + } + + if (resp.status !== 200 || !resp.ok) { + const errorData = await resp.text(); + console.log(errorData); + return new Response( + JSON.stringify({ message: "Error in CF function", error: errorData }), + { status: resp.status }, ); + } - console.log("sourcesOnly", sourcesOnly); - - if (sourcesOnly == "true") { - const data = await resp.json(); - console.log("data", data); - return new Response(JSON.stringify(data), { status: 200 }); - } - - if (resp.status !== 200 || !resp.ok) { - const errorData = await resp.json(); - console.log(errorData); - return new Response( - JSON.stringify({ message: "Error in CF function", error: errorData }), - { status: resp.status }, - ); - } - - // Stream the response back to the client - const { readable, writable } = new TransformStream(); - resp && resp.body!.pipeTo(writable); - - return new Response(readable, { status: 200 }); - } catch {} + return new Response(resp.body, { status: 200 }); } diff --git a/apps/web/app/api/editorai/route.ts b/apps/web/app/api/editorai/route.ts new file mode 100644 index 00000000..5e1fbf0c --- /dev/null +++ b/apps/web/app/api/editorai/route.ts @@ -0,0 +1,30 @@ +import type { NextRequest } from "next/server"; +import { ensureAuth } from "../ensureAuth"; + +export const runtime = "edge"; + +// ERROR #2 - This the the next function that calls the backend, I sometimes think this is redundency, but whatever +// I have commented the auth code, It should not work in development, but it still does sometimes +export async function POST(request: NextRequest) { + // const d = await ensureAuth(request); + // if (!d) { + // return new Response("Unauthorized", { status: 401 }); + // } + const res : {context: string, request: string} = await request.json() + + try { + const resp = await fetch(`${process.env.BACKEND_BASE_URL}/api/editorai?context=${res.context}&request=${res.request}`); + // this just checks if there are erros I am keeping it commented for you to better understand the important pieces + // if (resp.status !== 200 || !resp.ok) { + // const errorData = await resp.text(); + // console.log(errorData); + // return new Response( + // JSON.stringify({ message: "Error in CF function", error: errorData }), + // { status: resp.status }, + // ); + // } + return new Response(resp.body, { status: 200 }); + } catch (error) { + return new Response(`Error, ${error}`) + } +}
\ No newline at end of file diff --git a/apps/web/app/api/ensureAuth.ts b/apps/web/app/api/ensureAuth.ts index a1401a07..d2fbac0b 100644 --- a/apps/web/app/api/ensureAuth.ts +++ b/apps/web/app/api/ensureAuth.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { db } from "../helpers/server/db"; -import { sessions, users } from "../helpers/server/db/schema"; +import { db } from "../../server/db"; +import { sessions, users } from "../../server/db/schema"; import { eq } from "drizzle-orm"; export async function ensureAuth(req: NextRequest) { diff --git a/apps/web/app/api/getCount/route.ts b/apps/web/app/api/getCount/route.ts index f760c145..7cd2a2d3 100644 --- a/apps/web/app/api/getCount/route.ts +++ b/apps/web/app/api/getCount/route.ts @@ -1,6 +1,6 @@ -import { db } from "@/app/helpers/server/db"; +import { db } from "@/server/db"; import { and, eq, ne, sql } from "drizzle-orm"; -import { sessions, storedContent, users } from "@/app/helpers/server/db/schema"; +import { sessions, storedContent, users } from "@/server/db/schema"; import { type NextRequest, NextResponse } from "next/server"; import { ensureAuth } from "../ensureAuth"; @@ -20,7 +20,7 @@ export async function GET(req: NextRequest) { .from(storedContent) .where( and( - eq(storedContent.user, session.user.id), + eq(storedContent.userId, session.user.id), eq(storedContent.type, "twitter-bookmark"), ), ); @@ -32,7 +32,7 @@ export async function GET(req: NextRequest) { .from(storedContent) .where( and( - eq(storedContent.user, session.user.id), + eq(storedContent.userId, session.user.id), ne(storedContent.type, "twitter-bookmark"), ), ); diff --git a/apps/web/app/api/me/route.ts b/apps/web/app/api/me/route.ts index 20b6aece..621dcbfe 100644 --- a/apps/web/app/api/me/route.ts +++ b/apps/web/app/api/me/route.ts @@ -1,6 +1,6 @@ -import { db } from "@/app/helpers/server/db"; +import { db } from "@/server/db"; import { eq } from "drizzle-orm"; -import { sessions, users } from "@/app/helpers/server/db/schema"; +import { sessions, users } from "@/server/db/schema"; import { type NextRequest, NextResponse } from "next/server"; export const runtime = "edge"; diff --git a/apps/web/app/api/spaces/route.ts b/apps/web/app/api/spaces/route.ts index c46b02fc..cbed547d 100644 --- a/apps/web/app/api/spaces/route.ts +++ b/apps/web/app/api/spaces/route.ts @@ -1,5 +1,5 @@ -import { db } from "@/app/helpers/server/db"; -import { sessions, space, users } from "@/app/helpers/server/db/schema"; +import { db } from "@/server/db"; +import { sessions, space, users } from "@/server/db/schema"; import { eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { ensureAuth } from "../ensureAuth"; diff --git a/apps/web/app/api/store/route.ts b/apps/web/app/api/store/route.ts deleted file mode 100644 index f96f90cf..00000000 --- a/apps/web/app/api/store/route.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { db } from "@/app/helpers/server/db"; -import { and, eq, sql, inArray } from "drizzle-orm"; -import { - contentToSpace, - sessions, - storedContent, - users, - space, -} from "@/app/helpers/server/db/schema"; -import { type NextRequest, NextResponse } from "next/server"; -import { getMetaData } from "@/app/helpers/lib/get-metadata"; -import { ensureAuth } from "../ensureAuth"; - -export const runtime = "edge"; - -export async function POST(req: NextRequest) { - const session = await ensureAuth(req); - - if (!session) { - return new Response("Unauthorized", { status: 401 }); - } - - const data = (await req.json()) as { - pageContent: string; - url: string; - spaces?: string[]; - }; - - const metadata = await getMetaData(data.url); - let storeToSpaces = data.spaces; - - if (!storeToSpaces) { - storeToSpaces = []; - } - - const count = await db - .select({ - count: sql<number>`count(*)`.mapWith(Number), - }) - .from(storedContent) - .where( - and( - eq(storedContent.user, session.user.id), - eq(storedContent.type, "page"), - ), - ); - - if (count[0]!.count > 100) { - return NextResponse.json( - { message: "Error", error: "Limit exceeded" }, - { status: 499 }, - ); - } - - const rep = await db - .insert(storedContent) - .values({ - content: data.pageContent, - title: metadata.title, - description: metadata.description, - url: data.url, - baseUrl: metadata.baseUrl, - image: metadata.image, - savedAt: new Date(), - user: session.user.id, - }) - .returning({ id: storedContent.id }); - - const id = rep[0]?.id; - - if (!id) { - return NextResponse.json( - { message: "Error", error: "Error in CF function" }, - { status: 500 }, - ); - } - - if (storeToSpaces.length > 0) { - const spaceData = await db - .select() - .from(space) - .where( - and( - inArray(space.name, storeToSpaces ?? []), - eq(space.user, session.user.id), - ), - ) - .all(); - - await Promise.all([ - spaceData.forEach(async (space) => { - await db - .insert(contentToSpace) - .values({ contentId: id, spaceId: space.id }); - }), - ]); - } - - const res = (await Promise.race([ - fetch("https://cf-ai-backend.dhravya.workers.dev/add", { - method: "POST", - headers: { - "X-Custom-Auth-Key": process.env.BACKEND_SECURITY_KEY, - }, - body: JSON.stringify({ ...data, user: session.user.email }), - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Request timed out")), 40000), - ), - ])) as Response; - - if (res.status !== 200) { - console.log(res.status, res.statusText); - return NextResponse.json( - { message: "Error", error: "Error in CF function" }, - { status: 500 }, - ); - } - - return NextResponse.json({ message: "OK", data: "Success" }, { status: 200 }); -} diff --git a/apps/web/app/api/telegram/readme.md b/apps/web/app/api/telegram/readme.md new file mode 100644 index 00000000..79bd6f1c --- /dev/null +++ b/apps/web/app/api/telegram/readme.md @@ -0,0 +1,55 @@ +## how telegram bot stuff works + +### Let's start with the important bit: authentication. + +We wanted to find a good and secure way to authenticate users, or "link their supermemory account" to their telegram account. This was kinda challenging - because the requirements were tight and privacy was a big concern. + +1. No personally identifiable information should be stored, except the user's telegram ID and supermemory email. +2. The link should be as simple as a click of a button +3. it should work two-ways: If the user signs in to the website first, or uses the telegram bot first. +4. The user should be able to unlink their account at any time. +5. Should be very, very easy to host the telegram bot. + +We started out by trying to mingle with next-auth credentials provider - but that was a dead end. It would _work_, but would be too hard for us to implement and maintain, and would be a very bad user experience (get the token, copy it, paste it, etc). + +So we decided to go with a simple, yet secure, way of doing it. + +### the solution + +Well, the solution is simple af, surprisingly. To meet all these requirements, + +First off, we used the `grammy` library to create a telegram bot that works using websockets. (so, it's hosted with the website, and doesn't need a separate server) + +Now, let's examine both the flows: + +1. User signs in to the website first +2. Saves a bunch of stuff +3. wants to link their telegram account + +and... + +1. User uses the telegram bot first +2. Saves a bunch of stuff +3. wants to see their stuff in the supermemory account. + +What we ended up doing is creating a simple, yet secure way - always require signin through supermemory.ai website. +And if the user comes from the telegram bot, we just redirect them to the website with a token in the URL. + +The token. + +The token is literally just their telegram ID, but encrypted. We use a simple encryption algorithm to encrypt the telegram ID, and then decrypt it on the website. + +Why encryption? Because we don't want any random person to link any telegram account with their user id. The encryption is also interesting, done using an algorithm called [hushh](https://github.com/dhravya/hushh) that I made a while ago. It's simple and secure and all that's really needed is a secret key. + +Once the user signs in, we take the decrypted token and link it to their account. And that's it. The user can now use the telegram bot to access their stuff. Because it's on the same codebase on the server side, it's very easy to make database calls and also calls to the cf-ai-backend to generate stuff. + +### Natural language generation + +I wanted to add this: the bot actually does both - adding content and talking to the user - at the same time. + +How tho? +We use function calling in the backend repo smartly to decide what the user's intent would be. So, i can literally send the message "yo, can you remember this? (with anything else, can even be a URL!)" and the bot will understand that it's a command to add content. + +orr, i can send "hey, can you tell me about the time i went to the beach?" and the bot will understand that it's a command to get content. + +it's pretty cool. function calling using a cheap model works very well. diff --git a/apps/web/app/api/telegram/route.ts b/apps/web/app/api/telegram/route.ts new file mode 100644 index 00000000..c6c673b2 --- /dev/null +++ b/apps/web/app/api/telegram/route.ts @@ -0,0 +1,113 @@ +import { db } from "@/server/db"; +import { storedContent, users } from "@/server/db/schema"; +import { cipher } from "@/server/encrypt"; +import { eq } from "drizzle-orm"; +import { Bot, webhookCallback } from "grammy"; +import { User } from "grammy/types"; + +export const runtime = "edge"; + +if (!process.env.TELEGRAM_BOT_TOKEN) { + throw new Error("TELEGRAM_BOT_TOKEN is not defined"); +} + +console.log("Telegram bot activated"); +const token = process.env.TELEGRAM_BOT_TOKEN; + +const bot = new Bot(token); + +bot.command("start", async (ctx) => { + const user: User = (await ctx.getAuthor()).user; + + const cipherd = cipher(user.id.toString()); + await ctx.reply( + `Welcome to Supermemory bot. I am here to help you remember things better. Click here to create and link your account: https://beta.supermemory.ai/signin?telegramUser=${cipherd}`, + ); +}); + +bot.on("message", async (ctx) => { + const user: User = (await ctx.getAuthor()).user; + + const cipherd = cipher(user.id.toString()); + + const dbUser = await db.query.users + .findFirst({ + where: eq(users.telegramId, user.id.toString()), + }) + .execute(); + + if (!dbUser) { + await ctx.reply( + `Welcome to Supermemory bot. I am here to help you remember things better. Click here to create and link your account: https://beta.supermemory.ai/signin?telegramUser=${cipherd}`, + ); + + return; + } + + const message = await ctx.reply("I'm thinking..."); + + const response = await fetch( + `${process.env.BACKEND_BASE_URL}/api/autoChatOrAdd?query=${ctx.message.text}&user=${dbUser.id}`, + { + method: "POST", + headers: { + Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + // TODO: we can use the conversations API to get the last 5 messages + // get chatHistory from this conversation. + // Basically the last 5 messages between the user and the assistant. + // In ths form of [{role: 'user' | 'assistant', content: string}] + // https://grammy.dev/plugins/conversations + chatHistory: [], + }), + }, + ); + + if (response.status !== 200) { + console.log("Failed to get response from backend"); + console.log(response.status); + console.log(await response.text()); + await ctx.reply( + "Sorry, I am not able to process your request at the moment.", + ); + return; + } + + const data = (await response.json()) as { + status: string; + response: string; + contentAdded: { + type: string; + content: string; + url: string; + }; + }; + + // TODO: we might want to enrich this data with more information + if (data.contentAdded) { + await db + .insert(storedContent) + .values({ + content: data.contentAdded.content, + title: `${data.contentAdded.content.slice(0, 30)}... (Added from chatbot)`, + description: "", + url: data.contentAdded.url, + baseUrl: data.contentAdded.url, + image: "", + savedAt: new Date(), + userId: dbUser.id, + type: data.contentAdded.type, + }) + .returning({ id: storedContent.id }); + } + + await ctx.api.editMessageText(ctx.chat.id, message.message_id, data.response); +}); + +export const POST = webhookCallback(bot, "std/http"); + +export const GET = async () => { + return new Response("OK", { status: 200 }); +}; diff --git a/apps/web/app/api/unfirlsite/route.ts b/apps/web/app/api/unfirlsite/route.ts new file mode 100644 index 00000000..36e47987 --- /dev/null +++ b/apps/web/app/api/unfirlsite/route.ts @@ -0,0 +1,156 @@ +import { load } from "cheerio"; +import { AwsClient } from "aws4fetch"; + +import type { NextRequest } from "next/server"; +import { ensureAuth } from "../ensureAuth"; + +export const runtime = "edge"; + +export async function POST(request: NextRequest) { + const r2 = new AwsClient({ + accessKeyId: process.env.R2_ACCESS_KEY_ID, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, + }); + + async function unfurl(url: string) { + const response = await fetch(url); + if (response.status >= 400) { + throw new Error(`Error fetching url: ${response.status}`); + } + const contentType = response.headers.get("content-type"); + if (!contentType?.includes("text/html")) { + throw new Error(`Content-type not right: ${contentType}`); + } + + const content = await response.text(); + const $ = load(content); + + const og: { [key: string]: string | undefined } = {}; + const twitter: { [key: string]: string | undefined } = {}; + + $("meta[property^=og:]").each( + // @ts-ignore, it just works so why care of type safety if someone has better way go ahead + (_, el) => (og[$(el).attr("property")!] = $(el).attr("content")), + ); + $("meta[name^=twitter:]").each( + // @ts-ignore + (_, el) => (twitter[$(el).attr("name")!] = $(el).attr("content")), + ); + + const title = + og["og:title"] ?? + twitter["twitter:title"] ?? + $("title").text() ?? + undefined; + const description = + og["og:description"] ?? + twitter["twitter:description"] ?? + $('meta[name="description"]').attr("content") ?? + undefined; + const image = + og["og:image:secure_url"] ?? + og["og:image"] ?? + twitter["twitter:image"] ?? + undefined; + + return { + title, + description, + image, + }; + } + + const d = await ensureAuth(request); + if (!d) { + return new Response("Unauthorized", { status: 401 }); + } + + if ( + !process.env.R2_ACCESS_KEY_ID || + !process.env.R2_ACCOUNT_ID || + !process.env.R2_SECRET_ACCESS_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 website = new URL(request.url).searchParams.get("website"); + + if (!website) { + return new Response("Missing website", { status: 400 }); + } + + const salt = () => Math.floor(Math.random() * 11); + const encodeWebsite = `${encodeURIComponent(website)}${salt()}`; + + try { + // this returns the og image, description and title of website + const response = await unfurl(website); + + if (!response.image) { + return new Response(JSON.stringify(response)); + } + + if (!process.env.DEV_IMAGES) { + return new Response("Missing DEV_IMAGES namespace.", { status: 500 }); + } + + const imageUrl = await process.env.DEV_IMAGES!.get(encodeWebsite); + if (imageUrl) { + return new Response( + JSON.stringify({ + image: imageUrl, + title: response.title, + description: response.description, + }), + ); + } + + const res = await fetch(`${response.image}`); + const image = await res.blob(); + + const url = new URL( + `https://${process.env.R2_BUCKET_NAME}.${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + ); + + url.pathname = encodeWebsite; + url.searchParams.set("X-Amz-Expires", "3600"); + + const signedPuturl = await r2.sign( + new Request(url, { + method: "PUT", + }), + { + aws: { signQuery: true }, + }, + ); + await fetch(signedPuturl.url, { + method: "PUT", + body: image, + }); + + await process.env.DEV_IMAGES.put( + encodeWebsite, + `${process.env.R2_PUBLIC_BUCKET_ADDRESS}/${encodeWebsite}`, + ); + + return new Response( + JSON.stringify({ + image: `${process.env.R2_PUBLIC_BUCKET_ADDRESS}/${encodeWebsite}`, + title: response.title, + description: response.description, + }), + ); + } catch (error) { + console.log(error); + return new Response( + JSON.stringify({ + status: 500, + error: error, + }), + ); + } +} diff --git a/apps/web/app/helpers/lib/get-metadata.ts b/apps/web/app/helpers/lib/get-metadata.ts deleted file mode 100644 index 4609e49b..00000000 --- a/apps/web/app/helpers/lib/get-metadata.ts +++ /dev/null @@ -1,40 +0,0 @@ -"use server"; -import * as cheerio from "cheerio"; - -// TODO: THIS SHOULD PROBABLY ALSO FETCH THE OG-IMAGE -export async function getMetaData(url: string) { - const response = await fetch(url); - const html = await response.text(); - - const $ = cheerio.load(html); - - // Extract the base URL - const baseUrl = new URL(url).origin; - - // Extract title - const title = $("title").text().trim(); - - const description = $("meta[name=description]").attr("content") ?? ""; - - const _favicon = - $("link[rel=icon]").attr("href") ?? "https://supermemory.dhr.wtf/web.svg"; - - let favicon = - _favicon.trim().length > 0 - ? _favicon.trim() - : "https://supermemory.dhr.wtf/web.svg"; - if (favicon.startsWith("/")) { - favicon = baseUrl + favicon; - } else if (favicon.startsWith("./")) { - favicon = baseUrl + favicon.slice(1); - } - - // Prepare the metadata object - const metadata = { - title, - description, - image: favicon, - baseUrl, - }; - return metadata; -} diff --git a/apps/web/app/helpers/lib/get-theme-button.tsx b/apps/web/app/helpers/lib/get-theme-button.tsx deleted file mode 100644 index 020cc976..00000000 --- a/apps/web/app/helpers/lib/get-theme-button.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// 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/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 deleted file mode 100644 index 42cae589..00000000 --- a/apps/web/app/helpers/lib/handle-errors.ts +++ /dev/null @@ -1,25 +0,0 @@ -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/lib/searchParams.ts b/apps/web/app/helpers/lib/searchParams.ts deleted file mode 100644 index 9899eaf7..00000000 --- a/apps/web/app/helpers/lib/searchParams.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - createSearchParamsCache, - parseAsInteger, - parseAsString, - parseAsBoolean, - parseAsArrayOf, - parseAsJson, -} from "nuqs/server"; -import { z } from "zod"; - -export const homeSearchParamsCache = createSearchParamsCache({ - firstTime: parseAsBoolean.withDefault(false), -}); - -export const chatSearchParamsCache = createSearchParamsCache({ - firstTime: parseAsBoolean.withDefault(false), - q: parseAsString.withDefault(""), - spaces: parseAsArrayOf( - parseAsJson(() => - z.object({ - id: z.string(), - name: z.string(), - }), - ), - ).withDefault([]), -}); diff --git a/apps/web/app/helpers/server/auth.ts b/apps/web/app/helpers/server/auth.ts deleted file mode 100644 index 73119d87..00000000 --- a/apps/web/app/helpers/server/auth.ts +++ /dev/null @@ -1,29 +0,0 @@ -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.BACKEND_SECURITY_KEY, - 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 deleted file mode 100644 index 4d671bea..00000000 --- a/apps/web/app/helpers/server/db/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index c4616eb2..00000000 --- a/apps/web/app/helpers/server/db/schema.ts +++ /dev/null @@ -1,143 +0,0 @@ -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 index 6821a884..4d182799 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -6,6 +6,7 @@ import { GeistSans } from "geist/font/sans"; import { GeistMono } from "geist/font/mono"; import { cn } from "@repo/ui/lib/utils"; import BackgroundPlus from "./(landing)/GridPatterns/PlusGrid"; +import { Toaster } from "@repo/ui/shadcn/toaster"; const inter = Inter({ subsets: ["latin"] }); export const runtime = "edge"; @@ -77,6 +78,9 @@ export default function RootLayout({ )} > {children} + <body className={`${inter.className} dark`}> + {children} + <Toaster /> </body> </html> ); diff --git a/apps/web/app/ref/page.tsx b/apps/web/app/ref/page.tsx index 9ace733a..b51a16bb 100644 --- a/apps/web/app/ref/page.tsx +++ b/apps/web/app/ref/page.tsx @@ -1,9 +1,9 @@ import { Button } from "@repo/ui/shadcn/button"; -import { auth, signIn, signOut } from "../helpers/server/auth"; -import { db } from "../helpers/server/db"; +import { auth, signIn, signOut } from "../../server/auth"; +import { db } from "../../server/db"; import { sql } from "drizzle-orm"; -import { users } from "../helpers/server/db/schema"; -import { getThemeToggler } from "../helpers/lib/get-theme-button"; +import { users } from "../../server/db/schema"; +import { getThemeToggler } from "../../lib/get-theme-button"; export const runtime = "edge"; |