diff options
| -rw-r--r-- | apps/web/app/(dash)/chat/chatQueryInput.tsx | 181 | ||||
| -rw-r--r-- | apps/web/app/(dash)/chat/chatWindow.tsx | 6 | ||||
| -rw-r--r-- | apps/web/app/(dash)/dialogContentContainer.tsx | 234 | ||||
| -rw-r--r-- | apps/web/app/(dash)/dialogTriggerWrapper.tsx | 50 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/filterSpaces.tsx | 102 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/homeVariants.ts | 50 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/page.tsx | 27 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/queryinput.tsx | 166 | ||||
| -rw-r--r-- | apps/web/app/(dash)/menu.tsx | 485 | ||||
| -rwxr-xr-x | bun.lockb | bin | 1192716 -> 1192756 bytes | |||
| -rw-r--r-- | packages/ui/shadcn/command.tsx | 2 |
11 files changed, 763 insertions, 540 deletions
diff --git a/apps/web/app/(dash)/chat/chatQueryInput.tsx b/apps/web/app/(dash)/chat/chatQueryInput.tsx new file mode 100644 index 00000000..c7267298 --- /dev/null +++ b/apps/web/app/(dash)/chat/chatQueryInput.tsx @@ -0,0 +1,181 @@ +"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"; + +function QueryInput({ + initialQuery = "", + initialSpaces = [], + disabled = false, + className, + mini = false, + handleSubmit, + setInitialSpaces, +}: { + initialQuery?: string; + 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 [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], + ); + + return ( + <div className={`${className}`}> + <div + className={`bg-secondary border-2 border-b-0 border-border ${!mini ? "rounded-t-3xl" : "rounded-3xl"}`} + > + {/* input and action button */} + <form + action={async () => { + handleSubmit(q, preparedSpaces); + 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" + placeholder="Ask your second brain..." + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (q.trim().length === 0) { + return; + } + handleSubmit(q, preparedSpaces); + setQ(""); + } + }} + onChange={(e) => setQ(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> + </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> + ); +} + +export default QueryInput; diff --git a/apps/web/app/(dash)/chat/chatWindow.tsx b/apps/web/app/(dash)/chat/chatWindow.tsx index f0827a3d..0c1bace0 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 "./chatQueryInput"; import { cn } from "@repo/ui/lib/utils"; import { motion } from "framer-motion"; import { useRouter } from "next/navigation"; @@ -24,7 +24,6 @@ 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, @@ -199,9 +198,8 @@ function ChatWindow({ <div className="w-full h-96"> <QueryInput handleSubmit={() => {}} - initialQuery={q} initialSpaces={[]} - disabled + initialQuery={q} /> </div> </motion.div> diff --git a/apps/web/app/(dash)/dialogContentContainer.tsx b/apps/web/app/(dash)/dialogContentContainer.tsx new file mode 100644 index 00000000..aae71237 --- /dev/null +++ b/apps/web/app/(dash)/dialogContentContainer.tsx @@ -0,0 +1,234 @@ +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> + ); +} diff --git a/apps/web/app/(dash)/dialogTriggerWrapper.tsx b/apps/web/app/(dash)/dialogTriggerWrapper.tsx new file mode 100644 index 00000000..1e07e429 --- /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-secondary 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..2b951185 --- /dev/null +++ b/apps/web/app/(dash)/home/filterSpaces.tsx @@ -0,0 +1,102 @@ +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@repo/ui/shadcn/command"; +import { Check, Search } from "lucide-react"; +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 bg-[#369DFD1A] rounded-xl overflow-hidden pl-1"> + <div className="flex rounded-lg items-center"> + {selectedSpaces.map((v) => ( + <button + key={v.id} + onClick={() => handleSelect(v)} + className=" 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 text-white outline-0 ${ + selectedSpaces.length ? "w-5 hover:w-24 focus-within:w-20" : "w-44" + }`} + > + <div className="relative flex items-center"> + <Search className="h-4 w-4 shrink-0 opacity-50 ml-2" /> + <CommandInput + placeholder={selectedSpaces.length ? "" : "Search in Spaces"} + onKeyDown={handleKeyDown} + className="text-white peer placeholder:text-white" + onChangeCapture={(e) => setInput(e.currentTarget.value)} + value={input} + /> + </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.filter( + (space) => !selectedSpaces.some((v) => v.id === space.id), + ).length === 0 && ( + <> + <CommandItem className="text-[#eaeaea] data-[disabled]:opacity-90"> + No spaces found + </CommandItem> + </> + )} + {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> + ); +} 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..80dde7a4 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -7,8 +7,9 @@ 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 { ChromeIcon, GithubIcon, TwitterIcon } from "lucide-react"; +import Image from "next/image"; +import Logo from "../../../public/logo.svg"; const slap = { initial: { @@ -31,6 +32,8 @@ function Page({ // TODO: use this to show a welcome page/modal // const { firstTime } = homeSearchParamsCache.parse(searchParams); + const [queryPresent, setQueryPresent] = useState<boolean>(false); + const [telegramUser, setTelegramUser] = useState<string | undefined>( searchParams.telegramUser as string, ); @@ -71,8 +74,6 @@ 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 }, "*"); @@ -91,24 +92,15 @@ function Page({ }} 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> - ); - })} + <span>Ask your</span>{" "} + <span className="inline-flex items-center gap-2 bg-gradient-to-r to-blue-300 from-zinc-300 text-transparent bg-clip-text"> + supermemory + </span> </motion.h1> <div className="w-full pb-20 mt-12"> <QueryInput + setQueryPresent={setQueryPresent} handleSubmit={async (q, spaces) => { if (q.length === 0) { toast.error("Query is required"); @@ -127,7 +119,6 @@ function Page({ ); }} initialSpaces={spaces} - setInitialSpaces={setSpaces} /> </div> diff --git a/apps/web/app/(dash)/home/queryinput.tsx b/apps/web/app/(dash)/home/queryinput.tsx index c7267298..df7c71da 100644 --- a/apps/web/app/(dash)/home/queryinput.tsx +++ b/apps/web/app/(dash)/home/queryinput.tsx @@ -1,83 +1,50 @@ "use client"; +import React, { useState } from "react"; +import { FilterSpaces } from "./filterSpaces"; 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"; 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-secondary border-2 border-border overflow-hidden 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={3} + className="bg-transparent text-lg placeholder:text-[#9B9B9B] text-gray-200 tracking-[3%] outline-none resize-none w-full p-7" placeholder="Ask your second brain..." onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { @@ -85,95 +52,30 @@ 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> - </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 p-2 px-3 w-full items-center justify-between rounded-xl overflow-hidden"> + <FilterSpaces + selectedSpaces={selectedSpaces} + setSelectedSpaces={setSelectedSpaces} + initialSpaces={initialSpaces || []} /> - - <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> + <button type="submit" className="rounded-lg bg-[#369DFD1A] p-3"> + <Image src={ArrowRightIcon} alt="Enter" /> + </button> </div> - </> - )} + </form> + </div> </div> ); } diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx index 711b081c..5112eb4a 100644 --- a/apps/web/app/(dash)/menu.tsx +++ b/apps/web/app/(dash)/menu.tsx @@ -1,359 +1,176 @@ -"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-2 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-secondary 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-secondary 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..."); - - if (space) { - toast.error("A space with that name already exists."); - } + <HomeIcon width={24} height={24} /> + <p className="text-xs text-foreground-menu mt-2">Home</p> + </Link> - 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"> + <div className="cursor-pointer px-1 hover:scale-105 hover:text-[#bfc4c9] active:scale-90"> + <PlusIcon className="h-6 w-6" /> + </div> + <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; Binary files differdiff --git a/packages/ui/shadcn/command.tsx b/packages/ui/shadcn/command.tsx index ed929aa2..e91973be 100644 --- a/packages/ui/shadcn/command.tsx +++ b/packages/ui/shadcn/command.tsx @@ -3,7 +3,6 @@ import * as React from "react"; import { type DialogProps } from "@radix-ui/react-dialog"; import { Command as CommandPrimitive } from "cmdk"; -import { Search } from "lucide-react"; import { cn } from "@repo/ui/lib/utils"; import { Dialog, DialogContent } from "@repo/ui/shadcn/dialog"; @@ -39,7 +38,6 @@ const CommandInput = React.forwardRef< React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> >(({ className, ...props }, ref) => ( <div className="flex items-center px-3" cmdk-input-wrapper=""> - <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <CommandPrimitive.Input ref={ref} className={cn( |