aboutsummaryrefslogtreecommitdiff
path: root/apps/web/app/(dash)
diff options
context:
space:
mode:
authorKinfe Michael Tariku <[email protected]>2024-06-25 19:56:54 +0300
committerGitHub <[email protected]>2024-06-25 19:56:54 +0300
commitf46e42c2dfd1b223d4ad701a86d05fc0bb380e45 (patch)
treef17fdfadf3bec08eee7f02da33af952796657254 /apps/web/app/(dash)
parentfix: import using absolute path (diff)
parentdev and prod databases (diff)
downloadsupermemory-f46e42c2dfd1b223d4ad701a86d05fc0bb380e45.tar.xz
supermemory-f46e42c2dfd1b223d4ad701a86d05fc0bb380e45.zip
Merge branch 'main' into feat/landing_revamp
Diffstat (limited to 'apps/web/app/(dash)')
-rw-r--r--apps/web/app/(dash)/actions.ts48
-rw-r--r--apps/web/app/(dash)/chat/CodeBlock.tsx90
-rw-r--r--apps/web/app/(dash)/chat/[chatid]/page.tsx38
-rw-r--r--apps/web/app/(dash)/chat/actions.ts1
-rw-r--r--apps/web/app/(dash)/chat/chatWindow.tsx425
-rw-r--r--apps/web/app/(dash)/chat/markdownRenderHelpers.tsx25
-rw-r--r--apps/web/app/(dash)/chat/page.tsx16
-rw-r--r--apps/web/app/(dash)/dynamicisland.tsx315
-rw-r--r--apps/web/app/(dash)/header.tsx73
-rw-r--r--apps/web/app/(dash)/home/page.tsx65
-rw-r--r--apps/web/app/(dash)/home/queryinput.tsx119
-rw-r--r--apps/web/app/(dash)/layout.tsx15
-rw-r--r--apps/web/app/(dash)/memories/page.tsx133
-rw-r--r--apps/web/app/(dash)/menu.tsx66
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>
+ </>
);
}