diff options
| author | Dhravya <[email protected]> | 2024-06-29 00:11:28 -0500 |
|---|---|---|
| committer | Dhravya <[email protected]> | 2024-06-29 00:11:28 -0500 |
| commit | dfcc1ea7f7db341843d9d60c5c70f682f08dc4a8 (patch) | |
| tree | 2157ea4afea117dcfd19228e4f202d9cd1172e31 /apps/web | |
| parent | allow pointer events (diff) | |
| download | supermemory-dfcc1ea7f7db341843d9d60c5c70f682f08dc4a8.tar.xz supermemory-dfcc1ea7f7db341843d9d60c5c70f682f08dc4a8.zip | |
new filter
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/app/(dash)/home/page.tsx | 10 | ||||
| -rw-r--r-- | apps/web/app/(dash)/home/queryinput.tsx | 53 | ||||
| -rw-r--r-- | apps/web/app/(dash)/menu.tsx | 315 | ||||
| -rw-r--r-- | apps/web/app/actions/doers.ts | 12 |
4 files changed, 322 insertions, 68 deletions
diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx index a78301fb..17d52529 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -57,8 +57,18 @@ function Page({ <div className="w-full pb-20"> <QueryInput handleSubmit={async (q, spaces) => { + if (q.length === 0) { + toast.error("Query is required"); + return; + } + const threadid = await createChatThread(q); + if (!threadid.success || !threadid.data) { + toast.error("Failed to create chat thread"); + return; + } + push( `/chat/${threadid.data}?spaces=${JSON.stringify(spaces)}&q=${q}`, ); diff --git a/apps/web/app/(dash)/home/queryinput.tsx b/apps/web/app/(dash)/home/queryinput.tsx index 99476e40..868f93f9 100644 --- a/apps/web/app/(dash)/home/queryinput.tsx +++ b/apps/web/app/(dash)/home/queryinput.tsx @@ -4,9 +4,10 @@ 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 { MultipleSelector, Option } from "@repo/ui/shadcn/combobox"; import { useRouter } from "next/navigation"; import { getSpaces } from "@/app/actions/fetchers"; +import Combobox from "@repo/ui/shadcn/combobox"; +import { MinusIcon } from "lucide-react"; function QueryInput({ initialQuery = "", @@ -53,9 +54,9 @@ function QueryInput({ ); return ( - <div className={className}> + <div className={`${className}`}> <div - className={`bg-secondary ${!mini ? "rounded-t-3xl" : "rounded-3xl"}`} + className={`bg-secondary border-2 border-b-0 border-border ${!mini ? "rounded-t-3xl" : "rounded-3xl"}`} > {/* input and action button */} <form @@ -69,7 +70,7 @@ function QueryInput({ name="q" cols={30} 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" + 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") { @@ -90,7 +91,7 @@ function QueryInput({ 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" + 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> @@ -100,21 +101,37 @@ function QueryInput({ {!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 className="flex justify-between items-center gap-6 h-auto bg-secondary rounded-b-3xl border-2 border-border"> + <Combobox + options={options} + onSelect={(v) => + setSelectedSpaces((prev) => { + if (v === "") { + return []; + } + return [...prev, parseInt(v)]; + }) } + onSubmit={() => {}} + placeholder="Filter spaces..." /> + + <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> </> )} diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx index b7ea6c1c..29d25574 100644 --- a/apps/web/app/(dash)/menu.tsx +++ b/apps/web/app/(dash)/menu.tsx @@ -1,11 +1,60 @@ "use client"; -import React from "react"; +import React, { useEffect, useMemo, useState } from "react"; import Image from "next/image"; import Link from "next/link"; -import { MemoriesIcon, ExploreIcon, CanvasIcon } from "@repo/ui/icons"; +import { MemoriesIcon, ExploreIcon, CanvasIcon, AddIcon } from "@repo/ui/icons"; +import { Button } from "@repo/ui/shadcn/button"; +import { 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/shadcn/select"; +import { toast } from "sonner"; +import { getSpaces } from "../actions/fetchers"; +import { Space } from "../actions/types"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@repo/ui/shadcn/tooltip"; +import { InformationCircleIcon } from "@heroicons/react/24/outline"; +import { createMemory } from "../actions/doers"; +import { Input } from "@repo/ui/shadcn/input"; function Menu() { + const [spaces, setSpaces] = useState<Space[]>([]); + + 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, @@ -27,63 +76,231 @@ function Menu() { }, ]; + const [content, setContent] = useState(""); + const [selectedSpace, setSelectedSpace] = useState<string | null>(null); + + const autoDetectedType = useMemo(() => { + if (content.length === 0) { + return "none"; + } + + if (content.match(/https?:\/\/[\w\.]+\/[\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 handleSubmit = async (content?: string, space?: string) => { + setDialogOpen(false); + + toast.info("Creating memory..."); + + if (!content || content.length === 0) { + toast.error("Content is required"); + return; + } + + const cont = await createMemory({ + content: content, + spaces: space ? [space] : undefined, + }); + + setContent(""); + + if (cont.success) { + toast.success("Memory created"); + } else { + toast.error(`Memory creation failed: ${cont.error}`); + } + }; + return ( <> {/* 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) => ( - <Link - aria-disabled={item.disabled} - href={item.disabled ? "#" : item.url} - key={item.url} - 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> - </Link> - ))} + <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"> + <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"> + <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 group-focus-within: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 group-focus-within:opacity-100"> + {item.text} + </p> + </Link> + ))} + </div> </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()} + + <DialogContent className="sm:max-w-[425px]"> + <form + action={async (e: FormData) => { + const content = e.get("content")?.toString(); + const space = e.get("space")?.toString(); + + await handleSubmit(content, space); + }} + className="flex flex-col gap-4" + > + <DialogHeader> + <DialogTitle>Add memory</DialogTitle> + <DialogDescription> + A "Memory" is a bookmark, something you want to remember. + </DialogDescription> + </DialogHeader> + + <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 mt-2" + id="content" + name="content" + 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.preventDefault(); + handleSubmit(content); + } + }} + /> + </div> + + {autoDetectedType != "none" && ( + <div> + <Label + className="text-[#858B92] flex items-center gap-1 duration-200 transform transition-transform" + htmlFor="space" + > + Space + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <InformationCircleIcon className="w-4 h-4" /> + </TooltipTrigger> + <TooltipContent> + <p> + A space is a collection of memories. You can create a + space and then chat/write/ideate with it. + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </Label> + <Select + onValueChange={(value) => setSelectedSpace(value)} + value={selectedSpace ?? "none"} + defaultValue="none" + name="space" + > + <SelectTrigger className="mt-2"> + <SelectValue placeholder="None" /> + </SelectTrigger> + <SelectContent className="bg-secondary text-white"> + <SelectItem defaultChecked value="none"> + None + </SelectItem> + {spaces.map((space) => ( + <SelectItem key={space.id} value={space.id.toString()}> + {space.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </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"> + <div className="flex justify-around items-center"> + <DialogTrigger + className={`flex flex-col items-center cursor-pointer text-white`} > <Image - src={item.icon} - alt={`${item.text} icon`} + src={AddIcon} + alt="Logo" width={24} height={24} + className="hover:brightness-125 focus:brightness-125 duration-200 stroke-white" /> - <p className="text-xs text-foreground-menu mt-2">{item.text}</p> - </Link> - ))} + <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> </div> - </div> + </Dialog> </> ); } diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts index 99a1b719..a1de7b54 100644 --- a/apps/web/app/actions/doers.ts +++ b/apps/web/app/actions/doers.ts @@ -65,6 +65,8 @@ const typeDecider = (content: string) => { return "tweet"; } else if (content.match(/https?:\/\/[\w\.]+/)) { return "page"; + } else if (content.match(/https?:\/\/www\.[\w\.]+/)) { + return "page"; } else { return "note"; } @@ -138,7 +140,15 @@ export const createMemory = async (input: { }, }); pageContent = await response.text(); - metadata = await getMetaData(input.content); + + try { + metadata = await getMetaData(input.content); + } catch (e) { + return { + success: false, + error: "Failed to fetch metadata for the page. Please try again later.", + }; + } } else if (type === "tweet") { const tweet = await getTweetData(input.content.split("/").pop() as string); pageContent = JSON.stringify(tweet); |