diff options
| author | Dhravya <[email protected]> | 2024-06-30 14:07:16 -0500 |
|---|---|---|
| committer | Dhravya <[email protected]> | 2024-06-30 14:07:16 -0500 |
| commit | 7676ed57065eb61d54d500e68c7a7faae16e558b (patch) | |
| tree | 0d59a7dd9ee1772667abbc03158e268732ff5214 | |
| parent | tailwind shadow dom (diff) | |
| download | supermemory-7676ed57065eb61d54d500e68c7a7faae16e558b.tar.xz supermemory-7676ed57065eb61d54d500e68c7a7faae16e558b.zip | |
added things
24 files changed, 1623 insertions, 91 deletions
diff --git a/apps/extension/background.ts b/apps/extension/background.ts index dabb975c..5128ffed 100644 --- a/apps/extension/background.ts +++ b/apps/extension/background.ts @@ -1 +1,305 @@ -console.log("Hello from the background script!"); +import { Tweet } from "react-tweet/api"; +import { features, transformTweetData } from "./helpers"; + +const tweetToMd = (tweet: Tweet) => { + return `Tweet from @${tweet.user?.name ?? tweet.user?.screen_name ?? "Unknown"} + + ${tweet.text} + Images: ${tweet.photos ? tweet.photos.map((photo) => photo.url).join(", ") : "none"} + Time: ${tweet.created_at}, Likes: ${tweet.favorite_count}, Retweets: ${tweet.conversation_count} + + <raw>${JSON.stringify(tweet)}</raw>`; +}; + +const BOOKMARKS_URL = `https://x.com/i/api/graphql/xLjCVTqYWz8CGSprLU349w/Bookmarks?features=${encodeURIComponent(JSON.stringify(features))}`; + +const BACKEND_URL = "http://localhost:3000"; + +// This is to prevent going over the rate limit +let lastTwitterFetch = 0; + +const batchImportAll = async (cursor = "") => { + chrome.storage.session.get(["cookie", "csrf", "auth"], (result) => { + if (!result.cookie || !result.csrf || !result.auth) { + console.log("cookie, csrf, or auth is missing"); + return; + } + + const myHeaders = new Headers(); + myHeaders.append("Cookie", result.cookie); + myHeaders.append("X-Csrf-token", result.csrf); + myHeaders.append("Authorization", result.auth); + + const requestOptions: RequestInit = { + method: "GET", + headers: myHeaders, + redirect: "follow", + }; + + const variables = { + count: 100, // Using 100 as the count to prevent rate limiting + cursor: cursor, + includePromotedContent: false, + }; + + // Append cursor if present + const urlWithCursor = cursor + ? `${BOOKMARKS_URL}&variables=${encodeURIComponent(JSON.stringify(variables))}` + : BOOKMARKS_URL; + + fetch(urlWithCursor, requestOptions) + .then((response) => response.json()) + .then((data) => { + const tweets = getAllTweets(data); + + for (const tweet of tweets) { + console.log(tweet); + + const tweetMd = tweetToMd(tweet); + (async () => { + chrome.storage.local.get(["jwt"], ({ jwt }) => { + if (!jwt) { + console.error("No JWT found"); + return; + } + fetch(`${BACKEND_URL}/api/store`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ + pageContent: tweetMd, + url: `https://twitter.com/supermemoryai/status/${tweet.id_str}`, + title: `Tweet by ${tweet.user.name}`, + description: tweet.text.slice(0, 100), + type: "tweet", + }), + }).then((ers) => console.log(ers.status)); + }); + })(); + } + + console.log("tweets", tweets); + + console.log("data", data); + + // Extract next cursor + const instructions = + data.data?.bookmark_timeline_v2?.timeline?.instructions; + const lastInstruction = instructions?.[0].entries.pop(); + + if (lastInstruction?.entryId.startsWith("cursor-bottom-")) { + let nextCursor = lastInstruction?.content?.value; + + if (!nextCursor) { + // Find the nextcursor in the entire instructions array + for (let i = instructions.length - 1; i >= 0; i--) { + if (instructions[i].entryId.startsWith("cursor-bottom-")) { + nextCursor = instructions[i].content.value; + break; + } + } + } + + if (nextCursor) { + batchImportAll(nextCursor); // Recursively call with new cursor + } else { + // No more cursors, run final function + console.log("All bookmarks imported"); + // Run function maybe + } + } else { + // No cursor-bottom-* found, run final function + console.log("All bookmarks imported"); + // Run function maybe + } + }) + .catch((error) => console.error(error)); + }); +}; + +chrome.webRequest.onBeforeSendHeaders.addListener( + (details) => { + if ( + !(details.url.includes("x.com") || details.url.includes("twitter.com")) + ) { + return; + } + const authHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "authorization", + ); + const auth = authHeader ? authHeader.value : ""; + + const cookieHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "cookie", + ); + const cookie = cookieHeader ? cookieHeader.value : ""; + + const csrfHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "x-csrf-token", + ); + const csrf = csrfHeader ? csrfHeader.value : ""; + + if (!auth || !cookie || !csrf) { + console.log("auth, cookie, or csrf is missing"); + return; + } + chrome.storage.session.set({ cookie, csrf, auth }); + chrome.storage.local.get(["twitterBookmarks"], (result) => { + console.log("twitterBookmarks", result.twitterBookmarks); + if (result.twitterBookmarks !== "true") { + console.log("twitterBookmarks is NOT true"); + } else { + if ( + !details.requestHeaders || + details.requestHeaders.length === 0 || + details.requestHeaders === undefined + ) { + return; + } + + // Check cache first + chrome.storage.local.get(["lastFetch", "cachedData"], (result) => { + const now = new Date().getTime(); + if (result.lastFetch && now - result.lastFetch < 30 * 60 * 1000) { + // Cached data is less than 30 minutes old, use it + console.log("Using cached data"); + console.log(result.cachedData); + return; + } + + // No valid cache, proceed to fetch + const authHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "authorization", + ); + const auth = authHeader ? authHeader.value : ""; + + const cookieHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "cookie", + ); + const cookie = cookieHeader ? cookieHeader.value : ""; + + const csrfHeader = details.requestHeaders!.find( + (header) => header.name.toLowerCase() === "x-csrf-token", + ); + const csrf = csrfHeader ? csrfHeader.value : ""; + + if (!auth || !cookie || !csrf) { + console.log("auth, cookie, or csrf is missing"); + return; + } + chrome.storage.session.set({ cookie, csrf, auth }); + + const myHeaders = new Headers(); + myHeaders.append("Cookie", cookie); + myHeaders.append("X-Csrf-token", csrf); + myHeaders.append("Authorization", auth); + + const requestOptions: RequestInit = { + method: "GET", + headers: myHeaders, + redirect: "follow", + }; + + const variables = { + count: 200, + includePromotedContent: false, + }; + + // only fetch once in 1 minute + if (now - lastTwitterFetch < 60 * 1000) { + console.log("Waiting for ratelimits"); + return; + } + + fetch( + `${BOOKMARKS_URL}&variables=${encodeURIComponent(JSON.stringify(variables))}`, + requestOptions, + ) + .then((response) => response.text()) + .then((result) => { + const tweets = getAllTweets(JSON.parse(result)); + + console.log("tweets", tweets); + // Cache the result along with the current timestamp + chrome.storage.local.set({ + lastFetch: new Date().getTime(), + cachedData: tweets, + }); + + lastTwitterFetch = now; + }) + .catch((error) => console.error(error)); + }); + return; + } + }); + }, + { urls: ["*://x.com/*", "*://twitter.com/*"] }, + ["requestHeaders", "extraHeaders"], +); + +const getAllTweets = (rawJson: any): Tweet[] => { + const entries = + rawJson?.data?.bookmark_timeline_v2?.timeline?.instructions[0]?.entries; + + console.log("Entries: ", entries); + + if (!entries) { + console.error("No entries found"); + return []; + } + + const tweets = entries + .map((entry: any) => transformTweetData(entry)) + .filter((tweet: Tweet | null) => tweet !== null) as Tweet[]; + + console.log(tweets); + + return tweets; +}; + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + console.log(request); + if (request.type === "getJwt") { + chrome.storage.local.get(["jwt"], ({ jwt }) => { + sendResponse({ jwt }); + }); + + return true; + } else if (request.type === "urlSave") { + const content = request.content; + const url = request.url; + const title = request.title; + const description = request.description; + const ogImage = request.ogImage; + const favicon = request.favicon; + console.log(request.content, request.url); + + (async () => { + chrome.storage.local.get(["jwt"], ({ jwt }) => { + if (!jwt) { + console.error("No JWT found"); + return; + } + fetch(`${BACKEND_URL}/api/store`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ + pageContent: content, + url, + title, + spaces: request.spaces, + description, + ogImage, + image: favicon, + }), + }).then((ers) => console.log(ers.status)); + }); + })(); + } else if (request.type === "batchImportAll") { + batchImportAll(); + } +}); diff --git a/apps/extension/content/ContentApp.tsx b/apps/extension/content/ContentApp.tsx index ca1782c1..ee1fd6d4 100644 --- a/apps/extension/content/ContentApp.tsx +++ b/apps/extension/content/ContentApp.tsx @@ -1,10 +1,15 @@ import React, { useEffect, useState } from "react"; -import icon from "../public/icon/icon_48.png"; +import { Readability } from "@mozilla/readability"; -export default function ContentApp() { +export default function ContentApp({ token }: { token: string | undefined }) { const [text, setText] = useState(""); const [hover, setHover] = useState(false); + const [loading, setLoading] = useState(false); + + const [isTwitterBookmarksEnabled, setIsTwitterBookmarksEnabled] = + useState(false); + useEffect(() => { const messageListener = (message: any) => { setText(message); @@ -22,18 +27,163 @@ export default function ContentApp() { setHover(false); } }); + + const getUserData = () => { + const NO_JWT = [ + "supermemory.ai", + "beta.supermemory.ai", + "localhost:3000", + ]; + chrome.runtime.sendMessage({ type: "getJwt" }, (response) => { + if (!response.jwt && !NO_JWT.includes(window.location.host)) { + window.location.href = "https://supermemory.ai/signin"; + } + + console.log("jwt", response.jwt); + }); + }; + + getUserData(); + return () => { chrome.runtime.onMessage.removeListener(messageListener); }; }, []); + function sendUrlToAPI(spaces: number[]) { + setLoading(true); + + setTimeout(() => { + setLoading(false); + }, 1500); + + // get the current URL + const url = window.location.href; + + const blacklist: string[] = []; + // check if the URL is blacklisted + if (blacklist.some((blacklisted) => url.includes(blacklisted))) { + console.log("URL is blacklisted"); + return; + } else { + const clone = document.cloneNode(true) as Document; + const article = new Readability(clone).parse(); + + const ogImage = document + .querySelector('meta[property="og:image"]') + ?.getAttribute("content"); + + const favicon = ( + document.querySelector('link[rel="icon"]') as HTMLLinkElement + )?.href; + + console.log("article", article); + chrome.runtime.sendMessage({ + type: "urlSave", + content: article?.textContent, + url, + spaces, + title: article?.title, + description: article?.excerpt, + ogImage: ogImage, + favicon: favicon, + }); + } + } + return ( <div className="flex justify-end items-end min-h-screen w-full"> <button - className={`${hover && "opacity-100 p-2 rounded-l-2xl"} transition bg-slate-700 border border-white/30 opacity-0 size-4 h-[30vh] absolute flex bottom-12 items-center`} + onClick={() => sendUrlToAPI([])} + className={`${hover && "opacity-100"} hover:bg-black p-2 rounded-l-2xl transition bg-gray-900 border border-white/20 opacity-0 size-4 h-[30vh] absolute flex bottom-20 items-center text-lg`} > - <img width={24} height={24} src={icon} alt="Save to supermemory.ai" /> + {loading ? ( + <div className="text-sm">Saving...</div> + ) : ( + <svg + className="size-8" + width={24} + height={24} + viewBox="0 0 42 42" + fill="currentColor" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M19.0357 8C20.5531 8 21 9.27461 21 10.8438V16.3281H23.5536V14.2212C23.5536 13.1976 23.9468 12.216 24.6467 11.4922L25.0529 11.0721C24.9729 10.8772 24.9286 10.6627 24.9286 10.4375C24.9286 9.54004 25.6321 8.8125 26.5 8.8125C27.3679 8.8125 28.0714 9.54004 28.0714 10.4375C28.0714 11.335 27.3679 12.0625 26.5 12.0625C26.2822 12.0625 26.0748 12.0167 25.8863 11.9339L25.4801 12.354C25.0012 12.8492 24.7321 13.5209 24.7321 14.2212V16.3281H28.9714C29.2045 15.7326 29.7691 15.3125 30.4286 15.3125C31.2964 15.3125 32 16.04 32 16.9375C32 17.835 31.2964 18.5625 30.4286 18.5625C29.7691 18.5625 29.2045 18.1424 28.9714 17.5469H21V21.2031H25.0428C25.2759 20.6076 25.8405 20.1875 26.5 20.1875C27.3679 20.1875 28.0714 20.915 28.0714 21.8125C28.0714 22.71 27.3679 23.4375 26.5 23.4375C25.8405 23.4375 25.2759 23.0174 25.0428 22.4219H21V26.0781H24.4125C25.4023 26.0781 26.3516 26.4847 27.0515 27.2085L29.0292 29.2536C29.2177 29.1708 29.4251 29.125 29.6429 29.125C30.5107 29.125 31.2143 29.8525 31.2143 30.75C31.2143 31.6475 30.5107 32.375 29.6429 32.375C28.775 32.375 28.0714 31.6475 28.0714 30.75C28.0714 30.5248 28.1157 30.3103 28.1958 30.1154L26.2181 28.0703C25.7392 27.5751 25.0897 27.2969 24.4125 27.2969H21V31.1562C21 32.7254 20.5531 34 19.0357 34C17.6165 34 16.4478 32.8879 16.3004 31.4559C16.0451 31.527 15.775 31.5625 15.5 31.5625C13.7665 31.5625 12.3571 30.1051 12.3571 28.3125C12.3571 27.9367 12.421 27.5711 12.5339 27.2359C11.0509 26.657 10 25.1742 10 23.4375C10 21.8176 10.9183 20.416 12.2491 19.766C11.8219 19.2125 11.5714 18.5117 11.5714 17.75C11.5714 16.191 12.6321 14.891 14.0464 14.5711C13.9679 14.2918 13.9286 13.9922 13.9286 13.6875C13.9286 12.1691 14.9402 10.8895 16.3004 10.534C16.4478 9.11211 17.6165 8 19.0357 8Z" + fill="#888B94" + /> + </svg> + )} </button> + + <button + onClick={() => { + chrome.runtime.sendMessage({ type: "batchImportAll" }); + }} + className={`${hover && "opacity-100"} p-2 rounded-l-2xl transition bg-slate-700 border border-white/20 opacity-0 size-4 h-[30vh] absolute flex bottom-6 items-center`} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + fill="#888B94" + className="size-4" + width={24} + height={24} + > + <path + fillRule="evenodd" + d="M10 2c-1.716 0-3.408.106-5.07.31C3.806 2.45 3 3.414 3 4.517V17.25a.75.75 0 0 0 1.075.676L10 15.082l5.925 2.844A.75.75 0 0 0 17 17.25V4.517c0-1.103-.806-2.068-1.93-2.207A41.403 41.403 0 0 0 10 2Z" + clipRule="evenodd" + /> + </svg> + </button> + + {(window.location.href === "https://twitter.com" || + window.location.href === "https://x.com") && + (isTwitterBookmarksEnabled ? ( + <button + className={`${hover && "opacity-100"} p-2 rounded-l-2xl transition bg-slate-700 border border-white/20 opacity-0 size-4 h-[30vh] absolute flex bottom-6 items-center`} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + fill="#888B94" + className="size-4" + width={24} + height={24} + > + <path + fillRule="evenodd" + d="M10 2c-1.716 0-3.408.106-5.07.31C3.806 2.45 3 3.414 3 4.517V17.25a.75.75 0 0 0 1.075.676L10 15.082l5.925 2.844A.75.75 0 0 0 17 17.25V4.517c0-1.103-.806-2.068-1.93-2.207A41.403 41.403 0 0 0 10 2Z" + clipRule="evenodd" + /> + </svg> + </button> + ) : ( + <> + <button + onClick={() => { + chrome.runtime.sendMessage({ type: "batchImportAll" }); + }} + className={`${hover && "opacity-100"} p-2 rounded-l-2xl transition bg-slate-700 border border-white/20 opacity-0 size-4 h-[30vh] absolute flex bottom-6 items-center`} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + fill="#888B94" + className="size-4" + width={24} + height={24} + > + <path + fillRule="evenodd" + d="M10 2c-1.716 0-3.408.106-5.07.31C3.806 2.45 3 3.414 3 4.517V17.25a.75.75 0 0 0 1.075.676L10 15.082l5.925 2.844A.75.75 0 0 0 17 17.25V4.517c0-1.103-.806-2.068-1.93-2.207A41.403 41.403 0 0 0 10 2Z" + clipRule="evenodd" + /> + </svg> + </button> + </> + ))} </div> ); } diff --git a/apps/extension/content/content.tsx b/apps/extension/content/content.tsx index df66cab0..bacb79c0 100644 --- a/apps/extension/content/content.tsx +++ b/apps/extension/content/content.tsx @@ -31,7 +31,7 @@ function initial() { // Create a new div element to host the shadow root. // Styles for this div is in `content/content.css` const hostDiv = document.createElement("div"); - hostDiv.id = "extension-host"; + hostDiv.id = "supermemory-extension-host"; document.body.appendChild(hostDiv); // Attach the shadow DOM to the hostDiv and set the mode to @@ -41,11 +41,40 @@ function initial() { // Create a new div element that will be the root container for the React app const rootDiv = document.createElement("div"); - rootDiv.id = "extension-root"; + rootDiv.id = "supermemory-extension-root"; shadowRoot.appendChild(rootDiv); appendTailwindStyleLink(shadowRoot); const root = ReactDOM.createRoot(rootDiv); - root.render(<ContentApp />); + + const jwt = chrome.storage.local.get("jwt").then((data) => { + return data.jwt; + }) as Promise<string | undefined>; + + jwt.then((token) => root.render(<ContentApp token={token} />)); } + +window.addEventListener("message", (event) => { + if (event.source !== window) { + return; + } + const jwt = event.data.token; + + if (jwt) { + if ( + !( + window.location.hostname === "localhost" || + window.location.hostname === "supermemory.ai" || + window.location.hostname === "beta.supermemory.ai" + ) + ) { + console.log( + "JWT is only allowed to be used on localhost or anycontext.dhr.wtf", + ); + return; + } + + chrome.storage.local.set({ jwt }, () => {}); + } +}); diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json index 72e26344..b898cb0b 100644 --- a/apps/extension/manifest.json +++ b/apps/extension/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "version": "1.0", - "name": "content-shadow-dom-tailwind", - "description": "An extension template using React and TypeScript. This template includes a content script using Tailwind.css. To see it in action, visit https://docs.extensioncreate.com.", + "name": "supermemory", + "description": "An extension for https://supermemory.ai - an AI hub for all your bookmarks.", "background": { "service_worker": "./background.ts" }, @@ -21,5 +21,7 @@ "resources": ["public/*"], "matches": ["http://*/*", "https://*/*"] } - ] + ], + "permissions": ["webRequest", "storage"], + "host_permissions": ["https://x.com/*", "https://twitter.com/*"] } diff --git a/apps/web/app/(dash)/home/homeVariants.ts b/apps/web/app/(dash)/home/homeVariants.ts index ec24e22b..cc533fc4 100644 --- a/apps/web/app/(dash)/home/homeVariants.ts +++ b/apps/web/app/(dash)/home/homeVariants.ts @@ -44,7 +44,7 @@ export const variants = [ }, { type: "highlighted", - content: " digital treasures.", + content: " digital treasure.", }, ], ]; diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx index 4f7e4af6..f648923c 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import QueryInput from "./queryinput"; import { homeSearchParamsCache } from "@/lib/searchParams"; -import { getSpaces } from "@/app/actions/fetchers"; +import { getSessionAuthToken, getSpaces } from "@/app/actions/fetchers"; import { useRouter } from "next/navigation"; import { createChatThread, linkTelegramToUser } from "@/app/actions/doers"; import { toast } from "sonner"; @@ -67,6 +67,11 @@ function Page({ }); setShowVariant(Math.floor(Math.random() * variants.length)); + + getSessionAuthToken().then((token) => { + if (typeof window === "undefined") return; + window.postMessage({ token: token.data }, "*"); + }); }, []); return ( diff --git a/apps/web/app/(dash)/memories/page.tsx b/apps/web/app/(dash)/memories/page.tsx index cb3825e7..380a653e 100644 --- a/apps/web/app/(dash)/memories/page.tsx +++ b/apps/web/app/(dash)/memories/page.tsx @@ -1,18 +1,19 @@ "use client"; import { getAllUserMemoriesAndSpaces } from "@/app/actions/fetchers"; -import { Space } from "@/app/actions/types"; import { Content, StoredSpace } from "@/server/db/schema"; -import { NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons"; +import { MemoriesIcon, NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons"; +import { NotebookIcon, PaperclipIcon } from "lucide-react"; import Image from "next/image"; +import Link from "next/link"; import React, { useEffect, useMemo, useState } from "react"; import Masonry from "react-layout-masonry"; +import { getRawTweet } from "@repo/shared-types/utils"; +import { MyTweet } from "./render-tweet"; function Page() { const [filter, setFilter] = useState("All"); - const [search, setSearch] = useState(""); - const [memoriesAndSpaces, setMemoriesAndSpaces] = useState<{ memories: Content[]; spaces: StoredSpace[]; @@ -55,9 +56,13 @@ function Page() { return ( item.item === "memory" && (item.data as Content).type === "note" ); + if (filter === "Tweet") + return ( + item.item === "memory" && (item.data as Content).type === "tweet" + ); return false; }) - .sort((a, b) => a.date - b.date); + .sort((a, b) => b.date - a.date); }, [memoriesAndSpaces.memories, memoriesAndSpaces.spaces, filter]); useEffect(() => { @@ -69,7 +74,7 @@ function Page() { }, []); return ( - <div className="max-w-3xl min-w-3xl py-36 h-full flex mx-auto w-full flex-col gap-6"> + <div className="px-2 md:px-32 py-36 h-full flex mx-auto w-full flex-col gap-6"> <h2 className="text-white w-full font-medium text-2xl text-left"> My Memories </h2> @@ -79,7 +84,7 @@ function Page() { <Masonry className="mt-6" columns={{ 640: 1, 768: 2, 1024: 3, 1280: 4 }} - gap={4} + gap={12} > {sortedItems.map((item) => { if (item.item === "memory") { @@ -88,12 +93,15 @@ function Page() { type={(item.data as Content).type ?? "note"} content={(item.data as Content).content} title={(item.data as Content).title ?? "Untitled"} - url={(item.data as Content).url} + url={ + (item.data as Content).baseUrl ?? (item.data as Content).url + } image={ (item.data as Content).ogImage ?? (item.data as Content).image ?? "/placeholder-image.svg" // TODO: add this placeholder } + description={(item.data as Content).description ?? ""} /> ); } @@ -123,47 +131,73 @@ function TabComponent({ }) { // TODO: Display the space name and desription which is the number of elemenet in the space return ( - <div className="flex items-center my-6"> - <div> - <div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md"> - {title.slice(0, 2).toUpperCase()} - </div> - </div> - <div className="grow px-4"> - <div className="text-lg text-[#fff]">{title}</div> - <div>{description}</div> + <div className="flex flex-col gap-4 bg-[#161f2a]/30 backdrop-blur-md border-2 border-border w-full rounded-xl p-4 -z-10"> + <div className="flex items-center gap-2 text-xs"> + <Image alt="Spaces icon" src={MemoriesIcon} className="size-3" /> Space </div> - <div> - <Image src={NextIcon} alt="Search icon" /> + <div className="flex items-center"> + <div> + <div className="h-12 w-12 flex justify-center items-center rounded-md"> + {title.slice(0, 2).toUpperCase()} + </div> + </div> + <div className="grow px-4"> + <div className="text-lg text-[#fff] line-clamp-2">{title}</div> + <div>{description}</div> + </div> + <div> + <Image src={NextIcon} alt="Search icon" /> + </div> </div> </div> ); } -function LinkComponent({ +export function LinkComponent({ type, content, title, url, image, + description, }: { type: string; content: string; title: string; url: string; image?: string; + description: string; }) { // TODO: DISPLAY THE ITEM BASED ON `type` being note or page return ( - <div className="w-full"> - <div className="text-lg text-[#fff]">{title}</div> - <div>{content}</div> - <div>{url}</div> - </div> + <Link + href={url} + className={`bg-secondary border-2 border-border w-full rounded-xl ${type === "tweet" ? "" : "p-4"} hover:scale-105 transition duration-200`} + > + {type === "page" ? ( + <> + <div className="flex items-center gap-2 text-xs"> + <PaperclipIcon className="w-3 h-3" /> Page + </div> + <div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div> + <div>{url}</div> + </> + ) : type === "note" ? ( + <> + <div className="flex items-center gap-2 text-xs"> + <NotebookIcon className="w-3 h-3" /> Note + </div> + <div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div> + <div className="line-clamp-3">{content.replace(title, "")}</div> + </> + ) : type === "tweet" ? ( + <MyTweet tweet={JSON.parse(getRawTweet(content) ?? "{}")} /> + ) : null} + </Link> ); } -const FilterMethods = ["All", "Spaces", "Pages", "Notes"]; +const FilterMethods = ["All", "Spaces", "Pages", "Notes", "Tweet"]; function Filters({ setFilter, filter, @@ -175,12 +209,12 @@ function Filters({ <div className="flex gap-4"> {FilterMethods.map((i) => { return ( - <div + <button onClick={() => setFilter(i)} className={`transition px-6 py-2 rounded-xl bg-border ${i === filter ? " text-[#369DFD]" : "text-[#B3BCC5] bg-secondary hover:bg-secondary hover:text-[#76a3cc]"}`} > {i} - </div> + </button> ); })} </div> diff --git a/apps/web/app/(dash)/memories/render-tweet.tsx b/apps/web/app/(dash)/memories/render-tweet.tsx new file mode 100644 index 00000000..3e1e3746 --- /dev/null +++ b/apps/web/app/(dash)/memories/render-tweet.tsx @@ -0,0 +1,33 @@ +import type { Tweet } from "react-tweet/api"; +import { + type TwitterComponents, + TweetContainer, + TweetHeader, + TweetInReplyTo, + TweetBody, + TweetMedia, + TweetInfo, + QuotedTweet, + enrichTweet, +} from "react-tweet"; + +type Props = { + tweet: Tweet; + components?: TwitterComponents; +}; + +export const MyTweet = ({ tweet: t, components }: Props) => { + const tweet = enrichTweet(t); + return ( + <TweetContainer className="bg-transparent !m-0 !p-0 !z-0"> + <TweetHeader tweet={tweet} components={components} /> + {tweet.in_reply_to_status_id_str && <TweetInReplyTo tweet={tweet} />} + <TweetBody tweet={tweet} /> + {tweet.mediaDetails?.length ? ( + <TweetMedia tweet={tweet} components={components} /> + ) : null} + {tweet.quoted_tweet && <QuotedTweet tweet={tweet.quoted_tweet} />} + <TweetInfo tweet={tweet} /> + </TweetContainer> + ); +}; diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx index 340c7e16..a8ba4172 100644 --- a/apps/web/app/(dash)/menu.tsx +++ b/apps/web/app/(dash)/menu.tsx @@ -65,12 +65,6 @@ function Menu() { disabled: false, }, { - icon: ExploreIcon, - text: "Explore", - url: "/explore", - disabled: true, - }, - { icon: CanvasIcon, text: "Canvas", url: "/canvas", @@ -86,7 +80,9 @@ function Menu() { return "none"; } - if (content.match(/https?:\/\/[\w\.]+\/[\w]+\/[\w]+\/[\d]+/)) { + if ( + content.match(/https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/) + ) { return "tweet"; } else if (content.match(/https?:\/\/[\w\.]+/)) { return "page"; @@ -136,8 +132,8 @@ function Menu() { <> {/* 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"> - <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="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`} @@ -180,7 +176,7 @@ function Menu() { </div> </div> - <DialogContent className="sm:max-w-[425px] rounded-2xl bg-[#161f2a]/30 backdrop-blur-md"> + <DialogContent className="sm:max-w-[425px] rounded-2xl bg-[#161f2a]/40 backdrop-blur-md"> <form action={async (e: FormData) => { const content = e.get("content")?.toString(); @@ -202,7 +198,7 @@ function Menu() { Resource (URL or content) </Label> <Textarea - className="bg-[#161f2a] focus-visible:ring-0 border-none focus-visible:ring-offset-0 mt-2" + className="bg-[#2F353C] 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." @@ -287,7 +283,7 @@ function Menu() { } }} placeholder="Save or create space by typing." - className="bg-[#161f2a] h-min rounded-md mt-2 mb-4" + className="bg-[#2F353C] h-min rounded-md mt-2 mb-4" /> <div> @@ -296,6 +292,7 @@ function Menu() { {selectedSpaces.map((x, idx) => ( <button key={x} + type="button" onClick={() => setSelectedSpaces((prev) => prev.filter((y) => y !== x), diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts index 91a40f2c..69e57687 100644 --- a/apps/web/app/actions/doers.ts +++ b/apps/web/app/actions/doers.ts @@ -6,6 +6,7 @@ import { chatHistory, chatThreads, contentToSpace, + sessions, space, storedContent, users, @@ -17,9 +18,11 @@ import { getMetaData } from "@/lib/get-metadata"; import { and, eq, inArray, sql } from "drizzle-orm"; import { LIMITS } from "@/lib/constants"; import { z } from "zod"; -import { ChatHistory } from "@repo/shared-types"; +import { AddFromAPIType, ChatHistory } from "@repo/shared-types"; import { decipher } from "@/server/encrypt"; import { redirect } from "next/navigation"; +import { ensureAuth } from "../api/ensureAuth"; +import { tweetToMd } from "@repo/shared-types/utils"; export const createSpace = async ( input: string | FormData, @@ -61,7 +64,7 @@ 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]+/)) { + if (content.match(/https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/)) { return "tweet"; } else if (content.match(/https?:\/\/[\w\.]+/)) { return "page"; @@ -72,19 +75,23 @@ const typeDecider = (content: string) => { } }; -export const limit = async (userId: string, type = "page") => { - const count = await db +export const limit = async ( + userId: string, + type = "page", + items: number = 1, +) => { + const countResult = 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; - } + const currentCount = countResult[0]?.count || 0; + const totalLimit = LIMITS[type as keyof typeof LIMITS]; + const remainingLimit = totalLimit - currentCount; - return true; + return items <= remainingLimit; }; const getTweetData = async (tweetID: string) => { @@ -133,6 +140,8 @@ export const createMemory = async (input: { }; } + let noteId = 0; + if (type === "page") { const response = await fetch("https://md.dhr.wtf/?url=" + input.content, { headers: { @@ -151,7 +160,7 @@ export const createMemory = async (input: { } } else if (type === "tweet") { const tweet = await getTweetData(input.content.split("/").pop() as string); - pageContent = JSON.stringify(tweet); + pageContent = tweetToMd(tweet); metadata = { baseUrl: input.content, description: tweet.text, @@ -160,7 +169,7 @@ export const createMemory = async (input: { }; } else if (type === "note") { pageContent = input.content; - const noteId = new Date().getTime(); + noteId = new Date().getTime(); metadata = { baseUrl: `https://supermemory.ai/note/${noteId}`, description: `Note created at ${new Date().toLocaleString()}`, @@ -212,23 +221,49 @@ export const createMemory = async (input: { }; } + let contentId: number | undefined; + // 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 }); + try { + 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 }); + + contentId = insertResponse[0]?.id; + } catch (e) { + const error = e as Error; + console.log("Error: ", error.message); + + if ( + error.message.includes( + "D1_ERROR: UNIQUE constraint failed: storedContent.baseUrl", + ) + ) { + return { + success: false, + data: 0, + error: "Content already exists", + }; + } + + return { + success: false, + data: 0, + error: "Failed to save to database with error: " + error.message, + }; + } - const contentId = insertResponse[0]?.id; if (!contentId) { return { success: false, @@ -277,6 +312,9 @@ export const createMemory = async (input: { }; } + revalidatePath("/home"); + revalidatePath("/memories"); + return { success: true, data: 1, diff --git a/apps/web/app/actions/fetchers.ts b/apps/web/app/actions/fetchers.ts index 558ef3de..0035c217 100644 --- a/apps/web/app/actions/fetchers.ts +++ b/apps/web/app/actions/fetchers.ts @@ -18,6 +18,7 @@ import { ChatHistory, SourceZod } from "@repo/shared-types"; import { ChatHistory as ChatHistoryType } from "../../server/db/schema"; import { z } from "zod"; import { redirect } from "next/navigation"; +import { cookies, headers } from "next/headers"; export const getSpaces = async (): ServerActionReturnType<StoredSpace[]> => { const data = await auth(); @@ -186,3 +187,16 @@ export const getChatHistory = async (): ServerActionReturnType< }; } }; + +export const getSessionAuthToken = async (): ServerActionReturnType<string> => { + const token = + cookies().get("next-auth.session-token")?.value ?? + cookies().get("__Secure-authjs.session-token")?.value ?? + cookies().get("authjs.session-token")?.value ?? + headers().get("Authorization")?.replace("Bearer ", ""); + + return { + success: true, + data: token, + }; +}; diff --git a/apps/web/app/api/store/route.ts b/apps/web/app/api/store/route.ts new file mode 100644 index 00000000..8a126a56 --- /dev/null +++ b/apps/web/app/api/store/route.ts @@ -0,0 +1,202 @@ +import { type NextRequest } from "next/server"; +import { addFromAPIType, AddFromAPIType } from "@repo/shared-types"; +import { ensureAuth } from "../ensureAuth"; +import { z } from "zod"; +import { db } from "@/server/db"; +import { contentToSpace, space, storedContent } from "@/server/db/schema"; +import { and, eq, inArray } from "drizzle-orm"; +import { LIMITS } from "@/lib/constants"; +import { limit } from "@/app/actions/doers"; + +export const runtime = "edge"; + +const createMemoryFromAPI = async (input: { + data: AddFromAPIType; + userId: string; +}) => { + if (!(await limit(input.userId, input.data.type))) { + return { + success: false, + data: 0, + error: `You have exceeded the limit of ${LIMITS[input.data.type as keyof typeof LIMITS]} ${input.data.type}s.`, + }; + } + + const vectorSaveResponse = await fetch( + `${process.env.BACKEND_BASE_URL}/api/add`, + { + method: "POST", + body: JSON.stringify({ + pageContent: input.data.pageContent, + title: input.data.title, + description: input.data.description, + url: input.data.url, + spaces: input.data.spaces, + user: input.userId, + type: input.data.type, + }), + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY, + }, + }, + ); + + if (!vectorSaveResponse.ok) { + const errorData = await vectorSaveResponse.text(); + console.error(errorData); + return { + success: false, + data: 0, + error: `Failed to save to vector store. Backend returned error: ${errorData}`, + }; + } + + let contentId: number | undefined; + + // Insert into database + try { + const insertResponse = await db + .insert(storedContent) + .values({ + content: input.data.pageContent, + title: input.data.title, + description: input.data.description, + url: input.data.url, + baseUrl: input.data.url, + image: input.data.image, + savedAt: new Date(), + userId: input.userId, + type: input.data.type, + }) + .returning({ id: storedContent.id }); + + contentId = insertResponse[0]?.id; + } catch (e) { + const error = e as Error; + console.log("Error: ", error.message); + + if ( + error.message.includes( + "D1_ERROR: UNIQUE constraint failed: storedContent.baseUrl", + ) + ) { + return { + success: false, + data: 0, + error: "Content already exists", + }; + } + + return { + success: false, + data: 0, + error: "Failed to save to database with error: " + error.message, + }; + } + + if (!contentId) { + return { + success: false, + data: 0, + error: "Failed to save to database", + }; + } + + if (input.data.spaces.length > 0) { + // Adding the many-to-many relationship between content and spaces + const spaceData = await db + .select() + .from(space) + .where( + and( + inArray( + space.id, + input.data.spaces.map((s) => parseInt(s)), + ), + eq(space.user, input.userId), + ), + ) + .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}`, + }; + } +}; + +export async function POST(req: NextRequest) { + const session = await ensureAuth(req); + + if (!session) { + return new Response("Unauthorized", { status: 401 }); + } + + if (!process.env.BACKEND_SECURITY_KEY) { + return new Response("Missing BACKEND_SECURITY_KEY", { status: 500 }); + } + + const body = await req.json(); + + const validated = addFromAPIType.safeParse(body); + + if (!validated.success) { + return new Response( + JSON.stringify({ + message: "Invalid request", + error: validated.error, + }), + { status: 400 }, + ); + } + + const data = validated.data; + + const result = await createMemoryFromAPI({ + data, + userId: session.user.id, + }); + + if (!result.success) { + return new Response( + JSON.stringify({ + message: "Failed to save document", + error: result.error, + }), + { status: 500 }, + ); + } + + return new Response("ok", { status: 200 }); +} diff --git a/apps/web/migrations/0001_index_telegram_id.sql b/apps/web/migrations/0001_index_telegram_id.sql index 4b3aa44a..bff9d007 100644 --- a/apps/web/migrations/0001_index_telegram_id.sql +++ b/apps/web/migrations/0001_index_telegram_id.sql @@ -3,7 +3,7 @@ ALTER TABLE `space` ADD COLUMN `numItems` integer NOT NULL DEFAULT 0; --> statement-breakpoint ALTER TABLE `user` ADD COLUMN `telegramId` text; -CREATE INDEX `storedContent_user_idx` ON `storedContent` (`user`);--> statement-breakpoint CREATE INDEX `users_email_idx` ON `user` (`email`);--> statement-breakpoint CREATE INDEX `users_telegram_idx` ON `user` (`telegramId`);--> statement-breakpoint -CREATE INDEX `users_id_idx` ON `user` (`id`);
\ No newline at end of file +CREATE INDEX `users_id_idx` ON `user` (`id`); +CREATE UNIQUE INDEX `storedContent_baseUrl_unique` ON `storedContent` (`baseUrl`);
\ No newline at end of file diff --git a/apps/web/migrations/0000_classy_speed_demon.sql b/apps/web/migrations/000_setup.sql index a4855ec9..a4855ec9 100644 --- a/apps/web/migrations/0000_classy_speed_demon.sql +++ b/apps/web/migrations/000_setup.sql diff --git a/apps/web/migrations/meta/0001_snapshot.json b/apps/web/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..6d8e6ebc --- /dev/null +++ b/apps/web/migrations/meta/0001_snapshot.json @@ -0,0 +1,698 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3f58a63e-3416-40c0-b8ef-82aa87eeed77", + "prevId": "6988522d-8117-484d-b52a-94c0fbd75140", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": ["provider", "providerAccountId"], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {} + }, + "authenticator": { + "name": "authenticator", + "columns": { + "credentialID": { + "name": "credentialID", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentialPublicKey": { + "name": "credentialPublicKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentialDeviceType": { + "name": "credentialDeviceType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentialBackedUp": { + "name": "credentialBackedUp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "authenticator_credentialID_unique": { + "name": "authenticator_credentialID_unique", + "columns": ["credentialID"], + "isUnique": true + } + }, + "foreignKeys": { + "authenticator_userId_user_id_fk": { + "name": "authenticator_userId_user_id_fk", + "tableFrom": "authenticator", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "authenticator_userId_credentialID_pk": { + "columns": ["credentialID", "userId"], + "name": "authenticator_userId_credentialID_pk" + } + }, + "uniqueConstraints": {} + }, + "chatHistory": { + "name": "chatHistory", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "threadId": { + "name": "threadId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answerParts": { + "name": "answerParts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "answerSources": { + "name": "answerSources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "answerJustification": { + "name": "answerJustification", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "chatHistory_thread_idx": { + "name": "chatHistory_thread_idx", + "columns": ["threadId"], + "isUnique": false + } + }, + "foreignKeys": { + "chatHistory_threadId_chatThread_id_fk": { + "name": "chatHistory_threadId_chatThread_id_fk", + "tableFrom": "chatHistory", + "tableTo": "chatThread", + "columnsFrom": ["threadId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "chatThread": { + "name": "chatThread", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "firstMessage": { + "name": "firstMessage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chatThread_user_idx": { + "name": "chatThread_user_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "chatThread_userId_user_id_fk": { + "name": "chatThread_userId_user_id_fk", + "tableFrom": "chatThread", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "contentToSpace": { + "name": "contentToSpace", + "columns": { + "contentId": { + "name": "contentId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "contentToSpace_contentId_storedContent_id_fk": { + "name": "contentToSpace_contentId_storedContent_id_fk", + "tableFrom": "contentToSpace", + "tableTo": "storedContent", + "columnsFrom": ["contentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contentToSpace_spaceId_space_id_fk": { + "name": "contentToSpace_spaceId_space_id_fk", + "tableFrom": "contentToSpace", + "tableTo": "space", + "columnsFrom": ["spaceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "contentToSpace_contentId_spaceId_pk": { + "columns": ["contentId", "spaceId"], + "name": "contentToSpace_contentId_spaceId_pk" + } + }, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "space": { + "name": "space", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "user": { + "name": "user", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "numItems": { + "name": "numItems", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "space_name_unique": { + "name": "space_name_unique", + "columns": ["name"], + "isUnique": true + }, + "spaces_name_idx": { + "name": "spaces_name_idx", + "columns": ["name"], + "isUnique": false + }, + "spaces_user_idx": { + "name": "spaces_user_idx", + "columns": ["user"], + "isUnique": false + } + }, + "foreignKeys": { + "space_user_user_id_fk": { + "name": "space_user_user_id_fk", + "tableFrom": "space", + "tableTo": "user", + "columnsFrom": ["user"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "storedContent": { + "name": "storedContent", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "savedAt": { + "name": "savedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "baseUrl": { + "name": "baseUrl", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ogImage": { + "name": "ogImage", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'page'" + }, + "image": { + "name": "image", + "type": "text(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "storedContent_baseUrl_unique": { + "name": "storedContent_baseUrl_unique", + "columns": ["baseUrl"], + "isUnique": true + }, + "storedContent_url_idx": { + "name": "storedContent_url_idx", + "columns": ["url"], + "isUnique": false + }, + "storedContent_savedAt_idx": { + "name": "storedContent_savedAt_idx", + "columns": ["savedAt"], + "isUnique": false + }, + "storedContent_title_idx": { + "name": "storedContent_title_idx", + "columns": ["title"], + "isUnique": false + }, + "storedContent_user_idx": { + "name": "storedContent_user_idx", + "columns": ["user"], + "isUnique": false + } + }, + "foreignKeys": { + "storedContent_user_user_id_fk": { + "name": "storedContent_user_user_id_fk", + "tableFrom": "storedContent", + "tableTo": "user", + "columnsFrom": ["user"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telegramId": { + "name": "telegramId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": ["email"], + "isUnique": false + }, + "users_telegram_idx": { + "name": "users_telegram_idx", + "columns": ["telegramId"], + "isUnique": false + }, + "users_id_idx": { + "name": "users_id_idx", + "columns": ["id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": ["identifier", "token"], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json index 552ae12f..3c6b0ed8 100644 --- a/apps/web/migrations/meta/_journal.json +++ b/apps/web/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1719671614406, "tag": "0000_classy_speed_demon", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1719772835765, + "tag": "0001_chubby_vulture", + "breakpoints": true } ] } diff --git a/apps/web/server/db/schema.ts b/apps/web/server/db/schema.ts index e1b94cf3..26a4f22d 100644 --- a/apps/web/server/db/schema.ts +++ b/apps/web/server/db/schema.ts @@ -110,7 +110,7 @@ export const storedContent = createTable( description: text("description", { length: 255 }), url: text("url").notNull(), savedAt: int("savedAt", { mode: "timestamp" }).notNull(), - baseUrl: text("baseUrl", { length: 255 }), + baseUrl: text("baseUrl", { length: 255 }).unique(), ogImage: text("ogImage", { length: 255 }), type: text("type").default("page"), image: text("image", { length: 255 }), diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 34e0a9b0..63996482 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -17,7 +17,8 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - "../../packages/ui/" + "../../packages/ui/", + "../../packages/shared-types/utils.ts" ], "exclude": ["node_modules/"] } diff --git a/package.json b/package.json index 8bd208a2..8d1e73af 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,12 @@ "@cloudflare/next-on-pages": "1", "@cloudflare/workers-types": "^4.20240614.0", "@repo/eslint-config": "*", + "@repo/shared-types": "*", "@repo/tailwind-config": "*", "@repo/typescript-config": "*", - "@repo/shared-types": "*", "@repo/ui": "*", "@tailwindcss/typography": "^0.5.13", + "@types/turndown": "^5.0.4", "autoprefixer": "^10.4.19", "drizzle-kit": "0.21.2", "eslint-plugin-next-on-pages": "^1.11.3", @@ -54,6 +55,7 @@ "@iarna/toml": "^2.2.5", "@langchain/cloudflare": "^0.0.6", "@million/lint": "^1.0.0-rc.18", + "@mozilla/readability": "^0.5.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", @@ -97,6 +99,7 @@ "sonner": "^1.5.0", "tailwind-scrollbar": "^3.1.0", "tldraw": "^2.1.4", + "turndown": "^7.2.0", "uploadthing": "^6.10.4", "vaul": "^0.9.1", "zod": "^3.23.8" diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 9d5caa40..bcb5a7a9 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -32,6 +32,19 @@ export const ModelCompatibleChatHistoryZod = z.array( }), ); +export const addFromAPIType = z.object({ + pageContent: z.string(), + url: z.string(), + type: z.string().default("page"), + title: z.string().optional().default("Untitled"), + description: z.string().optional().default(""), + ogImage: z.string().optional(), + image: z.string().optional(), + spaces: z.array(z.string()).optional().default([]), +}); + +export type AddFromAPIType = z.infer<typeof addFromAPIType>; + export type ModelCompatibleChatHistory = z.infer< typeof ModelCompatibleChatHistoryZod >; diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json index 8b9e0110..18e6049e 100644 --- a/packages/shared-types/package.json +++ b/packages/shared-types/package.json @@ -1,5 +1,3 @@ { - "name": "@repo/shared-types", - "version": "0.0.0", - "private": true + "name": "@repo/shared-types" } diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json index f0c129a6..f44d886b 100644 --- a/packages/shared-types/tsconfig.json +++ b/packages/shared-types/tsconfig.json @@ -1,7 +1,7 @@ { - "extends": "@repo/typescript-config/react-library.json", + "extends": "@repo/typescript-config/nextjs.json", + "exclude": ["node_modules", "dist"], "compilerOptions": { "outDir": "dist" - }, - "exclude": ["node_modules", "dist"] + } } diff --git a/packages/typescript-config/react-library.json b/packages/typescript-config/react-library.json index 44924d9e..28a6d365 100644 --- a/packages/typescript-config/react-library.json +++ b/packages/typescript-config/react-library.json @@ -1,5 +1,5 @@ { - "$schema": "https://json.schemastore.org/tsconfig", + "$schema": "https://json.schemastore.org/base", "display": "React Library", "extends": "./base.json", "compilerOptions": { diff --git a/packages/ui/shadcn/combobox.tsx b/packages/ui/shadcn/combobox.tsx index 0caa4a45..ad70047f 100644 --- a/packages/ui/shadcn/combobox.tsx +++ b/packages/ui/shadcn/combobox.tsx @@ -53,7 +53,11 @@ const ComboboxWithCreate: React.FC<ComboboxWithCreateProps> = ({ /> <CommandList className="z-10 translate-y-12 translate-x-5 opacity-0 absolute group-focus-within:opacity-100 bg-secondary p-2 rounded-b-xl max-w-64"> <CommandEmpty> - <Button onClick={async () => onSubmit(inputValue)} variant="link"> + <Button + type="button" + onClick={async () => onSubmit(inputValue)} + variant="link" + > {createNewMessage} "{inputValue}" </Button> <p>Start by creating a space and adding content to it</p> |