diff options
Diffstat (limited to 'apps/web/app/(dash)')
| -rw-r--r-- | apps/web/app/(dash)/actions.ts | 48 | ||||
| -rw-r--r-- | apps/web/app/(dash)/chat/CodeBlock.tsx | 90 | ||||
| -rw-r--r-- | apps/web/app/(dash)/chat/[chatid]/page.tsx | 38 | ||||
| -rw-r--r-- | apps/web/app/(dash)/chat/actions.ts | 1 | ||||
| -rw-r--r-- | apps/web/app/(dash)/chat/chatWindow.tsx | 425 | ||||
| -rw-r--r-- | apps/web/app/(dash)/chat/markdownRenderHelpers.tsx | 25 | ||||
| -rw-r--r-- | apps/web/app/(dash)/chat/page.tsx | 16 | ||||
| -rw-r--r-- | apps/web/app/(dash)/dynamicisland.tsx | 315 | ||||
| -rw-r--r-- | apps/web/app/(dash)/header.tsx | 73 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/page.tsx | 65 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/queryinput.tsx | 119 | ||||
| -rw-r--r-- | apps/web/app/(dash)/layout.tsx | 15 | ||||
| -rw-r--r-- | apps/web/app/(dash)/memories/page.tsx | 133 | ||||
| -rw-r--r-- | apps/web/app/(dash)/menu.tsx | 66 |
14 files changed, 1238 insertions, 191 deletions
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> + </> ); } |