diff options
Diffstat (limited to 'apps/web/app/(dash)')
| -rw-r--r-- | apps/web/app/(dash)/chat/chatWindow.tsx | 218 | ||||
| -rw-r--r-- | apps/web/app/(dash)/chat/queryinput.tsx | 83 | ||||
| -rw-r--r-- | apps/web/app/(dash)/dialogContentContainer.tsx | 224 | ||||
| -rw-r--r-- | apps/web/app/(dash)/dialogTriggerWrapper.tsx | 50 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/filterSpaces.tsx | 108 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/heading.tsx | 38 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/history.tsx | 50 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/homeVariants.ts | 50 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/page.tsx | 97 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/queryinput.tsx | 163 | ||||
| -rw-r--r-- | apps/web/app/(dash)/layout.tsx | 8 | ||||
| -rw-r--r-- | apps/web/app/(dash)/menu.tsx | 483 |
12 files changed, 877 insertions, 695 deletions
diff --git a/apps/web/app/(dash)/chat/chatWindow.tsx b/apps/web/app/(dash)/chat/chatWindow.tsx index f0827a3d..066e7d20 100644 --- a/apps/web/app/(dash)/chat/chatWindow.tsx +++ b/apps/web/app/(dash)/chat/chatWindow.tsx @@ -2,7 +2,7 @@ import { AnimatePresence } from "framer-motion"; import React, { useEffect, useRef, useState } from "react"; -import QueryInput from "../home/queryinput"; +import QueryInput from "./queryinput"; import { cn } from "@repo/ui/lib/utils"; import { motion } from "framer-motion"; import { useRouter } from "next/navigation"; @@ -224,9 +224,73 @@ function ChatWindow({ {chat.question} </h2> - <div className="flex flex-col mt-2"> + <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">Answer</div> + <div className="text-foreground-menu py-2">Summary</div> <div className="text-base"> {/* Loading state */} {(chat.answer.parts.length === 0 || @@ -283,108 +347,60 @@ function ChatWindow({ > <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> - - <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 flex-col no-scrollbar overflow-auto gap-4 relative max-w-3xl no-scrollbar" - defaultChecked + {/* 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 > - <div className="w-full no-scrollbar flex gap-4"> - {/* 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> - ))} - </div> - - {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> - )} - </AccordionContent> - </AccordionItem> - </Accordion> - </div> + <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> diff --git a/apps/web/app/(dash)/chat/queryinput.tsx b/apps/web/app/(dash)/chat/queryinput.tsx new file mode 100644 index 00000000..99f55986 --- /dev/null +++ b/apps/web/app/(dash)/chat/queryinput.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { ArrowRightIcon } from "@repo/ui/icons"; +import Image from "next/image"; +import React, { useState } from "react"; + +function QueryInput({ + initialSpaces, + initialQuery = "", + disabled = false, + className, + mini = false, + handleSubmit, +}: { + initialQuery?: 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< + { id: number; name: string }[] + >([]); + + return ( + <div className={`${className}`}> + <div + className={`bg-[#1F2428] overflow-hidden border-2 border-gray-700/50 shadow-md shadow-[#1d1d1dc7] rounded-3xl`} + > + {/* input and action button */} + <form + action={async () => { + if (q.trim().length === 0) { + return; + } + handleSubmit(q, selectedSpaces); + setQ(""); + }} + className="flex gap-4 p-3" + > + <textarea + autoFocus + name="q" + cols={30} + rows={mini ? 2 : 4} + className="bg-transparent pt-2.5 text-lg placeholder:text-[#9B9B9B] 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.shiftKey) { + e.preventDefault(); + if (q.trim().length === 0) { + return; + } + handleSubmit(q, selectedSpaces); + setQ(""); + } + }} + onChange={(e) => setQ(e.target.value)} + value={q} + disabled={disabled} + /> + + <button + type="submit" + disabled={disabled} + className="h-12 w-12 rounded-[14px] bg-border 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>{" "} + </div> + {/* selected sources */} + </div> + ); +} + +export default QueryInput; diff --git a/apps/web/app/(dash)/dialogContentContainer.tsx b/apps/web/app/(dash)/dialogContentContainer.tsx new file mode 100644 index 00000000..7ad68f17 --- /dev/null +++ b/apps/web/app/(dash)/dialogContentContainer.tsx @@ -0,0 +1,224 @@ +import { StoredSpace } from "@/server/db/schema"; +import { useEffect, useMemo, useState } from "react"; +import { createMemory, createSpace } from "../actions/doers"; +import ComboboxWithCreate from "@repo/ui/shadcn/combobox"; +import { toast } from "sonner"; +import { getSpaces } from "../actions/fetchers"; +import { MinusIcon, PlusCircleIcon } from "lucide-react"; +import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@repo/ui/shadcn/dialog"; +import { Label } from "@repo/ui/shadcn/label"; +import { Textarea } from "@repo/ui/shadcn/textarea"; +import { Button } from "@repo/ui/shadcn/button"; + +export function DialogContentContainer({DialogClose}: {DialogClose: ()=> void}) { + const [spaces, setSpaces] = useState<StoredSpace[]>([]); +const [content, setContent] = useState(""); +const [selectedSpaces, setSelectedSpaces] = useState<number[]>([]); + +const options = useMemo( + () => + spaces.map((x) => ({ + label: x.name, + value: x.id.toString(), + })), + [spaces], +); + +const autoDetectedType = useMemo(() => { + if (content.length === 0) { + return "none"; + } + + if ( + content.match(/https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/) + ) { + return "tweet"; + } else if (content.match(/https?:\/\/[\w\.]+/)) { + return "page"; + } else if (content.match(/https?:\/\/www\.[\w\.]+/)) { + return "page"; + } else { + return "note"; + } +}, [content]); + +const handleSubmit = async (content?: string, spaces?: number[]) => { + DialogClose(); + + toast.info("Creating memory...", { + icon: <PlusCircleIcon className="w-4 h-4 text-white animate-spin" />, + duration: 7500, + }); + + if (!content || content.length === 0) { + toast.error("Content is required"); + return; + } + + console.log(spaces); + + const cont = await createMemory({ + content: content, + spaces: spaces ?? undefined, + }); + + setContent(""); + setSelectedSpaces([]); + + if (cont.success) { + toast.success("Memory created", { + richColors: true, + }); + } else { + toast.error(`Memory creation failed: ${cont.error}`); + } +}; + +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 ( + <DialogContent className="sm:max-w-[475px] text-[#F2F3F5] rounded-2xl bg-background z-[39] backdrop-blur-md"> + <form + action={async (e: FormData) => { + const content = e.get("content")?.toString(); + + await handleSubmit(content, selectedSpaces); + }} + className="flex flex-col gap-4 " + > + <DialogHeader> + <DialogTitle>Add memory</DialogTitle> + <DialogDescription className="text-[#F2F3F5]"> + A "Memory" is a bookmark, something you want to remember. + </DialogDescription> + </DialogHeader> + + <div> + <Label htmlFor="name">Resource (URL or content)</Label> + <Textarea + className={`bg-[#2F353C] text-[#DBDEE1] max-h-[35vh] overflow-auto focus-visible:ring-0 border-none focus-visible:ring-offset-0 mt-2 ${/^https?:\/\/\S+$/i.test(content) && "text-[#1D9BF0] underline underline-offset-2"}`} + id="content" + name="content" + rows={8} + placeholder="Start typing a note or paste a URL here. I'll remember it." + value={content} + onChange={(e) => setContent(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(content, selectedSpaces); + } + }} + /> + </div> + + <div> + <Label className="space-y-1" htmlFor="space"> + <h3 className="font-semibold text-lg tracking-tight"> + Spaces (Optional) + </h3> + <p className="leading-normal text-[#F2F3F5] text-sm"> + A space is a collection of memories. It's a way to organise your + memories. + </p> + </Label> + + <ComboboxWithCreate + options={spaces.map((x) => ({ + label: x.name, + value: x.id.toString(), + }))} + onSelect={(v) => + setSelectedSpaces((prev) => { + if (v === "") { + return []; + } + return [...prev, parseInt(v)]; + }) + } + onSubmit={async (spaceName) => { + const space = options.find((x) => x.label === spaceName); + toast.info("Creating space..."); + + if (space) { + toast.error("A space with that name already exists."); + } + + const creationTask = await createSpace(spaceName); + if (creationTask.success && creationTask.data) { + toast.success("Space created " + creationTask.data); + setSpaces((prev) => [ + ...prev, + { + name: spaceName, + id: creationTask.data!, + createdAt: new Date(), + user: null, + numItems: 0, + }, + ]); + setSelectedSpaces((prev) => [...prev, creationTask.data!]); + } else { + toast.error( + "Space creation failed: " + creationTask.error ?? + "Unknown error", + ); + } + }} + placeholder="Select or create a new space." + className="bg-[#2F353C] h-min rounded-md mt-4 mb-4" + /> + + <div> + {selectedSpaces.length > 0 && ( + <div className="flex flex-row flex-wrap gap-0.5 h-min"> + {[...new Set(selectedSpaces)].map((x, idx) => ( + <button + key={x} + type="button" + onClick={() => + setSelectedSpaces((prev) => prev.filter((y) => y !== x)) + } + className={`relative group p-2 py-3 bg-[#3C464D] max-w-32 ${ + idx === selectedSpaces.length - 1 ? "rounded-br-xl" : "" + }`} + > + <p className="line-clamp-1"> + {spaces.find((y) => y.id === x)?.name} + </p> + <div className="absolute h-full right-0 top-0 p-1 opacity-0 group-hover:opacity-100 items-center"> + <MinusIcon className="w-6 h-6 rounded-full bg-secondary" /> + </div> + </button> + ))} + </div> + )} + </div> + </div> + + <DialogFooter> + <Button + disabled={autoDetectedType === "none"} + variant={"secondary"} + type="submit" + > + Save {autoDetectedType != "none" && autoDetectedType} + </Button> + </DialogFooter> + </form> + </DialogContent> +); +}
\ No newline at end of file diff --git a/apps/web/app/(dash)/dialogTriggerWrapper.tsx b/apps/web/app/(dash)/dialogTriggerWrapper.tsx new file mode 100644 index 00000000..7dcfc355 --- /dev/null +++ b/apps/web/app/(dash)/dialogTriggerWrapper.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Dialog, DialogTrigger } from "@repo/ui/shadcn/dialog"; +import { useState } from "react"; +import { DialogContentContainer } from "./dialogContentContainer"; +import { PlusIcon } from "@heroicons/react/24/solid"; + +export function DialogDesktopTrigger() { + return ( + <DialogTriggerWrapper> + <div className="border-gray-700/50 border-[1px] space-y-4 group relative bg-[#1F2428] shadow-md shadow-[#1d1d1dc7] rounded-xl flex justify-center"> + <button className="cursor-pointer p-2 hover:scale-105 hover:text-[#bfc4c9] active:scale-90"> + <PlusIcon className="h-6 w-6" /> + </button> + <div className="opacity-0 group-hover:opacity-100 scale-x-50 group-hover:scale-x-100 origin-left transition-all absolute whitespace-nowrap pointer-events-none border-gray-700/50 border-[1px] bg-[#1F2428] shadow-md shadow-[#1d1d1dc7] rounded-xl px-2 py-1 left-[120%] -top-2"> + Add Memories + </div> + </div> + </DialogTriggerWrapper> + ); +} + +export function DialogMobileTrigger() { + return ( + <DialogTriggerWrapper> + <div className={`flex flex-col items-center cursor-pointer text-white`}> + <PlusIcon className="h-6 w-6 hover:brightness-125 focus:brightness-125 duration-200 stroke-white" /> + <p className="text-xs text-foreground-menu mt-2">Add</p> + </div> + </DialogTriggerWrapper> + ); +} +export default function DialogTriggerWrapper({ + children, +}: { + children: React.ReactNode; +}) { + const [dialogOpen, setDialogOpen] = useState(false); + + return ( + <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> + <DialogTrigger>{children}</DialogTrigger> + <DialogContentContainer + DialogClose={() => { + setDialogOpen(false); + }} + /> + </Dialog> + ); +} diff --git a/apps/web/app/(dash)/home/filterSpaces.tsx b/apps/web/app/(dash)/home/filterSpaces.tsx new file mode 100644 index 00000000..8818791a --- /dev/null +++ b/apps/web/app/(dash)/home/filterSpaces.tsx @@ -0,0 +1,108 @@ +import { ChevronUpDownIcon } from "@heroicons/react/24/outline"; +import { ArrowRightIcon } from "@repo/ui/icons"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@repo/ui/shadcn/command"; +import { Check } from "lucide-react"; +import Image from "next/image"; +import React, { useState } from "react"; + +type space = { + id: number; + name: string; +}; + +export function FilterSpaces({ + initialSpaces, + selectedSpaces, + setSelectedSpaces, +}: { + initialSpaces: space[]; + selectedSpaces: space[]; + setSelectedSpaces: React.Dispatch<React.SetStateAction<space[]>>; +}) { + const [input, setInput] = useState<string>(""); + + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Backspace" && input === "") { + setSelectedSpaces((prevValue) => prevValue.slice(0, -1)); + } + }; + + const handleSelect = (selectedSpace: space) => { + setSelectedSpaces((current) => + current.some((space) => space.id === selectedSpace.id) + ? current.filter((space) => space.id !== selectedSpace.id) + : [...current, selectedSpace], + ); + }; + + return ( + <div className="flex p-2 px-3 w-full items-center justify-between rounded-xl overflow-hidden"> + <div className="flex bg-[#2C3338] rounded-xl overflow-hidden pl-1"> + <div className="flex rounded-lg items-center"> + {selectedSpaces.map((v) => ( + <button + key={v.id} + onClick={() => handleSelect(v)} + className="bg-[#3a4248] text-white max-w-32 truncate-wor truncate whitespace-nowrap py-1 rounded-md px-2 mx-1 aria-selected:outline" + > + {v.name} + </button> + ))} + </div> + <Command + className={`group transition-all border-0 bg-[#2c3338] text-white outline-0 ${ + selectedSpaces.length ? "w-5 hover:w-24 focus-within:w-20" : "w-44" + }`} + > + <div className="relative"> + <CommandInput + placeholder={selectedSpaces.length ? "" : "Search in Spaces"} + onKeyDown={handleKeyDown} + className="text-white peer placeholder:text-white pl-2" + onChangeCapture={(e) => setInput(e.currentTarget.value)} + value={input} + /> + <ChevronUpDownIcon + className={`h-6 w-6 text-[#858B92] pointer-events-none absolute top-1/2 -translate-y-1/2 right-2 ${ + selectedSpaces.length && "opacity-0" + }`} + /> + </div> + <CommandList className="z-10 translate-y-12 translate-x-5 opacity-0 absolute group-focus-within:opacity-100 transition-opacity p-2 rounded-lg max-w-64 bg-[#2C3338]"> + <CommandGroup className="pointer-events-none opacity-0 group-focus-within:opacity-100 scale-50 scale-y-50 group-focus-within:scale-y-100 group-focus-within:scale-100 group-focus-within:pointer-events-auto transition-all origin-top"> + {initialSpaces.map((space) => { + if (!selectedSpaces.some((v) => v.id === space.id)) { + return ( + <CommandItem + className="text-[#eaeaea] data-[disabled]:opacity-90" + value={space.name} + key={space.id} + onSelect={() => handleSelect(space)} + > + <Check + className={`mr-2 h-4 w-4 ${selectedSpaces.some((v) => v.id === space.id) ? "opacity-100" : "opacity-0"}`} + /> + {space.name} + </CommandItem> + ); + } + })} + </CommandGroup> + </CommandList> + </Command> + </div> + <button + type="submit" + className="rounded-lg bg-[#369DFD1A] p-3" + > + <Image src={ArrowRightIcon} alt="Enter" /> + </button> + </div> + ); +} diff --git a/apps/web/app/(dash)/home/heading.tsx b/apps/web/app/(dash)/home/heading.tsx new file mode 100644 index 00000000..dc5b8799 --- /dev/null +++ b/apps/web/app/(dash)/home/heading.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Inter } from "next/font/google"; + +const poppins = Inter({ subsets: ["latin"], weight: ["600"] }); + +const headings = [ + "Unlock your digital brain", + "Save everything.", + " Connect anything.", + "Turn your bookmarks into insights.", + "The smart way to use your digital treasure.", +]; + +export function Heading({ queryPresent }: { queryPresent: boolean }) { + const [showHeading, setShowHeading] = useState<number>(0); + useEffect(() => { + setShowHeading(Math.floor(Math.random() * headings.length)); + }, [queryPresent]); + return ( + <div className="h-[7rem] flex items-end justify-center overflow-hidden text-white"> + <AnimatePresence mode="popLayout"> + {!queryPresent && ( + <motion.h1 + initial={{ opacity: 0, y: "20%" }} + animate={{ opacity: 1, y: "0%" }} + exit={{ opacity: 0, y: "20%", whiteSpace: "nowrap" }} + className={`text-[2.45rem] font-semibold ${ + queryPresent ? "pointer-events-none" : "pointer-events-auto" + } transition-opacity text-center ${poppins.className}`} + > + {headings[showHeading]} + </motion.h1> + )} + </AnimatePresence> + </div> + ); +} diff --git a/apps/web/app/(dash)/home/history.tsx b/apps/web/app/(dash)/home/history.tsx new file mode 100644 index 00000000..9c6757e5 --- /dev/null +++ b/apps/web/app/(dash)/home/history.tsx @@ -0,0 +1,50 @@ +import { getRecentChats } from "@/app/actions/fetchers"; +import { ArrowLongRightIcon } from "@heroicons/react/24/outline"; +import { Skeleton } from "@repo/ui/shadcn/skeleton"; +import Link from "next/link"; +import { memo, useEffect, useState } from "react"; +import { motion } from "framer-motion"; + +const History = memo(() => { + const [chatThreads, setChatThreads] = useState(null); + + useEffect(() => { + (async () => { + const chatThreads = await getRecentChats(); + + // @ts-ignore + setChatThreads(chatThreads); + })(); + }, []); + + if (!chatThreads) { + return ( + <> + <Skeleton className="w-[80%] h-4 bg-[#3b444b] "></Skeleton> + <Skeleton className="w-[40%] h-4 bg-[#3b444b] "></Skeleton> + <Skeleton className="w-[60%] h-4 bg-[#3b444b] "></Skeleton> + </> + ); + } + + // @ts-ignore, time wastage + if (!chatThreads.success || !chatThreads.data) { + return <div>Error fetching chat threads</div>; + } + + return ( + <ul className="text-base list-none space-y-3 text-[#b9b9b9]"> + {/* @ts-ignore */} + {chatThreads.data.map((thread) => ( + <motion.li initial={{opacity: 0, filter: "blur(1px)"}} animate={{opacity: 1, filter: "blur(0px)"}} className="flex items-center gap-2 truncate"> + <ArrowLongRightIcon className="h-5" />{" "} + <Link prefetch={false} href={`/chat/${thread.id}`}> + {thread.firstMessage} + </Link> + </motion.li> + ))} + </ul> + ); +}); + +export default History;
\ No newline at end of file diff --git a/apps/web/app/(dash)/home/homeVariants.ts b/apps/web/app/(dash)/home/homeVariants.ts deleted file mode 100644 index 1b44bab9..00000000 --- a/apps/web/app/(dash)/home/homeVariants.ts +++ /dev/null @@ -1,50 +0,0 @@ -export const variants = [ - [ - { - type: "text", - content: "Unlock your", - }, - { - type: "highlighted", - content: " digital brain", - }, - ], - [ - { - type: "text", - content: "Save", - }, - { - type: "highlighted", - content: " everything.", - }, - { - type: "text", - content: " Connect", - }, - { - type: "highlighted", - content: " anything.", - }, - ], - [ - { - type: "text", - content: "Turn your bookmarks into", - }, - { - type: "highlighted", - content: " insights.", - }, - ], - [ - { - type: "text", - content: "The smart way to use your", - }, - { - type: "highlighted", - content: " digital treasure.", - }, - ], -]; diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx index 378acdf8..630c4306 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -6,21 +6,18 @@ import { getSessionAuthToken, getSpaces } from "@/app/actions/fetchers"; import { useRouter } from "next/navigation"; import { createChatThread, linkTelegramToUser } from "@/app/actions/doers"; import { toast } from "sonner"; -import { motion } from "framer-motion"; -import { variants } from "./homeVariants"; +import { Heading } from "./heading"; +import History from "./history"; import { ChromeIcon, GithubIcon, TwitterIcon } from "lucide-react"; -const slap = { - initial: { - opacity: 0, - scale: 1.1, - }, - whileInView: { opacity: 1, scale: 1 }, - transition: { - duration: 0.5, - ease: "easeInOut", - }, - viewport: { once: true }, +const linkTelegram = async (telegramUser: string) => { + 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."); + } }; function Page({ @@ -28,41 +25,24 @@ function Page({ }: { searchParams: Record<string, string | string[] | undefined>; }) { - // TODO: use this to show a welcome page/modal - // const { firstTime } = homeSearchParamsCache.parse(searchParams); - - const [telegramUser, setTelegramUser] = useState<string | undefined>( - searchParams.telegramUser as string, - ); - const [extensionInstalled, setExtensionInstalled] = useState< - string | undefined - >(searchParams.extension as string); - const { push } = useRouter(); const [spaces, setSpaces] = useState<{ id: number; name: string }[]>([]); - const [showVariant, setShowVariant] = useState<number>(0); + const [queryPresent, setQueryPresent] = useState<boolean>(false); useEffect(() => { + // telegram bot + const telegramUser = searchParams.extension as string; 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(); + linkTelegram(telegramUser); } - if (extensionInstalled) { + if (searchParams.extension as string) { toast.success("Extension installed successfully"); } + // fetch spaces getSpaces().then((res) => { if (res.success && res.data) { setSpaces(res.data); @@ -71,44 +51,18 @@ function Page({ // TODO: HANDLE ERROR }); - setShowVariant(Math.floor(Math.random() * variants.length)); - getSessionAuthToken().then((token) => { if (typeof window === "undefined") return; window.postMessage({ token: token.data }, "*"); }); - }, [telegramUser]); + }, []); return ( - <div className="max-w-3xl h-full justify-center flex mx-auto w-full flex-col px-2 md:px-0"> - {/* all content goes here */} - {/* <div className="">hi {firstTime ? 'first time' : ''}</div> */} - - <motion.h1 - {...{ - ...slap, - transition: { ...slap.transition, delay: 0.2 }, - }} - className="text-center mx-auto bg-[linear-gradient(180deg,_#FFF_0%,_rgba(255,_255,_255,_0.00)_202.08%)] bg-clip-text text-4xl tracking-tighter text-transparent md:text-5xl" - > - {variants[showVariant]!.map((v, i) => { - return ( - <span - key={i} - className={ - v.type === "highlighted" - ? "bg-gradient-to-r to-blue-200 from-zinc-300 text-transparent bg-clip-text" - : "" - } - > - {v.content} - </span> - ); - })} - </motion.h1> - - <div className="w-full pb-20 mt-12"> + <div className="max-w-3xl mt-[18vh] mx-auto w-full px-2 md:px-0"> + <Heading queryPresent={queryPresent} /> + <div className="w-full py-12"> <QueryInput + setQueryPresent={(t: boolean) => setQueryPresent(t)} handleSubmit={async (q, spaces) => { if (q.length === 0) { toast.error("Query is required"); @@ -127,9 +81,12 @@ function Page({ ); }} initialSpaces={spaces} - setInitialSpaces={setSpaces} /> </div> + <div className="space-y-5"> + <h3 className="text-lg">Recent Searches</h3> + <History /> + </div> <div className="w-full fixed bottom-0 left-0 p-4"> <div className="flex items-center justify-center gap-8"> @@ -143,7 +100,7 @@ function Page({ Install extension </a> <a - href="https://github.com/supermemoryai/supermemory/issues/new" + href="https://github.com/Dhravya/supermemory/issues/new" target="_blank" rel="noreferrer" className="flex items-center gap-2 text-muted-foreground" @@ -152,7 +109,7 @@ function Page({ Bug report </a> <a - href="https://x.com/supermemoryai" + href="https://x.com/supermemory.ai" target="_blank" rel="noreferrer" className="flex items-center gap-2 text-muted-foreground" diff --git a/apps/web/app/(dash)/home/queryinput.tsx b/apps/web/app/(dash)/home/queryinput.tsx index c7267298..f15a712b 100644 --- a/apps/web/app/(dash)/home/queryinput.tsx +++ b/apps/web/app/(dash)/home/queryinput.tsx @@ -1,83 +1,48 @@ "use client"; -import { ArrowRightIcon } from "@repo/ui/icons"; -import Image from "next/image"; -import React, { useEffect, useMemo, useState } from "react"; -import Divider from "@repo/ui/shadcn/divider"; -import { useRouter } from "next/navigation"; -import { getSpaces } from "@/app/actions/fetchers"; -import Combobox from "@repo/ui/shadcn/combobox"; -import { MinusIcon } from "lucide-react"; -import { toast } from "sonner"; -import { createSpace } from "@/app/actions/doers"; +import React, { useState } from "react"; +import { FilterSpaces } from "./filterSpaces"; function QueryInput({ - initialQuery = "", - initialSpaces = [], - disabled = false, - className, - mini = false, + setQueryPresent, + initialSpaces, handleSubmit, - setInitialSpaces, }: { - initialQuery?: string; + setQueryPresent: (t: boolean) => void; initialSpaces?: { id: number; name: string; }[]; - disabled?: boolean; - className?: string; mini?: boolean; handleSubmit: (q: string, spaces: { id: number; name: string }[]) => void; - setInitialSpaces?: React.Dispatch< - React.SetStateAction<{ id: number; name: string }[]> - >; }) { - const [q, setQ] = useState(initialQuery); + const [q, setQ] = useState(""); - const [selectedSpaces, setSelectedSpaces] = useState<number[]>([]); - - const options = useMemo( - () => - initialSpaces.map((x) => ({ - label: x.name, - value: x.id.toString(), - })), - [initialSpaces], - ); - - const preparedSpaces = useMemo( - () => - initialSpaces - .filter((x) => selectedSpaces.includes(x.id)) - .map((x) => { - return { - id: x.id, - name: x.name, - }; - }), - [selectedSpaces, initialSpaces], - ); + const [selectedSpaces, setSelectedSpaces] = useState< + { id: number; name: string }[] + >([]); return ( - <div className={`${className}`}> + <div className={`w-full`}> <div - className={`bg-secondary border-2 border-b-0 border-border ${!mini ? "rounded-t-3xl" : "rounded-3xl"}`} + className={`bg-[#1F2428] overflow-hidden border-2 border-gray-700/50 shadow-md shadow-[#1d1d1dc7] rounded-3xl`} > {/* input and action button */} <form action={async () => { - handleSubmit(q, preparedSpaces); + if (q.trim().length === 0) { + return; + } + handleSubmit(q, selectedSpaces); setQ(""); }} - className="flex gap-4 p-3" > <textarea autoFocus name="q" cols={30} - rows={mini ? 2 : 4} - className="bg-transparent pt-2.5 text-base placeholder:text-[#9B9B9B] focus:text-gray-200 duration-200 tracking-[3%] outline-none resize-none w-full p-4" + rows={4} + className="bg-transparent pt-2.5 text-lg placeholder:text-[#9B9B9B] text-gray-200 tracking-[3%] outline-none resize-none w-full p-4" placeholder="Ask your second brain..." onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { @@ -85,95 +50,25 @@ function QueryInput({ if (q.trim().length === 0) { return; } - handleSubmit(q, preparedSpaces); + handleSubmit(q, selectedSpaces); setQ(""); } }} - onChange={(e) => setQ(e.target.value)} + onChange={(e) => + setQ((prev) => { + setQueryPresent(!!e.target.value.length); + return e.target.value; + }) + } value={q} - disabled={disabled} /> - - <button - type="submit" - onClick={(e) => { - e.preventDefault(); - if (q.trim().length === 0) { - return; - } - handleSubmit(q, preparedSpaces); - }} - disabled={disabled} - className="h-12 w-12 rounded-[14px] bg-border 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> + <FilterSpaces + selectedSpaces={selectedSpaces} + setSelectedSpaces={setSelectedSpaces} + initialSpaces={initialSpaces || []} + /> </form> </div> - {/* selected sources */} - {!mini && ( - <> - <Divider /> - <div className="flex justify-between items-center gap-6 h-auto bg-secondary rounded-b-3xl border-2 border-border"> - <Combobox - options={options} - className="rounded-bl-3xl bg-[#3C464D] w-44" - onSelect={(v) => - setSelectedSpaces((prev) => { - if (v === "") { - return []; - } - return [...prev, parseInt(v)]; - }) - } - onSubmit={async (spaceName) => { - const space = options.find((x) => x.label === spaceName); - toast.info("Creating space..."); - - if (space) { - toast.error("A space with that name already exists."); - } - - const creationTask = await createSpace(spaceName); - if (creationTask.success && creationTask.data) { - toast.success("Space created " + creationTask.data); - setInitialSpaces?.((prev) => [ - ...prev, - { - name: spaceName, - id: creationTask.data!, - }, - ]); - setSelectedSpaces((prev) => [...prev, creationTask.data!]); - } else { - toast.error( - "Space creation failed: " + creationTask.error ?? - "Unknown error", - ); - } - }} - placeholder="Chat with a space..." - /> - - <div className="flex flex-row gap-0.5 h-full"> - {preparedSpaces.map((x, idx) => ( - <button - key={x.id} - onClick={() => - setSelectedSpaces((prev) => prev.filter((y) => y !== x.id)) - } - className={`relative group p-2 py-3 bg-[#3C464D] max-w-32 ${idx === preparedSpaces.length - 1 ? "rounded-br-xl" : ""}`} - > - <p className="line-clamp-1">{x.name}</p> - <div className="absolute h-full right-0 top-0 p-1 opacity-0 group-hover:opacity-100 items-center"> - <MinusIcon className="w-6 h-6 rounded-full bg-secondary" /> - </div> - </button> - ))} - </div> - </div> - </> - )} </div> ); } diff --git a/apps/web/app/(dash)/layout.tsx b/apps/web/app/(dash)/layout.tsx index b2b27a4f..24857fb1 100644 --- a/apps/web/app/(dash)/layout.tsx +++ b/apps/web/app/(dash)/layout.tsx @@ -3,7 +3,6 @@ import Menu from "./menu"; import { redirect } from "next/navigation"; import { auth } from "../../server/auth"; import { Toaster } from "@repo/ui/shadcn/sonner"; -import BackgroundPlus from "../(landing)/GridPatterns/PlusGrid"; async function Layout({ children }: { children: React.ReactNode }) { const info = await auth(); @@ -13,18 +12,15 @@ async function Layout({ children }: { children: React.ReactNode }) { } return ( - <main className="h-screen flex flex-col"> + <main className="h-screen bg flex flex-col"> <div className="fixed top-0 left-0 w-full z-40"> <Header /> </div> - <div className="relative flex justify-center z-40 pointer-events-none"> <div - className="absolute -z-10 left-0 top-[10%] h-32 w-[90%] overflow-x-hidden bg-[rgb(54,157,253)] bg-opacity-100 md:bg-opacity-70 blur-[337.4px]" + className="absolute z-[100] left-0 top-[10%] h-32 w-[90%] overflow-x-hidden bg-[rgb(54,157,253)] bg-opacity-100 pointer-events-none md:bg-opacity-70 blur-[337.4px]" style={{ transform: "rotate(-30deg)" }} /> - </div> - <BackgroundPlus className="absolute top-0 left-0 w-full h-full -z-50 opacity-70" /> <Menu /> diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx index 711b081c..3cb79309 100644 --- a/apps/web/app/(dash)/menu.tsx +++ b/apps/web/app/(dash)/menu.tsx @@ -1,359 +1,174 @@ -"use client"; - -import React, { useEffect, useMemo, useState } from "react"; +import React from "react"; import Image from "next/image"; import Link from "next/link"; -import { MemoriesIcon, ExploreIcon, CanvasIcon, AddIcon } from "@repo/ui/icons"; -import { Button } from "@repo/ui/shadcn/button"; -import { MinusIcon, PlusCircleIcon } from "lucide-react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@repo/ui/shadcn/dialog"; -import { Label } from "@repo/ui/shadcn/label"; -import { Textarea } from "@repo/ui/shadcn/textarea"; -import { toast } from "sonner"; -import { getSpaces } from "../actions/fetchers"; +import { MemoriesIcon, CanvasIcon, AddIcon } from "@repo/ui/icons"; +import { DialogTrigger } from "@repo/ui/shadcn/dialog"; + import { HomeIcon } from "@heroicons/react/24/solid"; -import { createMemory, createSpace } from "../actions/doers"; -import ComboboxWithCreate from "@repo/ui/shadcn/combobox"; -import { StoredSpace } from "@/server/db/schema"; -import useMeasure from "react-use-measure"; +import { + PencilSquareIcon, + PlusIcon, + PresentationChartLineIcon, + RectangleStackIcon, +} from "@heroicons/react/24/solid"; +import DialogTriggerWrapper, { + DialogDesktopTrigger, + DialogMobileTrigger, +} from "./dialogTriggerWrapper"; + +const menuItems = [ + { + icon: MemoriesIcon, + text: "Memories", + url: "/memories", + disabled: false, + }, + { + icon: CanvasIcon, + text: "Canvas", + url: "/canvas", + disabled: true, + }, +]; + +const items = [ + { + icon: <HomeIcon className="h-6 w-6" />, + name: "home", + url: "/home", + disabled: false, + }, + { + icon: <RectangleStackIcon className="h-6 w-6" />, + name: "memories", + url: "/memories", + disabled: false, + }, + { + icon: <PencilSquareIcon className="h-6 w-6" />, + name: "editor", + url: "/#", + disabled: true, + }, + { + icon: <PresentationChartLineIcon className="h-6 w-6" />, + name: "thinkpad", + url: "/#", + disabled: true, + }, +]; function Menu() { - const [spaces, setSpaces] = useState<StoredSpace[]>([]); - - useEffect(() => { - (async () => { - let spaces = await getSpaces(); - - if (!spaces.success || !spaces.data) { - toast.warning("Unable to get spaces", { - richColors: true, - }); - setSpaces([]); - return; - } - setSpaces(spaces.data); - })(); - }, []); - - const menuItems = [ - { - icon: MemoriesIcon, - text: "Memories", - url: "/memories", - disabled: false, - }, - { - icon: CanvasIcon, - text: "Canvas", - url: "/canvas", - disabled: true, - }, - ]; - - const [content, setContent] = useState(""); - const [selectedSpaces, setSelectedSpaces] = useState<number[]>([]); - - const autoDetectedType = useMemo(() => { - if (content.length === 0) { - return "none"; - } - - if ( - content.match(/https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/) - ) { - return "tweet"; - } else if (content.match(/https?:\/\/[\w\.]+/)) { - return "page"; - } else if (content.match(/https?:\/\/www\.[\w\.]+/)) { - return "page"; - } else { - return "note"; - } - }, [content]); - - const [dialogOpen, setDialogOpen] = useState(false); - - const options = useMemo( - () => - spaces.map((x) => ({ - label: x.name, - value: x.id.toString(), - })), - [spaces], - ); - - const handleSubmit = async (content?: string, spaces?: number[]) => { - setDialogOpen(false); - - toast.info("Creating memory...", { - icon: <PlusCircleIcon className="w-4 h-4 text-white animate-spin" />, - duration: 7500, - }); - - if (!content || content.length === 0) { - toast.error("Content is required"); - return; - } - - console.log(spaces); - - const cont = await createMemory({ - content: content, - spaces: spaces ?? undefined, - }); - - setContent(""); - setSelectedSpaces([]); - - if (cont.success) { - toast.success("Memory created", { - richColors: true, - }); - } else { - toast.error(`Memory creation failed: ${cont.error}`); - } - }; - return ( <> {/* Desktop Menu */} - <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> - <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 z-[39]"> - <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] border-2 border-border bg-secondary px-3 py-4 duration-200 hover:w-40 z-[99999]"> - <div className="border-b border-border pb-4 w-full"> - <DialogTrigger - className={`flex w-full text-white brightness-75 hover:brightness-125 focus:brightness-125 cursor-pointer items-center gap-3 px-1 duration-200 justify-start`} - > - <Image - src={AddIcon} - alt="Logo" - width={24} - height={24} - className="hover:brightness-125 focus:brightness-125 duration-200 text-white" - /> - <p className="opacity-0 duration-200 group-hover:opacity-100"> - Add - </p> - </DialogTrigger> - </div> - {menuItems.map((item) => ( - <Link - aria-disabled={item.disabled} - href={item.disabled ? "#" : item.url} - key={item.url} - className={`flex w-full ${ - item.disabled - ? "cursor-not-allowed opacity-30" - : "text-white 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> - </Link> + <div className="hidden lg:flex items-center pointer-events-none z-[39] fixed left-0 top-0 h-screen flex-col justify-center px-2"> + <div className="pointer-events-none z-10 absolute top-1/2 h-1/3 w-full -translate-y-1/2 bg-blue-500/20 blur-[300px] "></div> + <div className="pointer-events-auto flex flex-col gap-2"> + <DialogDesktopTrigger /> + <div className="inline-flex w-14 flex-col items-start gap-6 rounded-2xl border-[1px] border-gray-700/50 bg-[#1F2428] px-3 py-4 text-[#b9b9b9] shadow-md shadow-[#1d1d1dc7]"> + {items.map((v) => ( + <NavItem {...v} /> ))} </div> </div> - - <DialogContent className="sm:max-w-[475px] text-[#F2F3F5] rounded-2xl bg-background z-[39] backdrop-blur-md"> - <form - action={async (e: FormData) => { - const content = e.get("content")?.toString(); - - await handleSubmit(content, selectedSpaces); - }} - className="flex flex-col gap-4 " + </div> + + {/* Mobile Menu */} + <div className="lg:hidden fixed bottom-0 left-0 w-full p-4 bg-secondary z-50 border-t-2 border-border"> + <div className="flex justify-around items-center"> + <Link + href={"/"} + className={`flex flex-col items-center text-white ${"cursor-pointer"}`} > - <DialogHeader> - <DialogTitle>Add memory</DialogTitle> - <DialogDescription className="text-[#F2F3F5]"> - A "Memory" is a bookmark, something you want to remember. - </DialogDescription> - </DialogHeader> - - <div> - <Label htmlFor="name">Resource (URL or content)</Label> - <Textarea - className={`bg-[#2F353C] text-[#DBDEE1] max-h-[35vh] overflow-auto focus-visible:ring-0 border-none focus-visible:ring-offset-0 mt-2 ${/^https?:\/\/\S+$/i.test(content) && "text-[#1D9BF0] underline underline-offset-2"}`} - id="content" - name="content" - rows={8} - placeholder="Start typing a note or paste a URL here. I'll remember it." - value={content} - onChange={(e) => setContent(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(content, selectedSpaces); - } - }} - /> - </div> - - <div> - <Label className="space-y-1" htmlFor="space"> - <h3 className="font-semibold text-lg tracking-tight"> - Spaces (Optional) - </h3> - <p className="leading-normal text-[#F2F3F5] text-sm"> - A space is a collection of memories. It's a way to organise - your memories. - </p> - </Label> - - <ComboboxWithCreate - options={spaces.map((x) => ({ - label: x.name, - value: x.id.toString(), - }))} - onSelect={(v) => - setSelectedSpaces((prev) => { - if (v === "") { - return []; - } - return [...prev, parseInt(v)]; - }) - } - onSubmit={async (spaceName) => { - const space = options.find((x) => x.label === spaceName); - toast.info("Creating space..."); + <HomeIcon width={24} height={24} /> + <p className="text-xs text-foreground-menu mt-2">Home</p> + </Link> - if (space) { - toast.error("A space with that name already exists."); - } - - const creationTask = await createSpace(spaceName); - if (creationTask.success && creationTask.data) { - toast.success("Space created " + creationTask.data); - setSpaces((prev) => [ - ...prev, - { - name: spaceName, - id: creationTask.data!, - createdAt: new Date(), - user: null, - numItems: 0, - }, - ]); - setSelectedSpaces((prev) => [...prev, creationTask.data!]); - } else { - toast.error( - "Space creation failed: " + creationTask.error ?? - "Unknown error", - ); - } - }} - placeholder="Select or create a new space." - className="bg-[#2F353C] h-min rounded-md mt-4 mb-4" - /> - - <div> - {selectedSpaces.length > 0 && ( - <div className="flex flex-row flex-wrap gap-0.5 h-min"> - {[...new Set(selectedSpaces)].map((x, idx) => ( - <button - key={x} - type="button" - onClick={() => - setSelectedSpaces((prev) => - prev.filter((y) => y !== x), - ) - } - className={`relative group p-2 py-3 bg-[#3C464D] max-w-32 ${ - idx === selectedSpaces.length - 1 - ? "rounded-br-xl" - : "" - }`} - > - <p className="line-clamp-1"> - {spaces.find((y) => y.id === x)?.name} - </p> - <div className="absolute h-full right-0 top-0 p-1 opacity-0 group-hover:opacity-100 items-center"> - <MinusIcon className="w-6 h-6 rounded-full bg-secondary" /> - </div> - </button> - ))} - </div> - )} - </div> - </div> - - <DialogFooter> - <Button - disabled={autoDetectedType === "none"} - variant={"secondary"} - type="submit" - > - Save {autoDetectedType != "none" && autoDetectedType} - </Button> - </DialogFooter> - </form> - </DialogContent> - - {/* Mobile Menu */} - <div className="lg:hidden fixed bottom-0 left-0 w-full p-4 bg-secondary z-50 border-t-2 border-border"> - <div className="flex justify-around items-center"> + <DialogMobileTrigger /> + {menuItems.map((item) => ( <Link - href={"/"} - className={`flex flex-col items-center text-white ${"cursor-pointer"}`} - > - <HomeIcon width={24} height={24} /> - <p className="text-xs text-foreground-menu mt-2">Home</p> - </Link> - - <DialogTrigger - className={`flex flex-col items-center cursor-pointer text-white`} + aria-disabled={item.disabled} + href={item.disabled ? "#" : item.url} + key={item.url} + className={`flex flex-col items-center ${ + item.disabled + ? "opacity-50 pointer-events-none" + : "cursor-pointer" + }`} > <Image - src={AddIcon} - alt="Logo" + src={item.icon} + alt={`${item.text} icon`} width={24} height={24} - className="hover:brightness-125 focus:brightness-125 duration-200 stroke-white" /> - <p className="text-xs text-foreground-menu mt-2">Add</p> - </DialogTrigger> - {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> + <p className="text-xs text-foreground-menu mt-2">{item.text}</p> + </Link> + ))} </div> - </Dialog> + </div> </> ); } +export function Navbar() { + return ( + <div className="pointer-events-none fixed left-0 top-0 flex h-screen flex-col justify-center px-2"> + <div className="pointer-events-none absolute top-1/2 h-1/3 w-full -translate-y-1/2 bg-blue-500/20 blur-[300px] "></div> + <div className="pointer-events-auto"> + <div className="inline-flex w-14 flex-col items-start gap-6 rounded-2xl border-[1px] border-gray-700/50 bg-[#1f24289b] px-3 py-4 text-[#b9b9b9] shadow-md shadow-[#1d1d1dc7]"> + <Top /> + {items.map((v) => ( + <NavItem {...v} /> + ))} + </div> + </div> + </div> + ); +} + +function Top() { + return ( + <DialogTriggerWrapper> + <DialogTrigger> + <div className="space-y-4 group relative"> + <button className="cursor-pointer px-1 hover:scale-105 hover:text-[#bfc4c9] active:scale-90"> + <PlusIcon className="h-6 w-6" /> + </button> + <div className="h-[1px] w-full bg-[#323b41]"></div> + <div className="opacity-0 group-hover:opacity-100 scale-x-50 group-hover:scale-x-100 origin-left transition-all absolute whitespace-nowrap -top-1 -translate-y-1/2 left-[150%] pointer-events-none border-gray-700/50 border-[1px] bg-[#1F2428] shadow-md shadow-[#1d1d1dc7] rounded-xl px-2 py-1"> + Add Memories + </div> + </div> + </DialogTrigger> + </DialogTriggerWrapper> + ); +} + +function NavItem({ + disabled, + icon, + url, + name, +}: { + disabled: boolean; + icon: React.JSX.Element; + name: string; + url: string; +}) { + return ( + <div className="relative group"> + <Link aria-disabled={disabled} href={disabled ? "#" : url}> + <div className={`cursor-pointer px-1 hover:scale-105 hover:text-[#bfc4c9] active:scale-90 ${disabled && "opacity-50"}`}> + {icon} + </div> + </Link> + <div className="opacity-0 group-hover:opacity-100 scale-x-50 group-hover:scale-x-100 origin-left transition-all absolute whitespace-nowrap top-1/2 -translate-y-1/2 left-[150%] pointer-events-none border-gray-700/50 border-[1px] bg-[#1F2428] shadow-md shadow-[#1d1d1dc7] rounded-xl px-2 py-1"> + {name} + </div> + </div> + ); +} + export default Menu; |