diff options
| author | CodeTorso <[email protected]> | 2024-06-20 08:38:21 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2024-06-20 08:38:21 -0600 |
| commit | af90b960af7a8e6debc059f8ca67af878f0f409a (patch) | |
| tree | 8097644103d6f49b5b247ddada12e78132167f93 /apps/web | |
| parent | add: animated query input (diff) | |
| parent | added multi-turn conversations (diff) | |
| download | supermemory-af90b960af7a8e6debc059f8ca67af878f0f409a.tar.xz supermemory-af90b960af7a8e6debc059f8ca67af878f0f409a.zip | |
Merge branch 'codetorso' into kartik
Diffstat (limited to 'apps/web')
41 files changed, 1196 insertions, 263 deletions
diff --git a/apps/web/app/(auth)/auth-buttons.tsx b/apps/web/app/(auth)/auth-buttons.tsx index 0e99213e..5b0ad06e 100644 --- a/apps/web/app/(auth)/auth-buttons.tsx +++ b/apps/web/app/(auth)/auth-buttons.tsx @@ -2,7 +2,7 @@ import { Button } from "@repo/ui/shadcn/button"; import React from "react"; -import { signIn } from "../helpers/server/auth"; +import { signIn } from "../../server/auth"; function SignIn() { return ( diff --git a/apps/web/app/(auth)/signin/page.tsx b/apps/web/app/(auth)/signin/page.tsx index ba84a94a..d7bad8da 100644 --- a/apps/web/app/(auth)/signin/page.tsx +++ b/apps/web/app/(auth)/signin/page.tsx @@ -1,7 +1,7 @@ import Image from "next/image"; import Link from "next/link"; import Logo from "@/public/logo.svg"; -import { signIn } from "@/app/helpers/server/auth"; +import { signIn } from "@/server/auth"; import { Google } from "@repo/ui/components/icons"; export const runtime = "edge"; diff --git a/apps/web/app/(canvas)/canvas.tsx b/apps/web/app/(canvas)/canvas.tsx index 9ec57d6d..498ab1eb 100644 --- a/apps/web/app/(canvas)/canvas.tsx +++ b/apps/web/app/(canvas)/canvas.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Editor, Tldraw, setUserPreferences, TLStoreWithStatus } from "tldraw"; import { createAssetFromUrl } from "./lib/createAssetUrl"; import "tldraw/tldraw.css"; @@ -7,10 +7,53 @@ import { twitterCardUtil } from "./twitterCard"; import createEmbedsFromUrl from "./lib/createEmbeds"; import { loadRemoteSnapshot } from "./lib/loadSnap"; import { SaveStatus } from "./savesnap"; -import { getAssetUrls } from '@tldraw/assets/selfHosted' -import { memo } from 'react'; +import { getAssetUrls } from "@tldraw/assets/selfHosted"; +import { memo } from "react"; +import DragContext from "./lib/context"; +import DropZone from "./dropComponent"; -export const Canvas = memo(()=>{ +export const Canvas = memo(() => { + const [isDraggingOver, setIsDraggingOver] = useState<boolean>(false); + const Dragref = useRef<HTMLDivElement | null>(null) + + const handleDragOver = (event: any) => { + event.preventDefault(); + setIsDraggingOver(true); + console.log("entere") + }; + + const handleDragLeave = () => { + setIsDraggingOver(false); + console.log("leaver") + }; + + useEffect(() => { + const divElement = Dragref.current; + if (divElement) { + divElement.addEventListener('dragover', handleDragOver); + divElement.addEventListener('dragleave', handleDragLeave); + } + return () => { + if (divElement) { + divElement.removeEventListener('dragover', handleDragOver); + divElement.removeEventListener('dragleave', handleDragLeave); + } + }; + }, []); + + return ( + <DragContext.Provider value={{ isDraggingOver, setIsDraggingOver }}> + <div + ref={Dragref} + className="w-full h-full" + > + <TldrawComponent /> + </div> + </DragContext.Provider> + ); +}); + +const TldrawComponent =memo(() => { const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({ status: "loading", }); @@ -38,18 +81,22 @@ export const Canvas = memo(()=>{ setUserPreferences({ id: "supermemory", isDarkMode: true }); - const assetUrls = getAssetUrls() + const assetUrls = getAssetUrls(); return ( - <Tldraw - assetUrls={assetUrls} - components={components} - store={storeWithStatus} - shapeUtils={[twitterCardUtil]} - onMount={handleMount} - > - <div className="absolute left-1/2 top-0 z-[1000000] flex -translate-x-1/2 gap-2 bg-[#2C3439] text-[#B3BCC5]"> - <SaveStatus /> - </div> - </Tldraw> + <div className="w-full h-full"> + <Tldraw + className="relative" + assetUrls={assetUrls} + components={components} + store={storeWithStatus} + shapeUtils={[twitterCardUtil]} + onMount={handleMount} + > + <div className="absolute left-1/2 top-0 z-[1000000] flex -translate-x-1/2 gap-2 bg-[#2C3439] text-[#B3BCC5]"> + <SaveStatus /> + </div> + <DropZone /> + </Tldraw> + </div> ); }) diff --git a/apps/web/app/(canvas)/canvas/page.tsx b/apps/web/app/(canvas)/canvas/page.tsx index 7abfa583..366a4481 100644 --- a/apps/web/app/(canvas)/canvas/page.tsx +++ b/apps/web/app/(canvas)/canvas/page.tsx @@ -18,7 +18,7 @@ function page() { const [fullScreen, setFullScreen] = useState(false); return ( - <div className={`h-screen w-full ${ !fullScreen && "px-4 py-6"} transition-all`}> + <div className={`h-screen w-full ${ !fullScreen ? "px-4 py-6": "bg-[#1F2428]"} transition-all`}> <div> <PanelGroup className={` ${fullScreen ? "w-[calc(100vw-2rem)]" : "w-screen"} transition-all`} direction="horizontal"> <Panel onExpand={()=> {setTimeout(()=> setFullScreen(false), 50)}} onCollapse={()=> {setTimeout(()=> setFullScreen(true), 50)}} defaultSize={30} collapsible={true} minSize={22}> diff --git a/apps/web/app/(canvas)/dropComponent.tsx b/apps/web/app/(canvas)/dropComponent.tsx new file mode 100644 index 00000000..03a32358 --- /dev/null +++ b/apps/web/app/(canvas)/dropComponent.tsx @@ -0,0 +1,76 @@ +import React, { useRef, useCallback, useEffect, useContext } from "react"; +import { useEditor } from "tldraw"; +import DragContext, { DragContextType } from "./lib/context"; +import { handleExternalDroppedContent } from "./lib/createEmbeds"; + +const stripHtmlTags = (html: string): string => { + const div = document.createElement("div"); + div.innerHTML = html; + return div.textContent || div.innerText || ""; +}; + +const useDrag = (): DragContextType => { + const context = useContext(DragContext); + if (!context) { + throw new Error('useCounter must be used within a CounterProvider'); + } + return context; +}; + + +function DropZone() { + const dropRef = useRef<HTMLDivElement | null>(null); + const {isDraggingOver, setIsDraggingOver} = useDrag(); + + const editor = useEditor(); + + const handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => { + event.preventDefault(); + setIsDraggingOver(false); + const dt = event.dataTransfer; + const items = dt.items; + + for (let i = 0; i < items.length; i++) { + if (items[i]!.kind === "file" && items[i]!.type.startsWith("image/")) { + const file = items[i]!.getAsFile(); + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target) { + // setDroppedImage(e.target.result as string); + } + }; + reader.readAsDataURL(file); + } + } else if (items[i]!.kind === "string") { + items[i]!.getAsString((data) => { + const cleanText = stripHtmlTags(data); + handleExternalDroppedContent({editor,text:cleanText}) + }); + } + } + }, []); + + useEffect(() => { + const divElement = dropRef.current; + if (divElement) { + // @ts-ignore + divElement.addEventListener("drop", handleDrop); + } + return () => { + if (divElement) { + // @ts-ignore + divElement.removeEventListener("drop", handleDrop); + } + }; + }, []); + + return ( + <div + className={`h-full w-full absolute top-0 left-0 z-[100000] pointer-events-none ${isDraggingOver && "bg-[#2C3439] pointer-events-auto"}`} + ref={dropRef} + ></div> + ); +} + +export default DropZone; diff --git a/apps/web/app/(canvas)/enabledComp.tsx b/apps/web/app/(canvas)/enabledComp.tsx index 5dbe6ee7..85811b82 100644 --- a/apps/web/app/(canvas)/enabledComp.tsx +++ b/apps/web/app/(canvas)/enabledComp.tsx @@ -7,12 +7,12 @@ export const components: Partial<TLUiComponents> = { TopPanel: null, DebugPanel: null, DebugMenu: null, + PageMenu: null, // Minimap: null, // ContextMenu: null, // HelpMenu: null, // ZoomMenu: null, // StylePanel: null, - // PageMenu: null, // NavigationPanel: null, // Toolbar: null, // KeyboardShortcutsDialog: null, diff --git a/apps/web/app/(canvas)/lib/context.tsx b/apps/web/app/(canvas)/lib/context.tsx new file mode 100644 index 00000000..36a106cf --- /dev/null +++ b/apps/web/app/(canvas)/lib/context.tsx @@ -0,0 +1,11 @@ +import { createContext } from 'react'; + +export interface DragContextType { + isDraggingOver: boolean; + setIsDraggingOver: React.Dispatch<React.SetStateAction<boolean>>; +} + + +const DragContext = createContext<DragContextType | undefined>(undefined); + +export default DragContext;
\ No newline at end of file diff --git a/apps/web/app/(canvas)/lib/createEmbeds.ts b/apps/web/app/(canvas)/lib/createEmbeds.ts index 322e697e..0db3c71b 100644 --- a/apps/web/app/(canvas)/lib/createEmbeds.ts +++ b/apps/web/app/(canvas)/lib/createEmbeds.ts @@ -2,8 +2,8 @@ import { AssetRecordType, Editor, TLAsset, TLAssetId, TLBookmarkShape, TLExterna export default async function createEmbedsFromUrl({url, point, sources, editor}: { url: string - point: VecLike | undefined - sources: TLExternalContentSource[] | undefined + point?: VecLike | undefined + sources?: TLExternalContentSource[] | undefined editor: Editor }){ @@ -50,10 +50,18 @@ export default async function createEmbedsFromUrl({url, point, sources, editor}: type: "url", url, }); - const fetchWebsite = await (await fetch(`https://unfurl-bookmark.pruthvirajthinks.workers.dev/?url=${url}`)).json() - if (fetchWebsite.title) bookmarkAsset.props.title = fetchWebsite.title; - if (fetchWebsite.image) bookmarkAsset.props.image = fetchWebsite.image; - if (fetchWebsite.description) bookmarkAsset.props.description = fetchWebsite.description; + const fetchWebsite: { + title?: string; + image?: string; + description?: string; + } = await (await fetch(`/api/unfirlsite?website=${url}`, { + method: "POST" + })).json() + if (bookmarkAsset){ + if (fetchWebsite.title) bookmarkAsset.props.title = fetchWebsite.title; + if (fetchWebsite.image) bookmarkAsset.props.image = fetchWebsite.image; + if (fetchWebsite.description) bookmarkAsset.props.description = fetchWebsite.description; + } if (!bookmarkAsset) throw Error("Could not create an asset"); asset = bookmarkAsset; } catch (e) { @@ -79,6 +87,38 @@ export default async function createEmbedsFromUrl({url, point, sources, editor}: }); } +function isURL(str: string) { + try { + new URL(str); + return true; + } catch { + return false; + } +} + + +export function handleExternalDroppedContent({text, editor}: {text:string, editor: Editor}){ + const position = editor.inputs.shiftKey + ? editor.inputs.currentPagePoint + : editor.getViewportPageBounds().center; + + if (isURL(text)){ + createEmbedsFromUrl({editor, url: text}) + } else{ + editor.createShape({ + type: "text", + x: position.x - 75, + y: position.y - 75, + props: { + text: text, + size: "s", + textAlign: "start", + }, + }); + + } +} + function centerSelectionAroundPoint(editor: Editor, position: VecLike) { // Re-position shapes so that the center of the group is at the provided point const viewportPageBounds = editor.getViewportPageBounds() diff --git a/apps/web/app/(dash)/chat/chatWindow.tsx b/apps/web/app/(dash)/chat/chatWindow.tsx index bb6a0be1..32fd1fce 100644 --- a/apps/web/app/(dash)/chat/chatWindow.tsx +++ b/apps/web/app/(dash)/chat/chatWindow.tsx @@ -1,7 +1,7 @@ "use client"; import { AnimatePresence } from "framer-motion"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import QueryInput from "../home/queryinput"; import { cn } from "@repo/ui/lib/utils"; import { motion } from "framer-motion"; @@ -19,7 +19,10 @@ import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import rehypeHighlight from "rehype-highlight"; import { code, p } from "./markdownRenderHelpers"; -import { codeLanguageSubset } from "@/app/helpers/constants"; +import { codeLanguageSubset } from "@/lib/constants"; +import { z } from "zod"; +import { toast } from "sonner"; +import Link from "next/link"; function ChatWindow({ q, @@ -33,19 +36,85 @@ function ChatWindow({ { question: q, answer: { - parts: [ - // { - // text: `It seems like there might be a typo in your question. Could you please clarify or provide more context? If you meant "interesting," please let me know what specific information or topic you find interesting, and I can help you with that.`, - // }, - ], + parts: [], sources: [], }, }, ]); + const [isAutoScroll, setIsAutoScroll] = useState(true); + + const removeJustificationFromText = (text: string) => { + // remove everything after the first "<justification>" word + const justificationLine = text.indexOf("<justification>"); + if (justificationLine !== -1) { + // Add that justification to the last chat message + const lastChatMessage = chatHistory[chatHistory.length - 1]; + if (lastChatMessage) { + lastChatMessage.answer.justification = text.slice(justificationLine); + } + return text.slice(0, justificationLine); + } + return text; + }; const router = useRouter(); const getAnswer = async (query: string, spaces: string[]) => { + const sourcesFetch = await fetch( + `/api/chat?q=${query}&spaces=${spaces}&sourcesOnly=true`, + { + method: "POST", + body: JSON.stringify({ chatHistory }), + }, + ); + + // TODO: handle this properly + const sources = await sourcesFetch.json(); + + const sourcesZod = z.object({ + ids: z.array(z.string()), + metadata: z.array(z.any()), + }); + + const sourcesParsed = sourcesZod.safeParse(sources); + + if (!sourcesParsed.success) { + console.log(sources); + console.error(sourcesParsed.error); + toast.error("Something went wrong while getting the sources"); + return; + } + + setChatHistory((prevChatHistory) => { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: "smooth", + }); + const newChatHistory = [...prevChatHistory]; + const lastAnswer = newChatHistory[newChatHistory.length - 1]; + if (!lastAnswer) return prevChatHistory; + const filteredSourceUrls = new Set( + sourcesParsed.data.metadata.map((source) => source.url), + ); + const uniqueSources = sourcesParsed.data.metadata.filter((source) => { + if (filteredSourceUrls.has(source.url)) { + filteredSourceUrls.delete(source.url); + return true; + } + return false; + }); + lastAnswer.answer.sources = uniqueSources.map((source) => ({ + title: source.title ?? "Untitled", + type: source.type ?? "page", + source: source.url ?? "https://supermemory.ai", + content: source.description ?? "No content available", + numChunks: sourcesParsed.data.metadata.filter( + (f) => f.url === source.url, + ).length, + })); + return newChatHistory; + }); + const resp = await fetch(`/api/chat?q=${query}&spaces=${spaces}`, { method: "POST", body: JSON.stringify({ chatHistory }), @@ -53,7 +122,6 @@ function ChatWindow({ const reader = resp.body?.getReader(); let done = false; - let result = ""; while (!done && reader) { const { value, done: d } = await reader.read(); done = d; @@ -62,23 +130,28 @@ function ChatWindow({ const newChatHistory = [...prevChatHistory]; const lastAnswer = newChatHistory[newChatHistory.length - 1]; if (!lastAnswer) return prevChatHistory; - lastAnswer.answer.parts.push({ text: new TextDecoder().decode(value) }); + const txt = new TextDecoder().decode(value); + + if (isAutoScroll) { + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: "smooth", + }); + } + + lastAnswer.answer.parts.push({ text: txt }); return newChatHistory; }); } - - console.log(result); }; useEffect(() => { if (q.trim().length > 0) { + setLayout("chat"); getAnswer( q, spaces.map((s) => s.id), ); - setTimeout(() => { - setLayout("chat"); - }, 300); } else { router.push("/home"); } @@ -94,18 +167,23 @@ function ChatWindow({ className="max-w-3xl h-full justify-center items-center flex mx-auto w-full flex-col" > <div className="w-full h-96"> - <QueryInput initialQuery={q} initialSpaces={[]} disabled /> + <QueryInput + handleSubmit={() => {}} + initialQuery={q} + initialSpaces={[]} + disabled + /> </div> </motion.div> ) : ( <div - className="max-w-3xl flex mx-auto w-full flex-col mt-24" + className="max-w-3xl relative flex mx-auto w-full flex-col mt-24 pb-32" key="chat" > {chatHistory.map((chat, idx) => ( <div key={idx} - className={`mt-8 ${idx != chatHistory.length - 1 ? "pb-2 border-b" : ""}`} + className={`mt-8 ${idx != chatHistory.length - 1 ? "pb-2 border-b border-b-gray-400" : ""}`} > <h2 className={cn( @@ -151,15 +229,25 @@ function ChatWindow({ </> ))} {chat.answer.sources.map((source, idx) => ( - <div + <Link + href={source.source} key={idx} className="rounded-xl bg-secondary p-4 flex flex-col gap-2 min-w-72" > - <div className="text-foreground-menu"> - {source.type} + <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"> + {source.content.length > 100 + ? source.content.slice(0, 100) + "..." + : source.content} </div> - <div>{source.title}</div> - </div> + </Link> ))} </AccordionContent> </AccordionItem> @@ -197,14 +285,67 @@ function ChatWindow({ }} className="flex flex-col gap-2" > - {chat.answer.parts.map((part) => part.text).join("")} + {removeJustificationFromText( + chat.answer.parts.map((part) => part.text).join(""), + )} </Markdown> </div> </div> - + {/* Justification */} + {chat.answer.justification && + chat.answer.justification.length && ( + <div + className={`${chat.answer.justification && chat.answer.justification.length > 0 ? "flex" : "hidden"}`} + > + <Accordion defaultValue={""} type="single" collapsible> + <AccordionItem value="justification"> + <AccordionTrigger className="text-foreground-menu"> + Justification + </AccordionTrigger> + <AccordionContent + className="relative flex gap-2 max-w-3xl overflow-auto no-scrollbar" + defaultChecked + > + {chat.answer.justification.length > 0 + ? chat.answer.justification + .replaceAll("<justification>", "") + .replaceAll("</justification>", "") + : "No justification provided."} + </AccordionContent> + </AccordionItem> + </Accordion> + </div> + )} </div> </div> ))} + + <div className="fixed bottom-0 w-full max-w-3xl pb-4"> + <QueryInput + mini + className="w-full shadow-md" + initialQuery={""} + initialSpaces={[]} + handleSubmit={async (q, spaces) => { + setChatHistory((prevChatHistory) => { + return [ + ...prevChatHistory, + { + question: q, + answer: { + parts: [], + sources: [], + }, + }, + ]; + }); + await getAnswer( + q, + spaces.map((s) => `${s.id}`), + ); + }} + /> + </div> </div> )} </AnimatePresence> diff --git a/apps/web/app/(dash)/dynamicisland.tsx b/apps/web/app/(dash)/dynamicisland.tsx index c08f883a..98fafc7a 100644 --- a/apps/web/app/(dash)/dynamicisland.tsx +++ b/apps/web/app/(dash)/dynamicisland.tsx @@ -4,12 +4,12 @@ import { AddIcon } from "@repo/ui/icons"; import Image from "next/image"; import { AnimatePresence, useMotionValueEvent, useScroll } from "framer-motion"; -import { useEffect, useRef, useState } from "react"; +import { useActionState, useEffect, useRef, useState } from "react"; import { motion } from "framer-motion"; import { Label } from "@repo/ui/shadcn/label"; import { Input } from "@repo/ui/shadcn/input"; import { Textarea } from "@repo/ui/shadcn/textarea"; -import { createSpace } from "../actions/doers"; +import { createMemory, createSpace } from "../actions/doers"; import { Select, SelectContent, @@ -20,6 +20,7 @@ import { import { Space } from "../actions/types"; import { getSpaces } from "../actions/fetchers"; import { toast } from "sonner"; +import { useFormStatus } from "react-dom"; export function DynamicIsland() { const { scrollYProgress } = useScroll(); @@ -253,13 +254,39 @@ function PageForm({ cancelfn: () => void; spaces: Space[]; }) { + const [loading, setLoading] = useState(false); + + const { pending } = useFormStatus(); return ( - <div className="bg-secondary border border-muted-foreground px-4 py-3 rounded-2xl mt-2 flex flex-col gap-3"> + <form + action={async (e: FormData) => { + const content = e.get("content")?.toString(); + const space = e.get("space")?.toString(); + if (!content) { + toast.error("Content is required"); + return; + } + setLoading(true); + const cont = await createMemory({ + content: content, + spaces: space ? [space] : undefined, + }); + + console.log(cont); + setLoading(false); + if (cont.success) { + toast.success("Memory created"); + } else { + toast.error("Memory creation failed"); + } + }} + className="bg-secondary border border-muted-foreground px-4 py-3 rounded-2xl mt-2 flex flex-col gap-3" + > <div> <Label className="text-[#858B92]" htmlFor="space"> Space </Label> - <Select> + <Select name="space"> <SelectTrigger> <SelectValue placeholder="Space" /> </SelectTrigger> @@ -272,24 +299,28 @@ function PageForm({ </SelectContent> </Select> </div> + <div key={`${loading}-${pending}`}> + {loading ? <div>Loading...</div> : "not loading"} + </div> <div> <Label className="text-[#858B92]" htmlFor="name"> Page Url </Label> <Input className="bg-[#2B3237] focus-visible:ring-0 border-none focus-visible:ring-offset-0" - id="name" + id="input" + name="content" /> </div> <div className="flex justify-end"> - <div - onClick={cancelfn} + <button + type="submit" className="bg-[#2B3237] px-2 py-1 rounded-xl cursor-pointer" > - cancel - </div> + Submit + </button> </div> - </div> + </form> ); } diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx index c539673d..bdf6a61e 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -1,11 +1,12 @@ -import React from "react"; -import Menu from "../menu"; -import Header from "../header"; +"use client"; + +import React, { useEffect, useState } from "react"; import QueryInput from "./queryinput"; -import { homeSearchParamsCache } from "@/app/helpers/lib/searchParams"; +import { homeSearchParamsCache } from "@/lib/searchParams"; import { getSpaces } from "@/app/actions/fetchers"; +import { useRouter } from "next/navigation"; -async function Page({ +function Page({ searchParams, }: { searchParams: Record<string, string | string[] | undefined>; @@ -13,12 +14,18 @@ async function Page({ // TODO: use this to show a welcome page/modal const { firstTime } = homeSearchParamsCache.parse(searchParams); - let spaces = await getSpaces(); + const [spaces, setSpaces] = useState<{ id: number; name: string }[]>([]); + + useEffect(() => { + getSpaces().then((res) => { + if (res.success && res.data) { + setSpaces(res.data); + } + // TODO: HANDLE ERROR + }); + }, []); - if (!spaces.success) { - // TODO: handle this error properly. - spaces.data = []; - } + const { push } = useRouter(); return ( <div className="max-w-3xl h-full justify-center flex mx-auto w-full flex-col"> @@ -26,7 +33,17 @@ async function Page({ {/* <div className="">hi {firstTime ? 'first time' : ''}</div> */} <div className="w-full pb-20"> - <QueryInput initialSpaces={spaces.data} /> + <QueryInput + handleSubmit={(q, spaces) => { + const newQ = + "/chat?q=" + + encodeURI(q) + + (spaces ? "&spaces=" + JSON.stringify(spaces) : ""); + + push(newQ); + }} + initialSpaces={spaces} + /> </div> </div> ); diff --git a/apps/web/app/(dash)/home/queryinput.tsx b/apps/web/app/(dash)/home/queryinput.tsx index fbd537e3..4fadfb6f 100644 --- a/apps/web/app/(dash)/home/queryinput.tsx +++ b/apps/web/app/(dash)/home/queryinput.tsx @@ -12,6 +12,9 @@ function QueryInput({ initialQuery = "", initialSpaces = [], disabled = false, + className, + mini = false, + handleSubmit, }: { initialQuery?: string; initialSpaces?: { @@ -19,32 +22,14 @@ function QueryInput({ name: string; }[]; disabled?: boolean; + className?: string; + mini?: boolean; + handleSubmit: (q: string, spaces: { id: number; name: string }[]) => void; }) { const [q, setQ] = useState(initialQuery); const [selectedSpaces, setSelectedSpaces] = useState<number[]>([]); - const { push } = useRouter(); - - const parseQ = () => { - // preparedSpaces is list of spaces selected by user, with id and name - const preparedSpaces = initialSpaces - .filter((x) => selectedSpaces.includes(x.id)) - .map((x) => { - return { - id: x.id, - name: x.name, - }; - }); - - const newQ = - "/chat?q=" + - encodeURI(q) + - (selectedSpaces ? "&spaces=" + JSON.stringify(preparedSpaces) : ""); - - return newQ; - }; - const options = useMemo( () => initialSpaces.map((x) => ({ @@ -54,21 +39,43 @@ function QueryInput({ [initialSpaces], ); + const preparedSpaces = useMemo( + () => + initialSpaces + .filter((x) => selectedSpaces.includes(x.id)) + .map((x) => { + return { + id: x.id, + name: x.name, + }; + }), + [selectedSpaces, initialSpaces], + ); + return ( - <div> - <div className="bg-secondary rounded-t-[24px]"> + <div className={className}> + <div + className={`bg-secondary ${!mini ? "rounded-t-3xl" : "rounded-3xl"}`} + > {/* input and action button */} - <form action={async () => push(parseQ())} className="flex gap-4 p-3"> + <form + action={async () => { + handleSubmit(q, preparedSpaces); + setQ(""); + }} + className="flex gap-4 p-3" + > <textarea name="q" cols={30} - rows={4} + rows={mini ? 2 : 4} className="bg-transparent pt-2.5 text-base placeholder:text-[#5D6165] text-[#9DA0A4] focus:text-white duration-200 tracking-[3%] outline-none resize-none w-full p-4" placeholder="Ask your second brain..." onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); - if (!e.shiftKey) push(parseQ()); + handleSubmit(q, preparedSpaces); + setQ(""); } }} onChange={(e) => setQ(e.target.value)} @@ -85,24 +92,29 @@ function QueryInput({ <Image src={ArrowRightIcon} alt="Right arrow icon" /> </button> </form> - - <Divider /> </div> {/* selected sources */} - <div className="flex items-center gap-6 p-2 h-auto bg-secondary rounded-b-[24px]"> - <MultipleSelector - 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> + {!mini && ( + <> + <Divider /> + <div className="flex items-center gap-6 p-2 h-auto bg-secondary rounded-b-3xl"> + <MultipleSelector + key={options.length} + disabled={disabled} + defaultOptions={options} + onChange={(e) => + setSelectedSpaces(e.map((x) => parseInt(x.value))) + } + placeholder="Focus on specific spaces..." + emptyIndicator={ + <p className="text-center text-lg leading-10 text-gray-600 dark:text-gray-400"> + no results found. + </p> + } + /> + </div> + </> + )} </div> ); } diff --git a/apps/web/app/(dash)/layout.tsx b/apps/web/app/(dash)/layout.tsx index 4c787c9c..4e1f6989 100644 --- a/apps/web/app/(dash)/layout.tsx +++ b/apps/web/app/(dash)/layout.tsx @@ -1,7 +1,7 @@ import Header from "./header"; import Menu from "./menu"; import { redirect } from "next/navigation"; -import { auth } from "../helpers/server/auth"; +import { auth } from "../../server/auth"; import { Toaster } from "@repo/ui/shadcn/sonner"; async function Layout({ children }: { children: React.ReactNode }) { diff --git a/apps/web/app/(dash)/memories/page.tsx b/apps/web/app/(dash)/memories/page.tsx index bc2fcd53..ff746d1d 100644 --- a/apps/web/app/(dash)/memories/page.tsx +++ b/apps/web/app/(dash)/memories/page.tsx @@ -1,14 +1,31 @@ "use client"; +import { getAllUserMemoriesAndSpaces } from "@/app/actions/fetchers"; +import { Space } from "@/app/actions/types"; +import { Content } from "@/server/db/schema"; import { NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons"; import Image from "next/image"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; -function page() { - const [filter, setFilter] = useState("All") - const setFilterfn = (i:string) => setFilter(i) +function Page() { + const [filter, setFilter] = useState("All"); + const setFilterfn = (i: string) => setFilter(i); + + const [search, setSearch] = useState(""); + + const [memoriesAndSpaces, setMemoriesAndSpaces] = useState<{ + memories: Content[]; + spaces: Space[]; + }>({ memories: [], spaces: [] }); + + useEffect(() => { + (async () => { + const { success, data } = await getAllUserMemoriesAndSpaces(); + if (!success ?? !data) return; + setMemoriesAndSpaces({ memories: data.memories, spaces: data.spaces }); + })(); + }, []); - const [search, setSearch] = useState("") return ( <div className="max-w-3xl min-w-3xl py-36 h-full flex mx-auto w-full flex-col gap-12"> <h2 className="text-white w-full font-medium text-2xl text-left"> @@ -16,41 +33,50 @@ function page() { </h2> <div className="flex flex-col gap-4"> - <div className="w-full relative"> - <input - type="text" - className=" w-full py-3 rounded-md text-lg pl-8 bg-[#1F2428] outline-none" - placeholder="search here..." - /> - <Image className="absolute top-1/2 -translate-y-1/2 left-2" src={SearchIcon} alt="Search icon" /> - </div> - - <Filters filter={filter} setFilter={setFilterfn} /> + <div className="w-full relative"> + <input + type="text" + className=" w-full py-3 rounded-md text-lg pl-8 bg-[#1F2428] outline-none" + placeholder="search here..." + /> + <Image + className="absolute top-1/2 -translate-y-1/2 left-2" + src={SearchIcon} + alt="Search icon" + /> + </div> + <Filters filter={filter} setFilter={setFilterfn} /> </div> <div> <div className="text-[#B3BCC5]">Spaces</div> - <TabComponent title="AI Technologies" description="Resources 12" /> - <TabComponent title="Python Tricks" description="Resources 120" /> - <TabComponent title="JavaScript Hacks" description="Resources 14" /> + {memoriesAndSpaces.spaces.map((space) => ( + <TabComponent title={space.name} description={space.id.toString()} /> + ))} </div> <div> <div className="text-[#B3BCC5]">Pages</div> - <LinkComponent title="How to make a custom AI model?" url="https://google.com" /> - <LinkComponent title="GPT 5 Release Date" url="https://wth.com" /> - <LinkComponent title="Why @sama never use uppercase" url="https://tom.com" /> + {memoriesAndSpaces.memories.map((memory) => ( + <LinkComponent title={memory.title ?? "No title"} url={memory.url} /> + ))} </div> </div> ); } -function TabComponent({title, description}: {title:string, description:string}){ +function TabComponent({ + title, + description, +}: { + title: string; + description: string; +}) { return ( <div className="flex items-center my-6"> <div> <div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md"> - {title.slice(0,2).toUpperCase()} + {title.slice(0, 2).toUpperCase()} </div> </div> <div className="grow px-4"> @@ -58,37 +84,50 @@ function TabComponent({title, description}: {title:string, description:string}){ <div>{description}</div> </div> <div> - <Image src={NextIcon} alt="Search icon" /> + <Image src={NextIcon} alt="Search icon" /> </div> </div> - ) + ); } -function LinkComponent({title, url}: {title:string, url:string}){ +function LinkComponent({ title, url }: { title: string; url: string }) { return ( <div className="flex items-center my-6"> - <div> - <div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md"> - <Image src={UrlIcon} alt="Url icon" /> + <div> + <div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md"> + <Image src={UrlIcon} alt="Url icon" /> + </div> + </div> + <div className="grow px-4"> + <div className="text-lg text-[#fff]">{title}</div> + <div>{url}</div> </div> </div> - <div className="grow px-4"> - <div className="text-lg text-[#fff]">{title}</div> - <div>{url}</div> - </div> - </div> - ) + ); } -const FilterMethods = ["All", "Spaces", "Pages", "Notes"] -function Filters({setFilter, filter}:{setFilter: (i:string)=> void, filter: string}){ +const FilterMethods = ["All", "Spaces", "Pages", "Notes"]; +function Filters({ + setFilter, + filter, +}: { + setFilter: (i: string) => void; + filter: string; +}) { return ( <div className="flex gap-4"> - {FilterMethods.map((i)=> { - return <div onClick={()=> setFilter(i)} className={`transition px-6 py-2 rounded-xl ${i === filter ? "bg-[#21303D] text-[#369DFD]" : "text-[#B3BCC5] bg-[#1F2428] hover:bg-[#1f262d] hover:text-[#76a3cc]"}`}>{i}</div> + {FilterMethods.map((i) => { + return ( + <div + onClick={() => setFilter(i)} + className={`transition px-6 py-2 rounded-xl ${i === filter ? "bg-[#21303D] text-[#369DFD]" : "text-[#B3BCC5] bg-[#1F2428] hover:bg-[#1f262d] hover:text-[#76a3cc]"}`} + > + {i} + </div> + ); })} </div> - ) + ); } -export default page; +export default Page; diff --git a/apps/web/app/(editor)/components/aigenerate.tsx b/apps/web/app/(editor)/components/aigenerate.tsx index b1c4ccd4..de9b2a3f 100644 --- a/apps/web/app/(editor)/components/aigenerate.tsx +++ b/apps/web/app/(editor)/components/aigenerate.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import Magic from "./ui/magic"; import CrazySpinner from "./ui/crazy-spinner"; import Asksvg from "./ui/asksvg"; @@ -8,14 +8,11 @@ import Autocompletesvg from "./ui/autocompletesvg"; import { motion, AnimatePresence } from "framer-motion"; import type { Editor } from "@tiptap/core"; import { useEditor } from "novel"; - +import { NodeSelection } from "prosemirror-state"; function Aigenerate() { const [visible, setVisible] = useState(false); const [generating, setGenerating] = useState(false); - - // generating -> can be converted to false, so we need to make sure the generation gets cancelled - // visible const { editor } = useEditor(); const setGeneratingfn = (v: boolean) => setGenerating(v); @@ -58,8 +55,9 @@ function Aigenerate() { }} className="absolute z-50 top-0" > - <ToolBar setGeneratingfn={setGeneratingfn} editor={editor} /> - <div className="h-8 w-18rem bg-blue-600 blur-[16rem]" /> + {/* TODO: handle Editor not initalised, maybe with a loading state. */} + <ToolBar setGeneratingfn={setGeneratingfn} editor={editor} /> + <div className="h-8 w-18rem bg-blue-600 blur-[16rem]" /> </motion.div> </div> ); @@ -68,10 +66,22 @@ function Aigenerate() { export default Aigenerate; const options = [ - <><Translatesvg />Translate</>, - <><Rewritesvg />Change Tone</>, - <><Asksvg />Ask Gemini</>, - <><Autocompletesvg />Auto Complete</> + <> + <Translatesvg /> + Translate + </>, + <> + <Rewritesvg /> + Change Tone + </>, + <> + <Asksvg /> + Ask Gemini + </>, + <> + <Autocompletesvg /> + Auto Complete + </>, ]; function ToolBar({ @@ -116,7 +126,7 @@ function ToolBar({ )} </AnimatePresence> <div className="select-none flex items-center whitespace-nowrap gap-3 relative z-[60] pointer-events-none"> - {item} + {item} </div> </div> ))} @@ -135,43 +145,42 @@ async function AigenerateContent({ }) { setGeneratingfn(true); - const {from, to} = editor.view.state.selection; - const content = editor.view.state.selection.content(); - content.content.forEach((v, i)=> { - v.forEach((v, i)=> { - console.log(v.text) - }) - }) - - const transaction = editor.state.tr - transaction.replaceRange(from, to, content) - - editor.view.dispatch(transaction) - - // console.log(content) - // content.map((v, i)=> console.log(v.content)) - - // const fragment = Fragment.fromArray(content); - - // console.log(fragment) - - // editor.view.state.selection.content().content.append(content) + const { from, to } = editor.view.state.selection; + + const slice = editor.state.selection.content(); + const text = editor.storage.markdown.serializer.serialize(slice.content); + + const request = [ + "Translate to hindi written in english, do not write anything else", + "change tone, improve the way be more formal", + "ask, answer the question", + "continue this, minimum 80 characters, do not repeat just continue don't use ... to denote start", + ]; + + const resp = await fetch("/api/editorai", { + method: "POST", + body: JSON.stringify({ + context: text, + request: request[idx], + }), + }); + + const reader = resp.body?.getReader(); + let done = false; + let position = to; + while (!done && reader) { + const { value, done: d } = await reader.read(); + done = d; + + const decoded = new TextDecoder().decode(value); + console.log(decoded); + editor + .chain() + .focus() + .insertContentAt(position + 1, decoded) + .run(); + position += decoded.length; + } setGeneratingfn(false); - - - - // const genAI = new GoogleGenerativeAI("AIzaSyDGwJCP9SH5gryyvh65LJ6xTZ0SOdNvzyY"); - // const model = genAI.getGenerativeModel({ model: "gemini-pro"}); - - // const result = (await model.generateContent(`${ty}, ${query}`)).response.text(); - - // .insertContentAt( - // { - // from: from, - // to: to, - // }, - // result, - // ) - // .run(); } diff --git a/apps/web/app/(editor)/editor.tsx b/apps/web/app/(editor)/editor.tsx index 5b4a60ce..f7f9a098 100644 --- a/apps/web/app/(editor)/editor.tsx +++ b/apps/web/app/(editor)/editor.tsx @@ -15,19 +15,20 @@ import Topbar from "./components/topbar"; const Editor = () => { const [initialContent, setInitialContent] = useState<null | JSONContent>( - null + null, ); const [saveStatus, setSaveStatus] = useState("Saved"); const [charsCount, setCharsCount] = useState(); const [visible, setVisible] = useState(true); useEffect(() => { + if (typeof window === "undefined") return; const content = window.localStorage.getItem("novel-content"); if (content) setInitialContent(JSON.parse(content)); else setInitialContent(defaultEditorContent); }, []); - if (!initialContent) return null; + if (!initialContent) return <>Loading...</>; return ( <div className="relative w-full max-w-screen-xl"> diff --git a/apps/web/app/(landing)/Cta.tsx b/apps/web/app/(landing)/Cta.tsx index be99bf99..f0f471c2 100644 --- a/apps/web/app/(landing)/Cta.tsx +++ b/apps/web/app/(landing)/Cta.tsx @@ -24,7 +24,7 @@ function Cta() { height={1405} priority draggable="false" - className="absolute z-[-2] hidden select-none rounded-3xl bg-black md:block lg:w-[80%]" + className="absolute z-[-2] hidden select-none rounded-3xl bg-background md:block lg:w-[80%]" /> <h1 className="z-20 mt-4 text-center text-5xl font-medium tracking-tight text-white"> Your bookmarks are collecting dust. diff --git a/apps/web/app/(landing)/page.tsx b/apps/web/app/(landing)/page.tsx index 09f94d92..5f8b28b4 100644 --- a/apps/web/app/(landing)/page.tsx +++ b/apps/web/app/(landing)/page.tsx @@ -5,7 +5,7 @@ import Cta from "./Cta"; import { Toaster } from "@repo/ui/shadcn/toaster"; import Features from "./Features"; import Footer from "./footer"; -import { auth } from "../helpers/server/auth"; +import { auth } from "../../server/auth"; import { redirect } from "next/navigation"; export const runtime = "edge"; diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts index c8a1f3b4..6c7180d9 100644 --- a/apps/web/app/actions/doers.ts +++ b/apps/web/app/actions/doers.ts @@ -1,10 +1,15 @@ "use server"; import { revalidatePath } from "next/cache"; -import { db } from "../helpers/server/db"; -import { space } from "../helpers/server/db/schema"; +import { db } from "../../server/db"; +import { contentToSpace, space, storedContent } from "../../server/db/schema"; import { ServerActionReturnType } from "./types"; -import { auth } from "../helpers/server/auth"; +import { auth } from "../../server/auth"; +import { Tweet } from "react-tweet/api"; +import { getMetaData } from "@/lib/get-metadata"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { LIMITS } from "@/lib/constants"; +import { z } from "zod"; export const createSpace = async ( input: string | FormData, @@ -41,3 +46,223 @@ export const createSpace = async ( } } }; + +const typeDecider = (content: string) => { + // if the content is a URL, then it's a page. if its a URL with https://x.com/user/status/123, then it's a tweet. else, it's a note. + // do strict checking with regex + if (content.match(/https?:\/\/[\w\.]+\/[\w]+\/[\w]+\/[\d]+/)) { + return "tweet"; + } else if (content.match(/https?:\/\/[\w\.]+/)) { + return "page"; + } else { + return "note"; + } +}; + +export const limit = async (userId: string, type = "page") => { + const count = await db + .select({ + count: sql<number>`count(*)`.mapWith(Number), + }) + .from(storedContent) + .where(and(eq(storedContent.userId, userId), eq(storedContent.type, type))); + + if (count[0]!.count > LIMITS[type as keyof typeof LIMITS]) { + return false; + } + + return true; +}; + +const getTweetData = async (tweetID: string) => { + const url = `https://cdn.syndication.twimg.com/tweet-result?id=${tweetID}&lang=en&features=tfw_timeline_list%3A%3Btfw_follower_count_sunset%3Atrue%3Btfw_tweet_edit_backend%3Aon%3Btfw_refsrc_session%3Aon%3Btfw_fosnr_soft_interventions_enabled%3Aon%3Btfw_show_birdwatch_pivots_enabled%3Aon%3Btfw_show_business_verified_badge%3Aon%3Btfw_duplicate_scribes_to_settings%3Aon%3Btfw_use_profile_image_shape_enabled%3Aon%3Btfw_show_blue_verified_badge%3Aon%3Btfw_legacy_timeline_sunset%3Atrue%3Btfw_show_gov_verified_badge%3Aon%3Btfw_show_business_affiliate_badge%3Aon%3Btfw_tweet_edit_frontend%3Aon&token=4c2mmul6mnh`; + + const resp = await fetch(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3", + Accept: "application/json", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br", + Connection: "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Cache-Control": "max-age=0", + TE: "Trailers", + }, + }); + console.log(resp.status); + const data = (await resp.json()) as Tweet; + + return data; +}; + +export const createMemory = async (input: { + content: string; + spaces?: string[]; +}): ServerActionReturnType<number> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + return { error: "Not authenticated", success: false }; + } + + const type = typeDecider(input.content); + + let pageContent = input.content; + let metadata: Awaited<ReturnType<typeof getMetaData>>; + + if (!(await limit(data.user.id, type))) { + return { + success: false, + data: 0, + error: `You have exceeded the limit of ${LIMITS[type as keyof typeof LIMITS]} ${type}s.`, + }; + } + + if (type === "page") { + const response = await fetch("https://md.dhr.wtf/?url=" + input.content, { + headers: { + Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY, + }, + }); + pageContent = await response.text(); + metadata = await getMetaData(input.content); + } else if (type === "tweet") { + const tweet = await getTweetData(input.content.split("/").pop() as string); + pageContent = JSON.stringify(tweet); + metadata = { + baseUrl: input.content, + description: tweet.text, + image: tweet.user.profile_image_url_https, + title: `Tweet by ${tweet.user.name}`, + }; + } else if (type === "note") { + pageContent = input.content; + const noteId = new Date().getTime(); + metadata = { + baseUrl: `https://supermemory.ai/note/${noteId}`, + description: `Note created at ${new Date().toLocaleString()}`, + image: "https://supermemory.ai/logo.png", + title: `${pageContent.slice(0, 20)} ${pageContent.length > 20 ? "..." : ""}`, + }; + } else { + return { + success: false, + data: 0, + error: "Invalid type", + }; + } + + let storeToSpaces = input.spaces; + + if (!storeToSpaces) { + storeToSpaces = []; + } + + const vectorSaveResponse = await fetch( + `${process.env.BACKEND_BASE_URL}/api/add`, + { + method: "POST", + body: JSON.stringify({ + pageContent, + title: metadata.title, + description: metadata.description, + url: metadata.baseUrl, + spaces: storeToSpaces, + user: data.user.id, + type, + }), + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY, + }, + }, + ); + + if (!vectorSaveResponse.ok) { + const errorData = await vectorSaveResponse.text(); + console.log(errorData); + return { + success: false, + data: 0, + error: `Failed to save to vector store. Backend returned error: ${errorData}`, + }; + } + + // Insert into database + const insertResponse = await db + .insert(storedContent) + .values({ + content: pageContent, + title: metadata.title, + description: metadata.description, + url: input.content, + baseUrl: metadata.baseUrl, + image: metadata.image, + savedAt: new Date(), + userId: data.user.id, + type, + }) + .returning({ id: storedContent.id }); + + const contentId = insertResponse[0]?.id; + if (!contentId) { + return { + success: false, + data: 0, + error: "Something went wrong while saving the document to the database", + }; + } + + if (storeToSpaces.length > 0) { + // Adding the many-to-many relationship between content and spaces + const spaceData = await db + .select() + .from(space) + .where( + and( + inArray( + space.id, + storeToSpaces.map((s) => parseInt(s)), + ), + eq(space.user, data.user.id), + ), + ) + .all(); + + await Promise.all( + spaceData.map(async (space) => { + await db + .insert(contentToSpace) + .values({ contentId: contentId, spaceId: space.id }); + }), + ); + } + + try { + const response = await vectorSaveResponse.json(); + + const expectedResponse = z.object({ status: z.literal("ok") }); + + const parsedResponse = expectedResponse.safeParse(response); + + if (!parsedResponse.success) { + return { + success: false, + data: 0, + error: `Failed to save to vector store. Backend returned error: ${parsedResponse.error.message}`, + }; + } + + return { + success: true, + data: 1, + }; + } catch (e) { + return { + success: false, + data: 0, + error: `Failed to save to vector store. Backend returned error: ${e}`, + }; + } +}; diff --git a/apps/web/app/actions/fetchers.ts b/apps/web/app/actions/fetchers.ts index 9c2527f0..dc71252e 100644 --- a/apps/web/app/actions/fetchers.ts +++ b/apps/web/app/actions/fetchers.ts @@ -1,10 +1,15 @@ "use server"; -import { eq } from "drizzle-orm"; -import { db } from "../helpers/server/db"; -import { users } from "../helpers/server/db/schema"; +import { eq, inArray, not, sql } from "drizzle-orm"; +import { db } from "../../server/db"; +import { + Content, + contentToSpace, + storedContent, + users, +} from "../../server/db/schema"; import { ServerActionReturnType, Space } from "./types"; -import { auth } from "../helpers/server/auth"; +import { auth } from "../../server/auth"; export const getSpaces = async (): ServerActionReturnType<Space[]> => { const data = await auth(); @@ -23,3 +28,115 @@ export const getSpaces = async (): ServerActionReturnType<Space[]> => { return { success: true, data: spacesWithoutUser }; }; + +export const getAllMemories = async ( + freeMemoriesOnly: boolean = false, +): ServerActionReturnType<Content[]> => { + const data = await auth(); + + if (!data || !data.user) { + return { error: "Not authenticated", success: false }; + } + + if (!freeMemoriesOnly) { + // Returns all memories, no matter the space. + const memories = await db.query.storedContent.findMany({ + where: eq(users, data.user.id), + }); + + return { success: true, data: memories }; + } + + // This only returns memories that are not a part of any space. + // This is useful for home page where we want to show a list of spaces and memories. + const contentNotInAnySpace = await db + .select() + .from(storedContent) + .where( + not( + eq( + storedContent.id, + db + .select({ contentId: contentToSpace.contentId }) + .from(contentToSpace), + ), + ), + ) + .execute(); + + return { success: true, data: contentNotInAnySpace }; +}; + +export const getAllUserMemoriesAndSpaces = async (): ServerActionReturnType<{ + spaces: Space[]; + memories: Content[]; +}> => { + const data = await auth(); + + if (!data || !data.user) { + return { error: "Not authenticated", success: false }; + } + + const spaces = await db.query.space.findMany({ + where: eq(users, data.user.id), + }); + + const spacesWithoutUser = spaces.map((space) => { + return { ...space, user: undefined }; + }); + + // const contentCountBySpace = await db + // .select({ + // spaceId: contentToSpace.spaceId, + // count: sql<number>`count(*)`.mapWith(Number), + // }) + // .from(contentToSpace) + // .where( + // inArray( + // contentToSpace.spaceId, + // spacesWithoutUser.map((space) => space.id), + // ), + // ) + // .groupBy(contentToSpace.spaceId) + // .execute(); + + // console.log(contentCountBySpace); + + // get a count with space mappings like spaceID: count (number of memories in that space) + const contentCountBySpace = await db + .select({ + spaceId: contentToSpace.spaceId, + count: sql<number>`count(*)`.mapWith(Number), + }) + .from(contentToSpace) + .where( + inArray( + contentToSpace.spaceId, + spacesWithoutUser.map((space) => space.id), + ), + ) + .groupBy(contentToSpace.spaceId) + .execute(); + + console.log(contentCountBySpace); + + const contentNotInAnySpace = await db + .select() + .from(storedContent) + .where( + not( + eq( + storedContent.id, + db + .select({ contentId: contentToSpace.contentId }) + .from(contentToSpace), + ), + ), + ) + .execute(); + + return { + success: true, + data: { spaces: spacesWithoutUser, memories: contentNotInAnySpace }, + }; +}; diff --git a/apps/web/app/actions/types.ts b/apps/web/app/actions/types.ts index fbf669e2..5c5afc5c 100644 --- a/apps/web/app/actions/types.ts +++ b/apps/web/app/actions/types.ts @@ -1,6 +1,7 @@ export type Space = { id: number; name: string; + numberOfMemories?: number; }; export type ServerActionReturnType<T> = Promise<{ diff --git a/apps/web/app/api/[...nextauth]/route.ts b/apps/web/app/api/[...nextauth]/route.ts index 50807ab1..e19cc16e 100644 --- a/apps/web/app/api/[...nextauth]/route.ts +++ b/apps/web/app/api/[...nextauth]/route.ts @@ -1,2 +1,2 @@ -export { GET, POST } from "../../helpers/server/auth"; +export { GET, POST } from "../../../server/auth"; export const runtime = "edge"; diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index aba8784c..c19ce92b 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -54,7 +54,7 @@ export async function POST(req: NextRequest) { ); const resp = await fetch( - `https://new-cf-ai-backend.dhravya.workers.dev/api/chat?query=${query}&user=${session.user.email}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`, + `${process.env.BACKEND_BASE_URL}/api/chat?query=${query}&user=${session.user.id}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`, { headers: { Authorization: `Bearer ${process.env.BACKEND_SECURITY_KEY}`, diff --git a/apps/web/app/api/editorai/route.ts b/apps/web/app/api/editorai/route.ts new file mode 100644 index 00000000..5e1fbf0c --- /dev/null +++ b/apps/web/app/api/editorai/route.ts @@ -0,0 +1,30 @@ +import type { NextRequest } from "next/server"; +import { ensureAuth } from "../ensureAuth"; + +export const runtime = "edge"; + +// ERROR #2 - This the the next function that calls the backend, I sometimes think this is redundency, but whatever +// I have commented the auth code, It should not work in development, but it still does sometimes +export async function POST(request: NextRequest) { + // const d = await ensureAuth(request); + // if (!d) { + // return new Response("Unauthorized", { status: 401 }); + // } + const res : {context: string, request: string} = await request.json() + + try { + const resp = await fetch(`${process.env.BACKEND_BASE_URL}/api/editorai?context=${res.context}&request=${res.request}`); + // this just checks if there are erros I am keeping it commented for you to better understand the important pieces + // if (resp.status !== 200 || !resp.ok) { + // const errorData = await resp.text(); + // console.log(errorData); + // return new Response( + // JSON.stringify({ message: "Error in CF function", error: errorData }), + // { status: resp.status }, + // ); + // } + return new Response(resp.body, { status: 200 }); + } catch (error) { + return new Response(`Error, ${error}`) + } +}
\ No newline at end of file diff --git a/apps/web/app/api/ensureAuth.ts b/apps/web/app/api/ensureAuth.ts index a1401a07..d2fbac0b 100644 --- a/apps/web/app/api/ensureAuth.ts +++ b/apps/web/app/api/ensureAuth.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { db } from "../helpers/server/db"; -import { sessions, users } from "../helpers/server/db/schema"; +import { db } from "../../server/db"; +import { sessions, users } from "../../server/db/schema"; import { eq } from "drizzle-orm"; export async function ensureAuth(req: NextRequest) { diff --git a/apps/web/app/api/getCount/route.ts b/apps/web/app/api/getCount/route.ts index f760c145..7cd2a2d3 100644 --- a/apps/web/app/api/getCount/route.ts +++ b/apps/web/app/api/getCount/route.ts @@ -1,6 +1,6 @@ -import { db } from "@/app/helpers/server/db"; +import { db } from "@/server/db"; import { and, eq, ne, sql } from "drizzle-orm"; -import { sessions, storedContent, users } from "@/app/helpers/server/db/schema"; +import { sessions, storedContent, users } from "@/server/db/schema"; import { type NextRequest, NextResponse } from "next/server"; import { ensureAuth } from "../ensureAuth"; @@ -20,7 +20,7 @@ export async function GET(req: NextRequest) { .from(storedContent) .where( and( - eq(storedContent.user, session.user.id), + eq(storedContent.userId, session.user.id), eq(storedContent.type, "twitter-bookmark"), ), ); @@ -32,7 +32,7 @@ export async function GET(req: NextRequest) { .from(storedContent) .where( and( - eq(storedContent.user, session.user.id), + eq(storedContent.userId, session.user.id), ne(storedContent.type, "twitter-bookmark"), ), ); diff --git a/apps/web/app/api/me/route.ts b/apps/web/app/api/me/route.ts index 20b6aece..621dcbfe 100644 --- a/apps/web/app/api/me/route.ts +++ b/apps/web/app/api/me/route.ts @@ -1,6 +1,6 @@ -import { db } from "@/app/helpers/server/db"; +import { db } from "@/server/db"; import { eq } from "drizzle-orm"; -import { sessions, users } from "@/app/helpers/server/db/schema"; +import { sessions, users } from "@/server/db/schema"; import { type NextRequest, NextResponse } from "next/server"; export const runtime = "edge"; diff --git a/apps/web/app/api/spaces/route.ts b/apps/web/app/api/spaces/route.ts index c46b02fc..cbed547d 100644 --- a/apps/web/app/api/spaces/route.ts +++ b/apps/web/app/api/spaces/route.ts @@ -1,5 +1,5 @@ -import { db } from "@/app/helpers/server/db"; -import { sessions, space, users } from "@/app/helpers/server/db/schema"; +import { db } from "@/server/db"; +import { sessions, space, users } from "@/server/db/schema"; import { eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { ensureAuth } from "../ensureAuth"; diff --git a/apps/web/app/api/store/route.ts b/apps/web/app/api/store/route.ts index f96f90cf..cb10db24 100644 --- a/apps/web/app/api/store/route.ts +++ b/apps/web/app/api/store/route.ts @@ -1,4 +1,4 @@ -import { db } from "@/app/helpers/server/db"; +import { db } from "@/server/db"; import { and, eq, sql, inArray } from "drizzle-orm"; import { contentToSpace, @@ -6,10 +6,12 @@ import { storedContent, users, space, -} from "@/app/helpers/server/db/schema"; +} from "@/server/db/schema"; import { type NextRequest, NextResponse } from "next/server"; -import { getMetaData } from "@/app/helpers/lib/get-metadata"; +import { getMetaData } from "@/lib/get-metadata"; import { ensureAuth } from "../ensureAuth"; +import { limit } from "@/app/actions/doers"; +import { LIMITS } from "@/lib/constants"; export const runtime = "edge"; @@ -33,22 +35,13 @@ export async function POST(req: NextRequest) { storeToSpaces = []; } - const count = await db - .select({ - count: sql<number>`count(*)`.mapWith(Number), - }) - .from(storedContent) - .where( - and( - eq(storedContent.user, session.user.id), - eq(storedContent.type, "page"), - ), - ); - - if (count[0]!.count > 100) { + if (!(await limit(session.user.id))) { return NextResponse.json( - { message: "Error", error: "Limit exceeded" }, - { status: 499 }, + { + message: "Error: Ratelimit exceeded", + error: `You have exceeded the limit of ${LIMITS["page"]} pages.`, + }, + { status: 429 }, ); } @@ -62,7 +55,7 @@ export async function POST(req: NextRequest) { baseUrl: metadata.baseUrl, image: metadata.image, savedAt: new Date(), - user: session.user.id, + userId: session.user.id, }) .returning({ id: storedContent.id }); diff --git a/apps/web/app/api/unfirlsite/route.ts b/apps/web/app/api/unfirlsite/route.ts new file mode 100644 index 00000000..4b8b4858 --- /dev/null +++ b/apps/web/app/api/unfirlsite/route.ts @@ -0,0 +1,134 @@ +import { load } from 'cheerio' +import { AwsClient } from "aws4fetch"; + +import type { NextRequest } from "next/server"; +import { ensureAuth } from "../ensureAuth"; + +export const runtime = "edge"; + +const r2 = new AwsClient({ + accessKeyId: process.env.R2_ACCESS_KEY_ID, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, +}); + + +export async function POST(request: NextRequest) { + + const d = await ensureAuth(request); + if (!d) { + return new Response("Unauthorized", { status: 401 }); + } + + if ( + !process.env.R2_ACCESS_KEY_ID || + !process.env.R2_ACCOUNT_ID || + !process.env.R2_SECRET_ACCESS_KEY || + !process.env.R2_BUCKET_NAME + ) { + return new Response( + "Missing one or more R2 env variables: R2_ENDPOINT, R2_ACCESS_ID, R2_SECRET_KEY, R2_BUCKET_NAME. To get them, go to the R2 console, create and paste keys in a `.dev.vars` file in the root of this project.", + { status: 500 }, + ); + } + + const website = new URL(request.url).searchParams.get("website"); + + if (!website) { + return new Response("Missing website", { status: 400 }); + } + + const salt = () => Math.floor(Math.random() * 11); + const encodeWebsite = `${encodeURIComponent(website)}${salt()}`; + + try { + // this returns the og image, description and title of website + const response = await unfurl(website); + + if (!response.image){ + return new Response(JSON.stringify(response)) + } + + const imageUrl = await process.env.DEV_IMAGES.get(encodeWebsite) + if (imageUrl){ + return new Response(JSON.stringify({ + image: imageUrl, + title: response.title, + description: response.description, + })) + } + + const res = await fetch(`${response.image}`) + const image = await res.blob(); + + const url = new URL( + `https://${process.env.R2_BUCKET_NAME}.${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com` + ); + + url.pathname = encodeWebsite; + url.searchParams.set("X-Amz-Expires", "3600"); + + const signedPuturl = await r2.sign( + new Request(url, { + method: "PUT", + }), + { + aws: { signQuery: true }, + } + ); + await fetch(signedPuturl.url, { + method: 'PUT', + body: image, + }); + + await process.env.DEV_IMAGES.put(encodeWebsite, `${process.env.R2_PUBLIC_BUCKET_ADDRESS}/${encodeWebsite}`) + + return new Response(JSON.stringify({ + image: `${process.env.R2_PUBLIC_BUCKET_ADDRESS}/${encodeWebsite}`, + title: response.title, + description: response.description, + })); + + } catch (error) { + console.log(error) + return new Response(JSON.stringify({ + status: 500, + error: error, + })) + } + } + +export async function unfurl(url: string) { + const response = await fetch(url) + if (response.status >= 400) { + throw new Error(`Error fetching url: ${response.status}`) + } + const contentType = response.headers.get('content-type') + if (!contentType?.includes('text/html')) { + throw new Error(`Content-type not right: ${contentType}`) + } + + const content = await response.text() + const $ = load(content) + + const og: { [key: string]: string | undefined } = {} + const twitter: { [key: string]: string | undefined } = {} + + // @ts-ignore, it just works so why care of type safety if someone has better way go ahead + $('meta[property^=og:]').each((_, el) => (og[$(el).attr('property')!] = $(el).attr('content'))) + // @ts-ignore + $('meta[name^=twitter:]').each((_, el) => (twitter[$(el).attr('name')!] = $(el).attr('content'))) + + const title = og['og:title'] ?? twitter['twitter:title'] ?? $('title').text() ?? undefined + const description = + og['og:description'] ?? + twitter['twitter:description'] ?? + $('meta[name="description"]').attr('content') ?? + undefined + const image = og['og:image:secure_url'] ?? og['og:image'] ?? twitter['twitter:image'] ?? undefined + + return { + title, + description, + image, + } +} diff --git a/apps/web/app/ref/page.tsx b/apps/web/app/ref/page.tsx index 9ace733a..b51a16bb 100644 --- a/apps/web/app/ref/page.tsx +++ b/apps/web/app/ref/page.tsx @@ -1,9 +1,9 @@ import { Button } from "@repo/ui/shadcn/button"; -import { auth, signIn, signOut } from "../helpers/server/auth"; -import { db } from "../helpers/server/db"; +import { auth, signIn, signOut } from "../../server/auth"; +import { db } from "../../server/db"; import { sql } from "drizzle-orm"; -import { users } from "../helpers/server/db/schema"; -import { getThemeToggler } from "../helpers/lib/get-theme-button"; +import { users } from "../../server/db/schema"; +import { getThemeToggler } from "../../lib/get-theme-button"; export const runtime = "edge"; diff --git a/apps/web/cf-env.d.ts b/apps/web/cf-env.d.ts index 98303f35..be5c991a 100644 --- a/apps/web/cf-env.d.ts +++ b/apps/web/cf-env.d.ts @@ -1,6 +1,17 @@ declare global { namespace NodeJS { - interface ProcessEnv extends CloudflareEnv {} + interface ProcessEnv extends CloudflareEnv { + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; + AUTH_SECRET: string; + R2_ENDPOINT: string; + R2_ACCESS_KEY_ID: string; + R2_SECRET_ACCESS_KEY: string; + R2_PUBLIC_BUCKET_ADDRESS: string; + R2_BUCKET_NAME: string; + BACKEND_SECURITY_KEY: string; + BACKEND_BASE_URL: string; + } } } diff --git a/apps/web/env.d.ts b/apps/web/env.d.ts index 2755280c..4f11ba55 100644 --- a/apps/web/env.d.ts +++ b/apps/web/env.d.ts @@ -2,14 +2,6 @@ // by running `wrangler types --env-interface CloudflareEnv env.d.ts` interface CloudflareEnv { - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; - AUTH_SECRET: string; - R2_ENDPOINT: string; - R2_ACCESS_ID: string; - R2_SECRET_KEY: string; - R2_BUCKET_NAME: string; - BACKEND_SECURITY_KEY: string; STORAGE: R2Bucket; DATABASE: D1Database; } diff --git a/apps/web/app/helpers/constants.ts b/apps/web/lib/constants.ts index c3fc640a..7a9485cf 100644 --- a/apps/web/app/helpers/constants.ts +++ b/apps/web/lib/constants.ts @@ -1,3 +1,9 @@ +export const LIMITS = { + page: 100, + tweet: 1000, + note: 1000, +}; + export const codeLanguageSubset = [ "python", "javascript", diff --git a/apps/web/app/helpers/lib/get-metadata.ts b/apps/web/lib/get-metadata.ts index 4609e49b..4609e49b 100644 --- a/apps/web/app/helpers/lib/get-metadata.ts +++ b/apps/web/lib/get-metadata.ts diff --git a/apps/web/app/helpers/lib/get-theme-button.tsx b/apps/web/lib/get-theme-button.tsx index 020cc976..020cc976 100644 --- a/apps/web/app/helpers/lib/get-theme-button.tsx +++ b/apps/web/lib/get-theme-button.tsx diff --git a/apps/web/app/helpers/lib/handle-errors.ts b/apps/web/lib/handle-errors.ts index 42cae589..42cae589 100644 --- a/apps/web/app/helpers/lib/handle-errors.ts +++ b/apps/web/lib/handle-errors.ts diff --git a/apps/web/app/helpers/lib/searchParams.ts b/apps/web/lib/searchParams.ts index 9899eaf7..9899eaf7 100644 --- a/apps/web/app/helpers/lib/searchParams.ts +++ b/apps/web/lib/searchParams.ts diff --git a/apps/web/app/helpers/server/auth.ts b/apps/web/server/auth.ts index c4e426d4..c4e426d4 100644 --- a/apps/web/app/helpers/server/auth.ts +++ b/apps/web/server/auth.ts diff --git a/apps/web/app/helpers/server/db/index.ts b/apps/web/server/db/index.ts index 4d671bea..4d671bea 100644 --- a/apps/web/app/helpers/server/db/index.ts +++ b/apps/web/server/db/index.ts diff --git a/apps/web/app/helpers/server/db/schema.ts b/apps/web/server/db/schema.ts index e3e789c6..1ff23c82 100644 --- a/apps/web/app/helpers/server/db/schema.ts +++ b/apps/web/server/db/schema.ts @@ -103,11 +103,9 @@ export const storedContent = createTable( savedAt: int("savedAt", { mode: "timestamp" }).notNull(), baseUrl: text("baseUrl", { length: 255 }), ogImage: text("ogImage", { length: 255 }), - type: text("type", { enum: ["note", "page", "twitter-bookmark"] }).default( - "page", - ), + type: text("type").default("page"), image: text("image", { length: 255 }), - userId: int("user").references(() => users.id, { + userId: text("user").references(() => users.id, { onDelete: "cascade", }), }, @@ -119,6 +117,8 @@ export const storedContent = createTable( }), ); +export type Content = typeof storedContent.$inferSelect; + export const contentToSpace = createTable( "contentToSpace", { |