diff options
Diffstat (limited to 'apps/web/app')
76 files changed, 4306 insertions, 502 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 2f913a75..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"; @@ -9,26 +9,22 @@ export const runtime = "edge"; async function Signin() { return ( <div className="flex items-center justify-between min-h-screen"> - <div className="relative w-1/2 flex items-center min-h-screen bg-secondary p-8"> + <div className="relative w-full lg:w-1/2 flex items-center justify-center lg:justify-start min-h-screen bg-secondary p-8"> <div className="absolute top-0 left-0 p-8 text-white inline-flex gap-2 items-center"> - <Image - src={Logo} - alt="SuperMemory logo" - className="hover:brightness-125 duration-200" - /> + <Image src={Logo} alt="SuperMemory logo" className="brightness-100" /> <span className="text-xl">SuperMemory.ai</span> </div> <div> <h1 className="text-5xl text-foreground mb-8"> - Hello, <span className="text-white">human</span>{" "} + Hello, <span className="text-white">human</span> </h1> <p className="text-white mb-8 text-lg"> Write, ideate, and learn with all the wisdom of your bookmarks. </p> <div className="flex items-center gap-4"> <div - className={`transition-width z-20 rounded-2xl bg-gradient-to-br from-gray-200/70 to-transparent p-[0.7px] duration-300 ease-in-out w-3/4`} + className={`transition-width z-20 rounded-2xl bg-gradient-to-br from-gray-200/70 to-transparent p-[0.7px] duration-300 ease-in-out w-3/4 hover:bg-opacity-90 hover:shadow-lg group`} > <form action={async () => { @@ -40,7 +36,7 @@ async function Signin() { > <button type="submit" - className={`relative text-white transition-width flex justify-between w-full items-center rounded-2xl bg-[#37485E] px-6 py-4 outline-none duration-300 focus:outline-none`} + className={`relative text-white transition-width flex justify-between w-full items-center rounded-2xl bg-[#37485E] px-6 py-4 outline-none duration-300 focus:outline-none group-hover:bg-opacity-50 group`} > <Google /> <span className="relative w-full self-start"> @@ -50,21 +46,19 @@ async function Signin() { </form> </div> </div> - <div className="text-slate-500 mt-16"> - By continuing, you agree to the - <Link href="/terms" className="text-slate-200"> - {" "} + <div className="text-slate-100 absolute bottom-4 lg:left-8 lg:-translate-x-0 left-1/2 -translate-x-1/2 "> + <Link href="/terms" className="text-slate-300"> Terms of Service </Link>{" "} - and - <Link href="/privacy" className="text-slate-200"> + | + <Link href="/privacy" className="text-slate-300"> {" "} Privacy Policy </Link> </div> </div> </div> - <div className="w-1/2 flex flex-col items-center justify-center min-h-screen"> + <div className="w-1/2 hidden lg:flex flex-col items-center justify-center min-h-screen"> <span className="text-3xl leading-relaxed italic mb-8"> Ready for your{" "} <span className="text-white font-bold">Second brain</span>? diff --git a/apps/web/app/(canvas)/canvas.tsx b/apps/web/app/(canvas)/canvas.tsx new file mode 100644 index 00000000..9ec57d6d --- /dev/null +++ b/apps/web/app/(canvas)/canvas.tsx @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Editor, Tldraw, setUserPreferences, TLStoreWithStatus } from "tldraw"; +import { createAssetFromUrl } from "./lib/createAssetUrl"; +import "tldraw/tldraw.css"; +import { components } from "./enabledComp"; +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'; + +export const Canvas = memo(()=>{ + const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({ + status: "loading", + }); + useEffect(() => { + const fetchStore = async () => { + const store = await loadRemoteSnapshot(); + + setStoreWithStatus({ + store: store, + status: "not-synced", + }); + }; + + fetchStore(); + }, []); + + const handleMount = useCallback((editor: Editor) => { + (window as any).app = editor; + (window as any).editor = editor; + editor.registerExternalAssetHandler("url", createAssetFromUrl); + editor.registerExternalContentHandler("url", ({ url, point, sources }) => { + createEmbedsFromUrl({ url, point, sources, editor }); + }); + }, []); + + setUserPreferences({ id: "supermemory", isDarkMode: true }); + + 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> + ); +}) diff --git a/apps/web/app/(canvas)/canvas/layout.tsx b/apps/web/app/(canvas)/canvas/layout.tsx new file mode 100644 index 00000000..9bc3b6d7 --- /dev/null +++ b/apps/web/app/(canvas)/canvas/layout.tsx @@ -0,0 +1,13 @@ +import "../canvasStyles.css"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <div lang="en" className="bg-[#151515]"> + <div>{children}</div> + </div> + ); +} diff --git a/apps/web/app/(canvas)/canvas/page.tsx b/apps/web/app/(canvas)/canvas/page.tsx new file mode 100644 index 00000000..366a4481 --- /dev/null +++ b/apps/web/app/(canvas)/canvas/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +// import Canvas from "./_components/canvas"; +import {Canvas} from "../canvas"; +import React, { useState } from "react"; +// import ReactTextareaAutosize from "react-textarea-autosize"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { + DragSvg, + SettingsSvg, + LinkSvg, + ThreeDBlock, + TextLoadingSvg, +} from "../svg"; + +function page() { + const [value, setValue] = useState(""); + const [fullScreen, setFullScreen] = useState(false); + + return ( + <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}> + <div className={`flex transition-all rounded-2xl ${fullScreen ? "h-screen": "h-[calc(100vh-3rem)]"} w-full flex-col overflow-hidden bg-[#1F2428]`}> + <div className="flex items-center justify-between bg-[#2C3439] px-4 py-2 text-lg font-medium text-[#989EA4]"> + Change Filters + <SettingsSvg /> + </div> + <div className="px-3 py-5"> + <input + placeholder="search..." + onChange={(e) => { + setValue(e.target.value); + }} + value={value} + // rows={1} + className="w-full resize-none rounded-xl bg-[#151515] px-3 py-4 text-xl text-[#989EA4] outline-none focus:outline-none sm:max-h-52" + /> + </div> + <div className="flex flex-col gap-10"> + <div className="flex gap-4 px-3 text-[#989EA4]"> + <TextLoadingSvg /> + <h1> + Nvidia will most likely create monopoly in software industry + as they are already largest player in GPU hardware by 20... + </h1> + </div> + <div className="flex gap-4 px-3 text-[#989EA4]"> + <ThreeDBlock /> + <div className="flex flex-col gap-2"> + <div> + <h1 className="line-clamp-3"> + Nvidia currently dominates the GPU hardware market, with + a market share over 97%. This has led some to argue... + </h1> + </div> + <p className="line-clamp-1 text-[#369DFD]"> + From space: GPU GOATS + </p> + </div> + </div> + <div className="flex gap-4 px-3 text-[#989EA4]"> + <LinkSvg /> + <div className="flex flex-col gap-2"> + <div> + <h1 className="line-clamp-3"> + Nvidia currently dominates the GPU hardware market, with + a market share over 97%. This has led some to argue... + </h1> + </div> + <p className="line-clamp-1 text-[#369DFD]"> + Page url: + https://www.cnbc.com/2024/05/23/nvidia-keeps-hitting-records-can-investors-still-buy-the-stock.html?&qsearchterm=nvidia + </p> + </div> + </div> + </div> + </div> + </Panel> + <PanelResizeHandle className={`relative flex items-center transition-all justify-center ${!fullScreen && "px-1"}`}> + {/* <div className="absolute z-[1000000] top-1/2 -translate-y-1/2"> */} + <div className={`rounded-lg bg-[#2F363B] ${!fullScreen && "px-1"} transition-all py-2`}> + <DragSvg /> + </div> + {/* </div> */} + </PanelResizeHandle> + <Panel className="relative" defaultSize={70} minSize={60}> + <div className={`absolute overflow-hidden transition-all inset-0 ${ fullScreen ? "h-screen " : "h-[calc(100vh-3rem)] rounded-2xl"} w-full`}> + <Canvas /> + </div> + </Panel> + </PanelGroup> + </div> + </div> + ); +} + +export default page; diff --git a/apps/web/app/(canvas)/canvasStyles.css b/apps/web/app/(canvas)/canvasStyles.css new file mode 100644 index 00000000..a53d8c96 --- /dev/null +++ b/apps/web/app/(canvas)/canvasStyles.css @@ -0,0 +1,24 @@ +.tl-background { + background: #1F2428 !important; +} + +.tlui-style-panel.tlui-style-panel__wrapper, .tlui-navigation-panel::before ,.tlui-menu-zone, .tlui-toolbar__tools, .tlui-popover__content, .tlui-menu, .tlui-button__help, .tlui-help-menu, .tlui-dialog__content { + background: #2C3439 !important; + border-top: #2C3439 !important; + border-right: #2C3439 !important; + border-bottom: #2C3439 !important; + border-left: #2C3439 !important; +} + +.tlui-navigation-panel::before { + border-top: #2C3439 !important; + border-right: #2C3439 !important; +} + +.tlui-minimap { + background: #2C3439 !important; +} + +.tlui-minimap__canvas { + background: #1F2428 !important; +}
\ No newline at end of file diff --git a/apps/web/app/(canvas)/enabledComp.tsx b/apps/web/app/(canvas)/enabledComp.tsx new file mode 100644 index 00000000..5dbe6ee7 --- /dev/null +++ b/apps/web/app/(canvas)/enabledComp.tsx @@ -0,0 +1,22 @@ +import { TLUiComponents } from "tldraw"; + +export const components: Partial<TLUiComponents> = { + ActionsMenu: null, + MainMenu: null, + QuickActions: null, + TopPanel: null, + DebugPanel: null, + DebugMenu: null, + // Minimap: null, + // ContextMenu: null, + // HelpMenu: null, + // ZoomMenu: null, + // StylePanel: null, + // PageMenu: null, + // NavigationPanel: null, + // Toolbar: null, + // KeyboardShortcutsDialog: null, + // HelperButtons: null, + // SharePanel: null, + // MenuPanel: null, +};
\ No newline at end of file diff --git a/apps/web/app/(canvas)/lib/createAssetUrl.ts b/apps/web/app/(canvas)/lib/createAssetUrl.ts new file mode 100644 index 00000000..05c2baea --- /dev/null +++ b/apps/web/app/(canvas)/lib/createAssetUrl.ts @@ -0,0 +1,94 @@ +import { + AssetRecordType, + TLAsset, + getHashForString, + truncateStringWithEllipsis, +} from "tldraw"; +// import { BOOKMARK_ENDPOINT } from './config' + +interface ResponseBody { + title?: string; + description?: string; + image?: string; +} + +export async function createAssetFromUrl({ + url, +}: { + type: "url"; + url: string; +}): Promise<TLAsset> { + // try { + // // First, try to get the meta data from our endpoint + // const meta = (await ( + // await fetch(BOOKMARK_ENDPOINT, { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // }, + // body: JSON.stringify({ + // url, + // }), + // }) + // ).json()) as ResponseBody + + // return { + // id: AssetRecordType.createId(getHashForString(url)), + // typeName: 'asset', + // type: 'bookmark', + // props: { + // src: url, + // description: meta.description ?? '', + // image: meta.image ?? '', + // title: meta.title ?? truncateStringWithEllipsis(url, 32), + // }, + // meta: {}, + // } + // } catch (error) { + // Otherwise, fallback to fetching data from the url + + let meta: { image: string; title: string; description: string }; + + try { + const resp = await fetch(url, { method: "GET", mode: "no-cors" }); + const html = await resp.text(); + const doc = new DOMParser().parseFromString(html, "text/html"); + meta = { + image: + doc.head + .querySelector('meta[property="og:image"]') + ?.getAttribute("content") ?? "", + title: + doc.head + .querySelector('meta[property="og:title"]') + ?.getAttribute("content") ?? truncateStringWithEllipsis(url, 32), + description: + doc.head + .querySelector('meta[property="og:description"]') + ?.getAttribute("content") ?? "", + }; + } catch (error) { + console.error(error); + meta = { + image: "", + title: truncateStringWithEllipsis(url, 32), + description: "", + }; + } + + // Create the bookmark asset from the meta + return { + id: AssetRecordType.createId(getHashForString(url)), + typeName: "asset", + type: "bookmark", + props: { + src: url, + image: meta.image, + title: meta.title, + description: meta.description, + favicon: meta.image, + }, + meta: {}, + }; + // } +} diff --git a/apps/web/app/(canvas)/lib/createEmbeds.ts b/apps/web/app/(canvas)/lib/createEmbeds.ts new file mode 100644 index 00000000..53d81533 --- /dev/null +++ b/apps/web/app/(canvas)/lib/createEmbeds.ts @@ -0,0 +1,142 @@ +import { AssetRecordType, Editor, TLAsset, TLAssetId, TLBookmarkShape, TLExternalContentSource, TLShapePartial, Vec, VecLike, createShapeId, getEmbedInfo, getHashForString } from "tldraw"; + +export default async function createEmbedsFromUrl({url, point, sources, editor}: { + url: string + point: VecLike | undefined + sources: TLExternalContentSource[] | undefined + editor: Editor +}){ + + const position = + point ?? + (editor.inputs.shiftKey + ? editor.inputs.currentPagePoint + : editor.getViewportPageBounds().center); + + if (url?.includes("x.com") || url?.includes("twitter.com")) { + return editor.createShape({ + type: "Twittercard", + x: position.x - 250, + y: position.y - 150, + props: { url: url }, + }); + + } + + // try to paste as an embed first + const embedInfo = getEmbedInfo(url); + + if (embedInfo) { + return editor.putExternalContent({ + type: "embed", + url: embedInfo.url, + point, + embed: embedInfo.definition, + }); + } + + const assetId: TLAssetId = AssetRecordType.createId( + getHashForString(url), + ); + const shape = createEmptyBookmarkShape(editor, url, position); + + // Use an existing asset if we have one, or else else create a new one + let asset = editor.getAsset(assetId) as TLAsset; + let shouldAlsoCreateAsset = false; + if (!asset) { + shouldAlsoCreateAsset = true; + try { + const bookmarkAsset = await editor.getAssetForExternalContent({ + type: "url", + url, + }); + 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) { + console.log(e) + return; + } + } + + editor.batch(() => { + if (shouldAlsoCreateAsset) { + editor.createAssets([asset]); + } + + editor.updateShapes([ + { + id: shape.id, + type: shape.type, + props: { + assetId: asset.id, + }, + }, + ]); + }); +} + +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() + let selectionPageBounds = editor.getSelectionPageBounds() + + if (selectionPageBounds) { + const offset = selectionPageBounds!.center.sub(position) + + editor.updateShapes( + editor.getSelectedShapes().map((shape) => { + const localRotation = editor.getShapeParentTransform(shape).decompose().rotation + const localDelta = Vec.Rot(offset, -localRotation) + return { + id: shape.id, + type: shape.type, + x: shape.x! - localDelta.x, + y: shape.y! - localDelta.y, + } + }) + ) + } + + // Zoom out to fit the shapes, if necessary + selectionPageBounds = editor.getSelectionPageBounds() + if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) { + editor.zoomToSelection() + } +} + +export function createEmptyBookmarkShape( + editor: Editor, + url: string, + position: VecLike +): TLBookmarkShape { + const partial: TLShapePartial = { + id: createShapeId(), + type: 'bookmark', + x: position.x - 150, + y: position.y - 160, + opacity: 1, + props: { + assetId: null, + url, + }, + } + + editor.batch(() => { + editor.createShapes([partial]).select(partial.id) + centerSelectionAroundPoint(editor, position) + }) + + return editor.getShape(partial.id) as TLBookmarkShape +}
\ No newline at end of file diff --git a/apps/web/app/(canvas)/lib/loadSnap.ts b/apps/web/app/(canvas)/lib/loadSnap.ts new file mode 100644 index 00000000..15aad998 --- /dev/null +++ b/apps/web/app/(canvas)/lib/loadSnap.ts @@ -0,0 +1,13 @@ +import { createTLStore, defaultShapeUtils } from "tldraw"; +import { twitterCardUtil } from "../twitterCard"; +export async function loadRemoteSnapshot() { + const res = await fetch( + "https://learning-cf.pruthvirajthinks.workers.dev/get/page3", + ); + const snapshot = JSON.parse(await res.json()); + const newStore = createTLStore({ + shapeUtils: [...defaultShapeUtils, twitterCardUtil], + }); + newStore.loadSnapshot(snapshot); + return newStore; +}
\ No newline at end of file diff --git a/apps/web/app/(canvas)/savesnap.tsx b/apps/web/app/(canvas)/savesnap.tsx new file mode 100644 index 00000000..f82e97e3 --- /dev/null +++ b/apps/web/app/(canvas)/savesnap.tsx @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from "react"; +import { debounce, useEditor } from "tldraw"; + +export function SaveStatus() { + const [save, setSave] = useState("saved!"); + const editor = useEditor(); + + const debouncedSave = useCallback( + debounce(async () => { + const snapshot = editor.store.getSnapshot(); + localStorage.setItem("saved", JSON.stringify(snapshot)); + + const res = await fetch( + "https://learning-cf.pruthvirajthinks.workers.dev/post/page3", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + data: snapshot, + }), + }, + ); + + console.log(await res.json()); + setSave("saved!"); + }, 3000), + [editor], // Dependency array ensures the function is not recreated on every render + ); + + useEffect(() => { + const unsubscribe = editor.store.listen( + () => { + setSave("saving..."); + debouncedSave(); + }, + { scope: "document", source: "user" }, + ); + + return () => unsubscribe(); // Cleanup on unmount + }, [editor, debouncedSave]); + + return <button>{save}</button>; +}
\ No newline at end of file diff --git a/apps/web/app/(canvas)/svg.tsx b/apps/web/app/(canvas)/svg.tsx new file mode 100644 index 00000000..bae4e614 --- /dev/null +++ b/apps/web/app/(canvas)/svg.tsx @@ -0,0 +1,97 @@ +export function SettingsSvg() { + return ( + <svg + width="16" + height="18" + viewBox="0 0 16 18" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M7.2321 0.875C6.46793 0.875 5.81627 1.4275 5.69043 2.18083L5.5421 3.07417C5.52543 3.17417 5.44627 3.29083 5.2946 3.36417C5.00906 3.50143 4.73438 3.66022 4.47293 3.83917C4.3346 3.935 4.1946 3.94417 4.09793 3.90833L3.25043 3.59C2.90397 3.4602 2.52268 3.45755 2.17445 3.58253C1.82621 3.70751 1.53362 3.95201 1.34877 4.2725L0.580434 5.60333C0.395508 5.92363 0.330196 6.29915 0.396115 6.66307C0.462034 7.027 0.65491 7.35575 0.940434 7.59083L1.64043 8.1675C1.7196 8.2325 1.7821 8.35833 1.76877 8.52583C1.74502 8.84178 1.74502 9.15906 1.76877 9.475C1.78127 9.64167 1.7196 9.76833 1.64127 9.83333L0.940434 10.41C0.65491 10.6451 0.462034 10.9738 0.396115 11.3378C0.330196 11.7017 0.395508 12.0772 0.580434 12.3975L1.34877 13.7283C1.53376 14.0487 1.8264 14.293 2.17462 14.4178C2.52285 14.5426 2.90406 14.5399 3.25043 14.41L4.0996 14.0917C4.19543 14.0558 4.33543 14.0658 4.4746 14.16C4.7346 14.3383 5.00877 14.4975 5.29543 14.635C5.4471 14.7083 5.52627 14.825 5.54293 14.9267L5.69127 15.8192C5.8171 16.5725 6.46877 17.125 7.23293 17.125H8.7696C9.53293 17.125 10.1854 16.5725 10.3113 15.8192L10.4596 14.9258C10.4763 14.8258 10.5546 14.7092 10.7071 14.635C10.9938 14.4975 11.2679 14.3383 11.5279 14.16C11.6671 14.065 11.8071 14.0558 11.9029 14.0917L12.7529 14.41C13.0992 14.5394 13.4801 14.5418 13.828 14.4168C14.1758 14.2919 14.4681 14.0476 14.6529 13.7275L15.4221 12.3967C15.607 12.0764 15.6723 11.7009 15.6064 11.3369C15.5405 10.973 15.3476 10.6443 15.0621 10.4092L14.3621 9.8325C14.2829 9.7675 14.2204 9.64167 14.2338 9.47417C14.2575 9.15822 14.2575 8.84095 14.2338 8.525C14.2204 8.35833 14.2829 8.23167 14.3613 8.16667L15.0613 7.59C15.6513 7.105 15.8038 6.265 15.4221 5.6025L14.6538 4.27167C14.4688 3.95132 14.1761 3.707 13.8279 3.58218C13.4797 3.45735 13.0985 3.46013 12.7521 3.59L11.9021 3.90833C11.8071 3.94417 11.6671 3.93417 11.5279 3.83917C11.2668 3.66025 10.9924 3.50145 10.7071 3.36417C10.5546 3.29167 10.4763 3.175 10.4596 3.07417L10.3104 2.18083C10.2497 1.81589 10.0614 1.48435 9.77905 1.24522C9.49674 1.0061 9.13874 0.874907 8.76877 0.875H7.23293H7.2321ZM8.00043 12.125C8.82923 12.125 9.62409 11.7958 10.2101 11.2097C10.7962 10.6237 11.1254 9.8288 11.1254 9C11.1254 8.1712 10.7962 7.37634 10.2101 6.79029C9.62409 6.20424 8.82923 5.875 8.00043 5.875C7.17163 5.875 6.37678 6.20424 5.79072 6.79029C5.20467 7.37634 4.87543 8.1712 4.87543 9C4.87543 9.8288 5.20467 10.6237 5.79072 11.2097C6.37678 11.7958 7.17163 12.125 8.00043 12.125Z" + fill="#989EA4" + /> + </svg> + ); +} + +export function DragSvg() { + return ( + <svg + width="6" + height="9" + viewBox="0 0 6 9" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M4.78829 0.916134C4.78348 0.920945 4.77696 0.923648 4.77015 0.923648C4.76335 0.923648 4.75682 0.920945 4.75201 0.916134C4.7472 0.911322 4.74449 0.904797 4.74449 0.897991C4.74449 0.891186 4.7472 0.884661 4.75201 0.879849C4.75682 0.875038 4.76335 0.872335 4.77015 0.872335C4.77696 0.872335 4.78348 0.875038 4.78829 0.879849C4.79311 0.884661 4.79581 0.891186 4.79581 0.897991C4.79581 0.904797 4.79311 0.911322 4.78829 0.916134ZM4.77015 0C4.53199 0 4.30358 0.0946096 4.13518 0.263016C3.96677 0.431421 3.87216 0.659829 3.87216 0.897991C3.87216 1.13615 3.96677 1.36456 4.13518 1.53297C4.30358 1.70137 4.53199 1.79598 4.77015 1.79598C5.00831 1.79598 5.23672 1.70137 5.40513 1.53297C5.57353 1.36456 5.66814 1.13615 5.66814 0.897991C5.66814 0.659829 5.57353 0.431421 5.40513 0.263016C5.23672 0.0946096 5.00831 0 4.77015 0ZM4.78829 4.40547C4.78348 4.41028 4.77696 4.41299 4.77015 4.41299C4.76335 4.41299 4.75682 4.41028 4.75201 4.40547C4.7472 4.40066 4.74449 4.39414 4.74449 4.38733C4.74449 4.38052 4.7472 4.374 4.75201 4.36919C4.75682 4.36438 4.76335 4.36167 4.77015 4.36167C4.77696 4.36167 4.78348 4.36438 4.78829 4.36919C4.79311 4.374 4.79581 4.38052 4.79581 4.38733C4.79581 4.39414 4.79311 4.40066 4.78829 4.40547ZM4.77015 3.48934C4.53199 3.48934 4.30358 3.58395 4.13518 3.75235C3.96677 3.92076 3.87216 4.14917 3.87216 4.38733C3.87216 4.62549 3.96677 4.8539 4.13518 5.02231C4.30358 5.19071 4.53199 5.28532 4.77015 5.28532C5.00831 5.28532 5.23672 5.19071 5.40513 5.02231C5.57353 4.8539 5.66814 4.62549 5.66814 4.38733C5.66814 4.14917 5.57353 3.92076 5.40513 3.75235C5.23672 3.58395 5.00831 3.48934 4.77015 3.48934ZM4.78829 7.89481C4.78348 7.89962 4.77696 7.90232 4.77015 7.90232C4.76335 7.90232 4.75682 7.89962 4.75201 7.89481C4.7472 7.89 4.74449 7.88347 4.74449 7.87667C4.74449 7.86986 4.7472 7.86334 4.75201 7.85853C4.75682 7.85371 4.76335 7.85101 4.77015 7.85101C4.77696 7.85101 4.78348 7.85371 4.78829 7.85853C4.79311 7.86334 4.79581 7.86986 4.79581 7.87667C4.79581 7.88347 4.79311 7.89 4.78829 7.89481ZM4.77015 6.97868C4.53199 6.97868 4.30358 7.07329 4.13518 7.24169C3.96677 7.4101 3.87216 7.63851 3.87216 7.87667C3.87216 8.11483 3.96677 8.34324 4.13518 8.51164C4.30358 8.68005 4.53199 8.77466 4.77015 8.77466C5.00831 8.77466 5.23672 8.68005 5.40513 8.51164C5.57353 8.34324 5.66814 8.11483 5.66814 7.87667C5.66814 7.63851 5.57353 7.4101 5.40513 7.24169C5.23672 7.07329 5.00831 6.97868 4.77015 6.97868ZM0.916134 0.91702C0.911322 0.921832 0.904796 0.924535 0.897991 0.924535C0.891187 0.924535 0.884661 0.921832 0.879849 0.91702C0.875038 0.912209 0.872335 0.905683 0.872335 0.898878C0.872335 0.892073 0.875038 0.885547 0.879849 0.880736C0.884661 0.875924 0.891187 0.873221 0.897991 0.873221C0.904796 0.873221 0.911322 0.875924 0.916134 0.880736C0.920945 0.885547 0.923648 0.892073 0.923648 0.898878C0.923648 0.905683 0.920945 0.912209 0.916134 0.91702ZM0.897991 0.000886679C0.659829 0.000886679 0.431422 0.0954962 0.263016 0.263902C0.0946102 0.432308 0 0.660715 0 0.898878C0 1.13704 0.0946102 1.36545 0.263016 1.53385C0.431422 1.70226 0.659829 1.79687 0.897991 1.79687C1.13615 1.79687 1.36456 1.70226 1.53297 1.53385C1.70137 1.36545 1.79598 1.13704 1.79598 0.898878C1.79598 0.660715 1.70137 0.432308 1.53297 0.263902C1.36456 0.0954962 1.13615 0.000886679 0.897991 0.000886679ZM0.916134 4.40636C0.911323 4.41117 0.904797 4.41387 0.897991 4.41387C0.891186 4.41387 0.88466 4.41117 0.879849 4.40636C0.875038 4.40155 0.872335 4.39502 0.872335 4.38822C0.872335 4.38141 0.875038 4.37489 0.879849 4.37007C0.88466 4.36526 0.891186 4.36256 0.897991 4.36256C0.904797 4.36256 0.911323 4.36526 0.916134 4.37007C0.920945 4.37489 0.923648 4.38141 0.923648 4.38822C0.923648 4.39502 0.920945 4.40155 0.916134 4.40636ZM0.897991 3.49022C0.659828 3.49022 0.431421 3.58484 0.263016 3.75324C0.0946104 3.92165 0 4.15005 0 4.38822C0 4.62638 0.0946104 4.85479 0.263016 5.02319C0.431421 5.1916 0.659828 5.28621 0.897991 5.28621C1.13615 5.28621 1.36456 5.1916 1.53297 5.02319C1.70137 4.85479 1.79598 4.62638 1.79598 4.38822C1.79598 4.15005 1.70137 3.92165 1.53297 3.75324C1.36456 3.58484 1.13615 3.49022 0.897991 3.49022ZM0.916134 7.8957C0.911322 7.90051 0.904795 7.90321 0.897991 7.90321C0.891187 7.90321 0.884661 7.90051 0.879849 7.8957C0.875038 7.89089 0.872335 7.88436 0.872335 7.87755C0.872335 7.87075 0.875038 7.86422 0.879849 7.85941C0.884661 7.8546 0.891187 7.8519 0.897991 7.8519C0.904795 7.8519 0.911322 7.8546 0.916134 7.85941C0.920945 7.86422 0.923648 7.87075 0.923648 7.87755C0.923648 7.88436 0.920945 7.89089 0.916134 7.8957ZM0.897991 6.97956C0.65983 6.97956 0.431422 7.07417 0.263016 7.24258C0.0946099 7.41098 0 7.63939 0 7.87755C0 8.11572 0.0946099 8.34412 0.263016 8.51253C0.431422 8.68094 0.65983 8.77555 0.897991 8.77555C1.13615 8.77555 1.36456 8.68094 1.53297 8.51253C1.70137 8.34412 1.79598 8.11572 1.79598 7.87755C1.79598 7.63939 1.70137 7.41098 1.53297 7.24258C1.36456 7.07417 1.13615 6.97956 0.897991 6.97956Z" + fill="#989EA4" + /> + </svg> + ); +} + +export function TextLoadingSvg() { + return ( + <svg + width="34" + height="24" + viewBox="0 0 14 10" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M0.8125 1.0625H13.1875M0.8125 5H13.1875M0.8125 8.9375H7" + stroke="#989EA4" + stroke-width="1.5" + stroke-linecap="round" + stroke-linejoin="round" + /> + </svg> + ); +} + +export function ThreeDBlock() { + return ( + <svg + width="32" + height="36" + viewBox="0 0 16 18" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M14.75 5.625L8 1.6875L1.25 5.625M14.75 5.625L8 9.5625M14.75 5.625V12.375L8 16.3125M1.25 5.625L8 9.5625M1.25 5.625V12.375L8 16.3125M8 9.5625V16.3125" + stroke="#989EA4" + stroke-width="1.5" + stroke-linecap="round" + stroke-linejoin="round" + /> + </svg> + ); +} + +export function LinkSvg() { + return ( + <svg + width="36" + height="36" + viewBox="0 0 18 18" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M9.89252 6.51602C10.3799 6.74871 10.8043 7.09497 11.1301 7.5257C11.4559 7.95643 11.6736 8.45906 11.7648 8.99136C11.8561 9.52366 11.8183 10.0701 11.6546 10.5848C11.4909 11.0994 11.206 11.5673 10.824 11.949L7.44902 15.324C6.81608 15.957 5.95763 16.3125 5.06252 16.3125C4.16741 16.3125 3.30896 15.957 2.67602 15.324C2.04308 14.6911 1.6875 13.8326 1.6875 12.9375C1.6875 12.0424 2.04308 11.184 2.67602 10.551L3.99377 9.23327M14.0063 8.76677L15.324 7.44902C15.957 6.81608 16.3125 5.95763 16.3125 5.06252C16.3125 4.16741 15.957 3.30896 15.324 2.67602C14.6911 2.04308 13.8326 1.6875 12.9375 1.6875C12.0424 1.6875 11.184 2.04308 10.551 2.67602L7.17602 6.05102C6.794 6.43277 6.50917 6.90063 6.34546 7.41529C6.18175 7.92995 6.14393 8.47638 6.2352 9.00868C6.32646 9.54098 6.54414 10.0436 6.86994 10.4743C7.19574 10.9051 7.62015 11.2513 8.10752 11.484" + stroke="#989EA4" + stroke-width="1.5" + stroke-linecap="round" + stroke-linejoin="round" + /> + </svg> + ); +} diff --git a/apps/web/app/(canvas)/twitterCard.tsx b/apps/web/app/(canvas)/twitterCard.tsx new file mode 100644 index 00000000..c5582a98 --- /dev/null +++ b/apps/web/app/(canvas)/twitterCard.tsx @@ -0,0 +1,84 @@ +import { BaseBoxShapeUtil, HTMLContainer, TLBaseShape, toDomPrecision } from "tldraw"; + +type ITwitterCardShape = TLBaseShape< + "Twittercard", + { w: number; h: number; url: string } +>; + +export class twitterCardUtil extends BaseBoxShapeUtil<ITwitterCardShape> { + static override type = "Twittercard" as const; + + getDefaultProps(): ITwitterCardShape["props"] { + return { + w: 500, + h: 550, + url: "", + }; + } + + component(s: ITwitterCardShape) { + return ( + <HTMLContainer className="flex h-full w-full items-center justify-center"> + <TwitterPost + url={s.props.url} + width={s.props.w} + isInteractive={false} + height={s.props.h} + /> + </HTMLContainer> + ); + } + + indicator(shape: ITwitterCardShape) { + return <rect width={shape.props.w} height={shape.props.h} />; + } +} + +function TwitterPost({ + isInteractive, + width, + height, + url, +}: { + isInteractive: boolean; + width: number; + height: number; + url: string; +}) { + const link = (() => { + try { + const urlObj = new URL(url); + const path = urlObj.pathname; + return path; + } catch (error) { + console.error("Invalid URL", error); + return null; + } + })(); + + return ( + <iframe + className="tl-embed" + draggable={false} + width={toDomPrecision(width)} + height={toDomPrecision(height)} + seamless + referrerPolicy="no-referrer-when-downgrade" + style={{ + pointerEvents: isInteractive ? "all" : "none", + zIndex: isInteractive ? "" : "-1", + }} + srcDoc={` + <html lang="en"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Document</title> + </head> + <body> + <blockquote data-theme="dark" class="twitter-tweet"><p lang="en" dir="ltr"><a href="https://twitter.com${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> + </body> + </html>`} + /> + ); +} diff --git a/apps/web/app/(dash)/actions.ts b/apps/web/app/(dash)/actions.ts deleted file mode 100644 index 70c2a567..00000000 --- a/apps/web/app/(dash)/actions.ts +++ /dev/null @@ -1,48 +0,0 @@ -"use server"; - -import { cookies, headers } from "next/headers"; -import { db } from "../helpers/server/db"; -import { sessions, users, space } from "../helpers/server/db/schema"; -import { eq } from "drizzle-orm"; -import { redirect } from "next/navigation"; - -export async function ensureAuth() { - 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 ", ""); - - if (!token) { - return undefined; - } - - const sessionData = await db - .select() - .from(sessions) - .innerJoin(users, eq(users.id, sessions.userId)) - .where(eq(sessions.sessionToken, token)); - - if (!sessionData || sessionData.length < 0) { - return undefined; - } - - return { - user: sessionData[0]!.user, - session: sessionData[0]!, - }; -} - -export async function getSpaces() { - const data = await ensureAuth(); - if (!data) { - redirect("/signin"); - } - - const sp = await db - .select() - .from(space) - .where(eq(space.user, data.user.email)); - - return sp; -} diff --git a/apps/web/app/(dash)/chat/CodeBlock.tsx b/apps/web/app/(dash)/chat/CodeBlock.tsx new file mode 100644 index 00000000..0bb6a19d --- /dev/null +++ b/apps/web/app/(dash)/chat/CodeBlock.tsx @@ -0,0 +1,90 @@ +import React, { useRef, useState } from "react"; + +const CodeBlock = ({ + lang, + codeChildren, +}: { + lang: string; + codeChildren: React.ReactNode & React.ReactNode[]; +}) => { + const codeRef = useRef<HTMLElement>(null); + + return ( + <div className="bg-black rounded-md"> + <CodeBar lang={lang} codeRef={codeRef} /> + <div className="p-4 overflow-y-auto"> + <code ref={codeRef} className={`!whitespace-pre hljs language-${lang}`}> + {codeChildren} + </code> + </div> + </div> + ); +}; + +const CodeBar = React.memo( + ({ + lang, + codeRef, + }: { + lang: string; + codeRef: React.RefObject<HTMLElement>; + }) => { + const [isCopied, setIsCopied] = useState<boolean>(false); + return ( + <div className="flex items-center relative text-gray-200 bg-gray-800 px-4 py-2 text-xs font-sans"> + <span className="">{lang}</span> + <button + className="flex ml-auto gap-2" + aria-label="copy codeblock" + onClick={async () => { + const codeString = codeRef.current?.textContent; + if (codeString) + navigator.clipboard.writeText(codeString).then(() => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), 3000); + }); + }} + > + {isCopied ? ( + <> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth={1.5} + stroke="currentColor" + className="size-4" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0 1 18 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3 1.5 1.5 3-3.75" + /> + </svg> + Copied! + </> + ) : ( + <> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth={1.5} + stroke="currentColor" + className="size-4" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" + /> + </svg> + Copy code + </> + )} + </button> + </div> + ); + }, +); +export default CodeBlock; diff --git a/apps/web/app/(dash)/chat/actions.ts b/apps/web/app/(dash)/chat/actions.ts index 908fe79e..e69de29b 100644 --- a/apps/web/app/(dash)/chat/actions.ts +++ b/apps/web/app/(dash)/chat/actions.ts @@ -1 +0,0 @@ -"use server"; diff --git a/apps/web/app/(dash)/chat/chatWindow.tsx b/apps/web/app/(dash)/chat/chatWindow.tsx index 43c337ee..77c1f32b 100644 --- a/apps/web/app/(dash)/chat/chatWindow.tsx +++ b/apps/web/app/(dash)/chat/chatWindow.tsx @@ -6,29 +6,147 @@ import QueryInput from "../home/queryinput"; import { cn } from "@repo/ui/lib/utils"; import { motion } from "framer-motion"; import { useRouter } from "next/navigation"; +import { ChatHistory } from "@repo/shared-types"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@repo/ui/shadcn/accordion"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import rehypeHighlight from "rehype-highlight"; +import { code, p } from "./markdownRenderHelpers"; +import { codeLanguageSubset } from "@/lib/constants"; +import { z } from "zod"; +import { toast } from "sonner"; +import Link from "next/link"; +import { sources } from "next/dist/compiled/webpack/webpack"; -function ChatWindow({ q }: { q: string }) { +function ChatWindow({ + q, + spaces, +}: { + q: string; + spaces: { id: string; name: string }[]; +}) { const [layout, setLayout] = useState<"chat" | "initial">("initial"); + const [chatHistory, setChatHistory] = useState<ChatHistory[]>([ + { + 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.`, + // }, + ], + sources: [], + }, + }, + ]); 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) => { + 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.content ?? "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 }), + }); + + const reader = resp.body?.getReader(); + let done = false; + let result = ""; + while (!done && reader) { + const { value, done: d } = await reader.read(); + done = d; + + setChatHistory((prevChatHistory) => { + const newChatHistory = [...prevChatHistory]; + const lastAnswer = newChatHistory[newChatHistory.length - 1]; + if (!lastAnswer) return prevChatHistory; + lastAnswer.answer.parts.push({ text: new TextDecoder().decode(value) }); + return newChatHistory; + }); + } + + console.log(result); + }; + useEffect(() => { - if (q !== "") { + if (q.trim().length > 0) { + getAnswer( + q, + spaces.map((s) => s.id), + ); setTimeout(() => { setLayout("chat"); }, 300); } else { router.push("/home"); } - }, [q]); + }, []); + return ( - <div> + <div className="h-full"> <AnimatePresence mode="popLayout"> {layout === "initial" ? ( <motion.div exit={{ opacity: 0 }} key="initial" - className="max-w-3xl flex mx-auto w-full flex-col" + 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 /> @@ -36,16 +154,121 @@ function ChatWindow({ q }: { q: string }) { </motion.div> ) : ( <div - className="max-w-3xl flex mx-auto w-full flex-col mt-8" + className="max-w-3xl flex mx-auto w-full flex-col mt-24" key="chat" > - <h2 - className={cn( - "transition-all transform translate-y-0 opacity-100 duration-500 ease-in-out font-semibold text-2xl", - )} - > - {q} - </h2> + {chatHistory.map((chat, idx) => ( + <div + key={idx} + className={`mt-8 ${idx != chatHistory.length - 1 ? "pb-2 border-b" : ""}`} + > + <h2 + className={cn( + "text-white transition-all transform translate-y-0 opacity-100 duration-500 ease-in-out font-semibold text-2xl", + )} + > + {chat.question} + </h2> + + <div className="flex flex-col gap-2 mt-2"> + <div + className={`${chat.answer.sources.length > 0 || chat.answer.parts.length === 0 ? "flex" : "hidden"}`} + > + <Accordion + defaultValue={ + idx === chatHistory.length - 1 ? "memories" : "" + } + type="single" + collapsible + > + <AccordionItem value="memories"> + <AccordionTrigger className="text-foreground-menu"> + Related Memories + </AccordionTrigger> + {/* TODO: fade out content on the right side, the fade goes away when the user scrolls */} + <AccordionContent + className="relative flex gap-2 max-w-3xl overflow-auto no-scrollbar" + defaultChecked + > + {/* Loading state */} + {chat.answer.sources.length > 0 || + (chat.answer.parts.length === 0 && ( + <> + {[1, 2, 3, 4].map((_, idx) => ( + <div + key={`loadingState-${idx}`} + className="rounded-xl bg-secondary p-4 flex flex-col gap-2 min-w-72 animate-pulse" + > + <div className="bg-slate-700 h-2 rounded-full w-1/2"></div> + <div className="bg-slate-700 h-2 rounded-full w-full"></div> + </div> + ))} + </> + ))} + {chat.answer.sources.map((source, idx) => ( + <Link + href={source.source} + key={idx} + className="rounded-xl bg-secondary p-4 flex flex-col gap-2 min-w-72" + > + <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> + </Link> + ))} + </AccordionContent> + </AccordionItem> + </Accordion> + </div> + + {/* Summary */} + <div> + <div className="text-foreground-menu py-2">Summary</div> + <div className="text-base"> + {chat.answer.parts.length === 0 && ( + <div className="animate-pulse flex space-x-4"> + <div className="flex-1 space-y-3 py-1"> + <div className="h-2 bg-slate-700 rounded"></div> + <div className="h-2 bg-slate-700 rounded"></div> + </div> + </div> + )} + <Markdown + remarkPlugins={[remarkGfm, [remarkMath]]} + rehypePlugins={[ + rehypeKatex, + [ + rehypeHighlight, + { + detect: true, + ignoreMissing: true, + subset: codeLanguageSubset, + }, + ], + ]} + components={{ + code: code as any, + p: p as any, + }} + className="flex flex-col gap-2" + > + {chat.answer.parts.map((part) => part.text).join("")} + </Markdown> + </div> + </div> + </div> + </div> + ))} </div> )} </AnimatePresence> diff --git a/apps/web/app/(dash)/chat/markdownRenderHelpers.tsx b/apps/web/app/(dash)/chat/markdownRenderHelpers.tsx new file mode 100644 index 00000000..747d4fca --- /dev/null +++ b/apps/web/app/(dash)/chat/markdownRenderHelpers.tsx @@ -0,0 +1,25 @@ +import { DetailedHTMLProps, HTMLAttributes, memo } from "react"; +import { ExtraProps } from "react-markdown"; +import CodeBlock from "./CodeBlock"; + +export const code = memo((props: JSX.IntrinsicElements["code"]) => { + const { className, children } = props; + const match = /language-(\w+)/.exec(className || ""); + const lang = match && match[1]; + + return <CodeBlock lang={lang || "text"} codeChildren={children as any} />; +}); + +export const p = memo( + ( + props?: Omit< + DetailedHTMLProps< + HTMLAttributes<HTMLParagraphElement>, + HTMLParagraphElement + >, + "ref" + >, + ) => { + return <p className="whitespace-pre-wrap">{props?.children}</p>; + }, +); diff --git a/apps/web/app/(dash)/chat/page.tsx b/apps/web/app/(dash)/chat/page.tsx index 9e28fda7..73519851 100644 --- a/apps/web/app/(dash)/chat/page.tsx +++ b/apps/web/app/(dash)/chat/page.tsx @@ -1,5 +1,7 @@ import ChatWindow from "./chatWindow"; -import { chatSearchParamsCache } from "../../helpers/lib/searchParams"; +import { chatSearchParamsCache } from "../../../lib/searchParams"; +// @ts-expect-error +await import("katex/dist/katex.min.css"); function Page({ searchParams, @@ -10,7 +12,7 @@ function Page({ console.log(spaces); - return <ChatWindow q={q} />; + return <ChatWindow q={q} spaces={[]} />; } export default Page; diff --git a/apps/web/app/(dash)/dynamicisland.tsx b/apps/web/app/(dash)/dynamicisland.tsx new file mode 100644 index 00000000..6fa56fae --- /dev/null +++ b/apps/web/app/(dash)/dynamicisland.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { AddIcon } from "@repo/ui/icons"; +import Image from "next/image"; + +import { AnimatePresence, useMotionValueEvent, useScroll } from "framer-motion"; +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 { createMemory, createSpace } from "../actions/doers"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/shadcn/select"; +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(); + const [visible, setVisible] = useState(true); + + useMotionValueEvent(scrollYProgress, "change", (current) => { + if (typeof current === "number") { + let direction = current! - scrollYProgress.getPrevious()!; + + if (direction < 0 || direction === 1) { + setVisible(true); + } else { + setVisible(false); + } + } + }); + + return ( + <div className="fixed z-40 left-1/2 -translate-x-1/2 top-12"> + <AnimatePresence mode="wait"> + <motion.div + initial={{ + opacity: 1, + y: -150, + }} + animate={{ + y: visible ? 0 : -150, + opacity: visible ? 1 : 0, + }} + transition={{ + duration: 0.2, + }} + className="flex flex-col items-center" + > + <DynamicIslandContent /> + </motion.div> + </AnimatePresence> + </div> + ); +} + +export default DynamicIsland; + +function DynamicIslandContent() { + const [show, setshow] = useState(true); + function cancelfn() { + setshow(true); + } + + const lastBtn = useRef<string>(); + useEffect(() => { + console.log(show); + }, [show]); + + useEffect(() => { + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + setshow(true); + } + console.log(e.key, lastBtn.current); + if (e.key === "a" && lastBtn.current === "Alt") { + setshow(false); + } + lastBtn.current = e.key; + }); + }, []); + return ( + <> + {show ? ( + <div + onClick={() => setshow(!show)} + className="bg-secondary px-3 w-[2.23rem] overflow-hidden hover:w-[9.2rem] whitespace-nowrap py-2 rounded-3xl transition-[width] cursor-pointer" + > + <div className="flex gap-4 items-center"> + <Image src={AddIcon} alt="Add icon" /> + Add Content + </div> + </div> + ) : ( + <div> + <ToolBar cancelfn={cancelfn} /> + </div> + )} + </> + ); +} + +const fakeitems = ["spaces", "page", "note"]; + +function ToolBar({ cancelfn }: { cancelfn: () => void }) { + const [spaces, setSpaces] = useState<Space[]>([]); + + const [index, setIndex] = useState(0); + + useEffect(() => { + (async () => { + let spaces = await getSpaces(); + + if (!spaces.success || !spaces.data) { + toast.warning("Unable to get spaces", { + richColors: true, + }); + setSpaces([]); + return; + } + setSpaces(spaces.data); + })(); + }, []); + + return ( + <AnimatePresence mode="wait"> + <motion.div + initial={{ + opacity: 0, + y: 20, + }} + animate={{ + y: 0, + opacity: 1, + }} + exit={{ + opacity: 0, + y: 20, + }} + transition={{ + duration: 0.2, + }} + className="flex flex-col items-center" + > + <div className="bg-secondary py-[.35rem] px-[.6rem] rounded-2xl"> + <HoverEffect + items={fakeitems} + index={index} + indexFn={(i) => setIndex(i)} + /> + </div> + {index === 0 ? ( + <SpaceForm cancelfn={cancelfn} /> + ) : index === 1 ? ( + <PageForm cancelfn={cancelfn} spaces={spaces} /> + ) : ( + <NoteForm cancelfn={cancelfn} spaces={spaces} /> + )} + </motion.div> + </AnimatePresence> + ); +} + +export const HoverEffect = ({ + items, + index, + indexFn, +}: { + items: string[]; + index: number; + indexFn: (i: number) => void; +}) => { + return ( + <div className={"flex"}> + {items.map((item, idx) => ( + <button + key={idx} + className="relative block h-full w-full px-2 py-1" + onClick={() => indexFn(idx)} + > + <AnimatePresence> + {index === idx && ( + <motion.span + className="absolute inset-0 block h-full w-full rounded-xl bg-[#2B3237]" + layoutId="hoverBackground" + initial={{ opacity: 0 }} + animate={{ + opacity: 1, + transition: { duration: 0.15 }, + }} + exit={{ + opacity: 0, + transition: { duration: 0.15, delay: 0.2 }, + }} + /> + )} + </AnimatePresence> + <h3 className="text-[#858B92] z-50 relative">{item}</h3> + </button> + ))} + </div> + ); +}; + +function SpaceForm({ cancelfn }: { cancelfn: () => void }) { + return ( + <form + action={createSpace} + 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="name"> + Name + </Label> + <Input + className="bg-[#2B3237] focus-visible:ring-0 border-none focus-visible:ring-offset-0" + id="name" + name="name" + /> + </div> + <div className="flex justify-between"> + <a className="text-blue-500" href=""> + pull from store + </a> + {/* <div + onClick={cancelfn} + className="bg-[#2B3237] px-2 py-1 rounded-xl cursor-pointer" + > + cancel + </div> */} + <button + type="submit" + className="bg-[#2B3237] px-2 py-1 rounded-xl cursor-pointer" + > + Submit + </button> + </div> + </form> + ); +} + +function PageForm({ + cancelfn, + spaces, +}: { + cancelfn: () => void; + spaces: Space[]; +}) { + const [loading, setLoading] = useState(false); + + const { pending } = useFormStatus(); + return ( + <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 name="space"> + <SelectTrigger> + <SelectValue placeholder="Space" /> + </SelectTrigger> + <SelectContent className="bg-secondary text-white"> + {spaces.map((space) => ( + <SelectItem key={space.id} value={space.id.toString()}> + {space.name} + </SelectItem> + ))} + </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="input" + name="content" + /> + </div> + <div className="flex justify-end"> + <button + type="submit" + className="bg-[#2B3237] px-2 py-1 rounded-xl cursor-pointer" + > + Submit + </button> + </div> + </form> + ); +} + +function NoteForm({ + cancelfn, + spaces, +}: { + cancelfn: () => void; + spaces: Space[]; +}) { + return ( + <div 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="name"> + Space + </Label> + <Select> + <SelectTrigger> + <SelectValue placeholder="Space" /> + </SelectTrigger> + <SelectContent className="bg-secondary text-white"> + {spaces.map((space) => ( + <SelectItem key={space.id} value={space.id.toString()}> + {space.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div> + <Label className="text-[#858B92]" htmlFor="name"> + Note + </Label> + <Textarea + cols={4} + className="bg-[#2B3237] focus-visible:ring-0 border-none focus-visible:ring-offset-0 resize-none" + id="name" + /> + </div> + <div className="flex justify-end"> + <div + onClick={cancelfn} + className="bg-[#2B3237] px-2 py-1 rounded-xl cursor-pointer" + > + cancel + </div> + </div> + </div> + ); +} diff --git a/apps/web/app/(dash)/header.tsx b/apps/web/app/(dash)/header.tsx index 104c63bc..026cb080 100644 --- a/apps/web/app/(dash)/header.tsx +++ b/apps/web/app/(dash)/header.tsx @@ -2,49 +2,25 @@ import React from "react"; import Image from "next/image"; import Link from "next/link"; import Logo from "../../public/logo.svg"; -import { AddIcon, ChatIcon } from "@repo/ui/icons"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/shadcn/tabs"; +import { ChatIcon } from "@repo/ui/icons"; + +import DynamicIsland from "./dynamicisland"; function Header() { return ( <div> - <div className="flex items-center justify-between relative z-10"> - <Link href="/"> + <div className="fixed left-0 w-full flex items-center justify-between z-10"> + <Link className="px-5" href="/home"> <Image src={Logo} alt="SuperMemory logo" - className="hover:brightness-125 duration-200" + className="hover:brightness-75 brightness-50 duration-200" /> </Link> - <Tabs - className="absolute flex flex-col justify-center items-center w-full -z-10 group top-0 transition-transform duration-1000 ease-out" - defaultValue="account" - > - <div className="bg-secondary all-center h-11 rounded-full p-2 min-w-14"> - <button className="p-2 group-hover:hidden transition duration-500 ease-in-out"> - <Image src={AddIcon} alt="Add icon" /> - </button> - - <div className="hidden group-hover:flex inset-0 transition-opacity duration-500 ease-in-out"> - <TabsList className="p-2"> - <TabsTrigger value="account">Account</TabsTrigger> - <TabsTrigger value="password">Password</TabsTrigger> - </TabsList> - </div> - </div> - - <div className="bg-secondary all-center rounded-full p-2 mt-4 min-w-14 hidden group-hover:block"> - <TabsContent value="account"> - Make changes to your account here. - </TabsContent> - <TabsContent value="password"> - Change your password here. - </TabsContent> - </div> - </Tabs> + <DynamicIsland /> - <button className="flex shrink-0 duration-200 items-center gap-2 px-2 py-1.5 rounded-xl hover:bg-secondary"> + <button className="flex shrink-0 duration-200 items-center gap-2 px-5 py-1.5 rounded-xl hover:bg-secondary"> <Image src={ChatIcon} alt="Chat icon" /> Start new chat </button> diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx index 7a6bb94f..55f2928e 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -2,8 +2,8 @@ import React from "react"; import Menu from "../menu"; import Header from "../header"; import QueryInput from "./queryinput"; -import { homeSearchParamsCache } from "@/app/helpers/lib/searchParams"; -import { getSpaces } from "../actions"; +import { homeSearchParamsCache } from "@/lib/searchParams"; +import { getSpaces } from "@/app/actions/fetchers"; async function Page({ searchParams, @@ -13,15 +13,20 @@ async function Page({ // TODO: use this to show a welcome page/modal const { firstTime } = homeSearchParamsCache.parse(searchParams); - const spaces = await getSpaces(); + let spaces = await getSpaces(); + + if (!spaces.success) { + // TODO: handle this error properly. + spaces.data = []; + } return ( - <div className="max-w-3xl flex mx-auto w-full flex-col"> + <div className="max-w-3xl h-full justify-center flex mx-auto w-full flex-col"> {/* all content goes here */} {/* <div className="">hi {firstTime ? 'first time' : ''}</div> */} <div className="w-full h-96"> - <QueryInput initialSpaces={spaces} /> + <QueryInput initialSpaces={spaces.data} /> </div> </div> ); diff --git a/apps/web/app/(dash)/home/queryinput.tsx b/apps/web/app/(dash)/home/queryinput.tsx index d098fda8..d0c27b8d 100644 --- a/apps/web/app/(dash)/home/queryinput.tsx +++ b/apps/web/app/(dash)/home/queryinput.tsx @@ -2,10 +2,11 @@ import { ArrowRightIcon } from "@repo/ui/icons"; import Image from "next/image"; -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import Divider from "@repo/ui/shadcn/divider"; import { MultipleSelector, Option } from "@repo/ui/shadcn/combobox"; import { useRouter } from "next/navigation"; +import { getSpaces } from "@/app/actions/fetchers"; function QueryInput({ initialQuery = "", @@ -13,7 +14,10 @@ function QueryInput({ disabled = false, }: { initialQuery?: string; - initialSpaces?: { user: string | null; id: number; name: string }[]; + initialSpaces?: { + id: number; + name: string; + }[]; disabled?: boolean; }) { const [q, setQ] = useState(initialQuery); @@ -41,14 +45,18 @@ function QueryInput({ return newQ; }; - const options = initialSpaces.map((x) => ({ - label: x.name, - value: x.id.toString(), - })); + const options = useMemo( + () => + initialSpaces.map((x) => ({ + label: x.name, + value: x.id.toString(), + })), + [initialSpaces], + ); return ( <div> - <div className="bg-secondary rounded-t-[24px] w-full mt-40"> + <div className="bg-secondary rounded-t-[24px]"> {/* input and action button */} <form action={async () => push(parseQ())} className="flex gap-4 p-3"> <textarea @@ -82,6 +90,7 @@ function QueryInput({ {/* 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)))} diff --git a/apps/web/app/(dash)/layout.tsx b/apps/web/app/(dash)/layout.tsx index dffa27fa..3ec8926e 100644 --- a/apps/web/app/(dash)/layout.tsx +++ b/apps/web/app/(dash)/layout.tsx @@ -1,22 +1,25 @@ import Header from "./header"; import Menu from "./menu"; -import { ensureAuth } from "./actions"; import { redirect } from "next/navigation"; +import { auth } from "../../server/auth"; +import { Toaster } from "@repo/ui/shadcn/sonner"; async function Layout({ children }: { children: React.ReactNode }) { - const info = await ensureAuth(); + const info = await auth(); if (!info) { return redirect("/signin"); } return ( - <main className="h-screen flex flex-col p-4 relative"> + <main className="h-screen flex flex-col p-4 relative "> <Header /> <Menu /> {children} + + <Toaster /> </main> ); } diff --git a/apps/web/app/(dash)/memories/page.tsx b/apps/web/app/(dash)/memories/page.tsx new file mode 100644 index 00000000..ff746d1d --- /dev/null +++ b/apps/web/app/(dash)/memories/page.tsx @@ -0,0 +1,133 @@ +"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, { useEffect, useState } from "react"; + +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 }); + })(); + }, []); + + 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"> + My Memories + </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> + <div> + <div className="text-[#B3BCC5]">Spaces</div> + {memoriesAndSpaces.spaces.map((space) => ( + <TabComponent title={space.name} description={space.id.toString()} /> + ))} + </div> + + <div> + <div className="text-[#B3BCC5]">Pages</div> + {memoriesAndSpaces.memories.map((memory) => ( + <LinkComponent title={memory.title ?? "No title"} url={memory.url} /> + ))} + </div> + </div> + ); +} + +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()} + </div> + </div> + <div className="grow px-4"> + <div className="text-lg text-[#fff]">{title}</div> + <div>{description}</div> + </div> + <div> + <Image src={NextIcon} alt="Search icon" /> + </div> + </div> + ); +} + +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> + <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; +}) { + 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> + ); + })} + </div> + ); +} + +export default Page; diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx index 1177bca6..5f26f545 100644 --- a/apps/web/app/(dash)/menu.tsx +++ b/apps/web/app/(dash)/menu.tsx @@ -1,13 +1,14 @@ import React from "react"; import Image from "next/image"; import { MemoriesIcon, ExploreIcon, HistoryIcon } from "@repo/ui/icons"; +import Link from "next/link"; function Menu() { const menuItems = [ { icon: MemoriesIcon, text: "Memories", - url: "/", + url: "/memories", }, { icon: ExploreIcon, @@ -22,23 +23,27 @@ function Menu() { ]; return ( - <div className="absolute h-full p-4 flex items-center top-0 left-0"> + <div className="fixed h-screen pb-[25vh] w-full p-4 flex items-end justify-end lg:justify-start lg:items-center top-0 left-0 pointer-events-none"> <div className=""> - <div className="hover:rounded-2x group inline-flex w-14 text-foreground-menu text-[15px] font-medium flex-col items-start gap-6 overflow-hidden rounded-[28px] bg-secondary px-3 py-4 duration-200 hover:w-40"> + <div className="pointer-events-auto group flex w-14 text-foreground-menu text-[15px] font-medium flex-col items-start gap-6 overflow-hidden rounded-[28px] bg-secondary px-3 py-4 duration-200 hover:w-40"> {menuItems.map((item) => ( - <div + <Link + href={item.url} key={item.url} - className="flex w-full cursor-pointer items-center gap-3 px-1 duration-200 hover:scale-105 hover:brightness-150 active:scale-90" + className="flex w-full cursor-pointer items-center gap-3 px-1 duration-200 hover:scale-105 hover:brightness-150 active:scale-90 justify-end md:justify-start" > + <p className="md:hidden opacity-0 duration-200 group-hover:opacity-100"> + {item.text} + </p> <Image src={item.icon} alt={`${item.text} icon`} - className="hover:brightness-125 duration-200" + className="hover:brightness-125 duration-200 " /> - <p className="opacity-0 duration-200 group-hover:opacity-100"> + <p className="hidden md:block opacity-0 duration-200 group-hover:opacity-100"> {item.text} </p> - </div> + </Link> ))} </div> </div> diff --git a/apps/web/app/(editor)/ai.md b/apps/web/app/(editor)/ai.md new file mode 100644 index 00000000..1528a7bd --- /dev/null +++ b/apps/web/app/(editor)/ai.md @@ -0,0 +1,43 @@ +## to access the editor +``` +import { useEditor } from "novel"; +const editor = useEditor() +``` + +## to get previous text +``` +import { getPrevText } from "novel/utils"; +const pos = editor.state.selection.from; +const text = getPrevText(editor, pos); +``` + +## selected content into markdown format +``` +const slice = editor.state.selection.content(); +const text = editor.storage.markdown.serializer.serialize(slice.content); +``` + +## replace Selection +``` +const selection = editor.view.state.selection; +editor.chain().focus() + .insertContentAt( + { + from: selection.from, + to: selection.to, + }, + completion, + ) + .run(); +``` + + +## to insert after +``` +const selection = editor.view.state.selection; +editor + .chain() + .focus() + .insertContentAt(selection.to + 1, completion) + .run(); +``` diff --git a/apps/web/app/(editor)/components/aigenerate.tsx b/apps/web/app/(editor)/components/aigenerate.tsx new file mode 100644 index 00000000..f27fd50f --- /dev/null +++ b/apps/web/app/(editor)/components/aigenerate.tsx @@ -0,0 +1,169 @@ +import React, { useEffect, useRef, useState } from "react"; +import Magic from "./ui/magic"; +import CrazySpinner from "./ui/crazy-spinner"; +import Asksvg from "./ui/asksvg"; +import Rewritesvg from "./ui/rewritesvg"; +import Translatesvg from "./ui/translatesvg"; +import Autocompletesvg from "./ui/autocompletesvg"; +import { motion, AnimatePresence } from "framer-motion"; +import type { Editor } from "@tiptap/core"; +import { useEditor } from "novel"; + +function Aigenerate() { + const [visible, setVisible] = useState(false); + const [generating, setGenerating] = useState(false); + + const { editor } = useEditor(); + const setGeneratingfn = (v: boolean) => setGenerating(v); + + return ( + <div className="z-[60] bg-[#171B1F] fixed left-0 bottom-0 w-screen flex justify-center pt-4 pb-6"> + <motion.div + animate={{ + y: visible ? "30%" : 0, + }} + onClick={() => { + setVisible(!visible); + if (visible) editor?.commands.unsetAIHighlight(); + }} + className={`select-none relative z-[70] rounded-3xl text-[#369DFD] bg-[#21303D] px-4 py-3 text-sm flex gap-2 items-center font-medium whitespace-nowrap overflow-hidden transition-[width] w-[6.25rem] ${visible && "w-[10.55rem]"}`} + > + <Magic className="h-4 w-4 shrink-0 translate-y-[5%]" /> + {visible && generating ? ( + <> + Generating <CrazySpinner /> + </> + ) : visible ? ( + <>Press Commands</> + ) : ( + <>Ask AI</> + )} + </motion.div> + <motion.div + initial={{ + opacity: 0, + y: 20, + }} + animate={{ + y: visible ? "-60%" : 20, + opacity: visible ? 1 : 0, + }} + whileHover={{ scale: 1.05 }} + transition={{ + duration: 0.2, + }} + className="absolute z-50 top-0" + > + <ToolBar setGeneratingfn={setGeneratingfn} editor={editor} /> + <div className="h-8 w-18rem bg-blue-600 blur-[16rem]" /> + </motion.div> + </div> + ); +} + +export default Aigenerate; + +const options = [ + <><Translatesvg />Translate</>, + <><Rewritesvg />Change Tone</>, + <><Asksvg />Ask Gemini</>, + <><Autocompletesvg />Auto Complete</> +]; + +function ToolBar({ + editor, + setGeneratingfn, +}: { + editor: Editor; + setGeneratingfn: (v: boolean) => void; +}) { + const [index, setIndex] = useState(0); + + return ( + <div + className={ + "select-none flex gap-6 bg-[#1F2428] active:scale-[.98] transition rounded-3xl px-1 py-1 text-sm font-medium" + } + > + {options.map((item, idx) => ( + <div + key={idx} + className="relative block h-full w-full px-3 py-2 text-[#989EA4]" + onMouseEnter={() => setIndex(idx)} + > + <AnimatePresence> + {index === idx && ( + <motion.span + onClick={() => + AigenerateContent({ idx, editor, setGeneratingfn }) + } + className="absolute select-none inset-0 block h-full w-full rounded-xl bg-background-light" + layoutId="hoverBackground" + initial={{ opacity: 0 }} + animate={{ + opacity: 1, + transition: { duration: 0.15 }, + }} + exit={{ + opacity: 0, + transition: { duration: 0.15, delay: 0.2 }, + }} + /> + )} + </AnimatePresence> + <div className="select-none flex items-center whitespace-nowrap gap-3 relative z-[60] pointer-events-none"> + {item} + </div> + </div> + ))} + </div> + ); +} + +async function AigenerateContent({ + idx, + editor, + setGeneratingfn, +}: { + idx: number; + editor: Editor; + setGeneratingfn: (v: boolean) => void; +}) { + setGeneratingfn(true); + + 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, maximum 30 characters, do not repeat just continue don't use ... to denote start", + ] + + const res = await fetch("/api/editorai", { + method: "POST", + body: JSON.stringify({ + context: text, + request: request[idx], + }), + }) + const {completion}: {completion: string} = await res.json(); + console.log(completion) + + if (idx === 0 || idx === 1){ + const selectionLength = completion.length + from + editor.chain().focus() + .insertContentAt({from, to}, completion).setTextSelection({from, to: selectionLength}) + .run(); + } else { + const selectionLength = completion.length + to + 1 + editor.chain().focus() + .insertContentAt(to+1, completion).setTextSelection({from, to: selectionLength}) + .run(); + } + + setGeneratingfn(false); +} diff --git a/apps/web/app/(editor)/components/editorcommands.tsx b/apps/web/app/(editor)/components/editorcommands.tsx new file mode 100644 index 00000000..8e80df46 --- /dev/null +++ b/apps/web/app/(editor)/components/editorcommands.tsx @@ -0,0 +1,85 @@ +import React, { useState } from "react"; +import { + EditorBubble, + EditorCommand, + EditorCommandEmpty, + EditorCommandItem, + EditorCommandList, +} from "novel"; +import { suggestionItems } from "./slash-command"; +import { Separator } from "@repo/ui/shadcn/separator"; +import { NodeSelector } from "./selectors/node-selector"; +import { LinkSelector } from "./selectors/link-selector"; +import { TextButtons } from "./selectors/text-buttons"; +import { ColorSelector } from "./selectors/color-selector"; +import { BgColorSelector } from "./selectors/bgcolor-selector"; + +function EditorCommands() { + return ( + <> + <SlashCommand /> + <PopupMenu /> + </> + ); +} + +function SlashCommand() { + return ( + <EditorCommand className="z-50 h-auto max-h-[330px] min-w-[20rem] overflow-y-auto rounded-lg bg-[#1F2428] shadow-md transition-all"> + <EditorCommandEmpty className="px-4 text-lg text-muted-foreground"> + No results + </EditorCommandEmpty> + <EditorCommandList> + {suggestionItems.map((item) => ( + <EditorCommandItem + value={item.title} + onCommand={(val) => item.command(val)} + className="flex w-full items-center space-x-4 rounded-md px-4 py-3 text-left text-sm hover:bg-accent aria-selected:bg-[#21303D] group/command" + key={item.title} + > + <div className="flex h-11 w-11 items-center justify-center rounded-md bg-[#2D343A] group-aria-selected/command:bg-[#369DFD33] stroke-[#989EA4] group-aria-selected/command:stroke-[#369DFD]"> + {item.icon} + </div> + <div> + <p className="font-medium text-[#FFFFFF] group-aria-selected/command:text-[#369DFD]"> + {item.title} + </p> + <p className="text-xs text-muted-foreground group-aria-selected/command:text-[#369DFDB2]"> + {item.description} + </p> + </div> + </EditorCommandItem> + ))} + </EditorCommandList> + </EditorCommand> + ); +} + +function PopupMenu() { + const [openNode, setOpenNode] = useState(false); + const [openColor, setOpenColor] = useState(false); + const [openBgColor, setOpenBgColor] = useState(false); + const [openLink, setOpenLink] = useState(false); + const [openMenu, setOpenMenu] = useState(false); + return ( + <EditorBubble + tippyOptions={{ + placement: openMenu ? "bottom-start" : "top", + }} + className="flex w-fit max-w-[90vw] overflow-hidden bg-[#1F2428] text-white rounded " + > + <Separator orientation="vertical" /> + <NodeSelector open={openNode} onOpenChange={setOpenNode} /> + <Separator orientation="vertical" /> + <LinkSelector open={openLink} onOpenChange={setOpenLink} /> + <Separator orientation="vertical" /> + <TextButtons /> + <Separator orientation="vertical" /> + <ColorSelector open={openColor} onOpenChange={setOpenColor} /> + <Separator orientation="vertical" /> + <BgColorSelector open={openBgColor} onOpenChange={setOpenBgColor} /> + </EditorBubble> + ); +} + +export default EditorCommands; diff --git a/apps/web/app/(editor)/components/extensions.ts b/apps/web/app/(editor)/components/extensions.ts new file mode 100644 index 00000000..0c581154 --- /dev/null +++ b/apps/web/app/(editor)/components/extensions.ts @@ -0,0 +1,141 @@ +import { + AIHighlight, + CharacterCount, + CodeBlockLowlight, + GlobalDragHandle, + HorizontalRule, + Placeholder, + StarterKit, + TaskItem, + TaskList, + TiptapImage, + TiptapLink, + UpdatedImage, + Youtube, +} from "novel/extensions"; +import { UploadImagesPlugin } from "novel/plugins"; + +import { cx } from "class-variance-authority"; +import { common, createLowlight } from "lowlight"; + +//TODO I am using cx here to get tailwind autocomplete working, idk if someone else can write a regex to just capture the class key in objects +const aiHighlight = AIHighlight; +//You can overwrite the placeholder with your own configuration +const placeholder = Placeholder; +const tiptapLink = TiptapLink.configure({ + HTMLAttributes: { + class: cx( + "text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer", + ), + }, +}); + +const tiptapImage = TiptapImage.extend({ + addProseMirrorPlugins() { + return [ + UploadImagesPlugin({ + imageClass: cx("opacity-40 rounded-lg border border-stone-200"), + }), + ]; + }, +}).configure({ + allowBase64: true, + HTMLAttributes: { + class: cx("rounded-lg border border-muted"), + }, +}); + +const updatedImage = UpdatedImage.configure({ + HTMLAttributes: { + class: cx("rounded-lg border border-muted"), + }, +}); + +const taskList = TaskList.configure({ + HTMLAttributes: { + class: cx("not-prose pl-2 "), + }, +}); +const taskItem = TaskItem.configure({ + HTMLAttributes: { + class: cx("flex gap-2 items-start my-4"), + }, + nested: true, +}); + +const horizontalRule = HorizontalRule.configure({ + HTMLAttributes: { + class: cx("mt-4 mb-6 border-t border-muted-foreground"), + }, +}); + +const starterKit = StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: cx("list-disc list-outside leading-3 -mt-2"), + }, + }, + orderedList: { + HTMLAttributes: { + class: cx("list-decimal list-outside leading-3 -mt-2"), + }, + }, + listItem: { + HTMLAttributes: { + class: cx("leading-normal -mb-2"), + }, + }, + blockquote: { + HTMLAttributes: { + class: cx("border-l-4 border-primary"), + }, + }, + codeBlock: { + HTMLAttributes: { + class: cx("rounded-md bg-muted text-muted-foreground border p-5 font-mono font-medium"), + }, + }, + code: { + HTMLAttributes: { + class: cx("rounded-md bg-muted px-1.5 py-1 font-mono font-medium"), + spellcheck: "false", + }, + }, + horizontalRule: false, + dropcursor: { + color: "#DBEAFE", + width: 4, + }, + gapcursor: false, +}); + +const codeBlockLowlight = CodeBlockLowlight.configure({ + // configure lowlight: common / all / use highlightJS in case there is a need to specify certain language grammars only + // common: covers 37 language grammars which should be good enough in most cases + lowlight: createLowlight(common), +}); + +const youtube = Youtube.configure({ + HTMLAttributes: { + class: cx("rounded-lg border border-muted"), + }, + inline: false, +}); + +const characterCount = CharacterCount.configure(); + +export const defaultExtensions = [ + starterKit, + placeholder, + tiptapLink, + tiptapImage, + updatedImage, + taskList, + taskItem, + horizontalRule, + aiHighlight, + codeBlockLowlight, + youtube, + characterCount, + GlobalDragHandle, +]; diff --git a/apps/web/app/(editor)/components/image-upload.ts b/apps/web/app/(editor)/components/image-upload.ts new file mode 100644 index 00000000..d10be168 --- /dev/null +++ b/apps/web/app/(editor)/components/image-upload.ts @@ -0,0 +1,50 @@ +import { createImageUpload } from "novel/plugins"; +import { toast } from "sonner"; + +const onUpload = (file: File) => { + //Endpoint: to upload the image + const promise = fetch("", { + method: "POST", + body: file, + }); + + return new Promise((resolve, reject) => { + toast.promise( + promise.then(async (res) => { + if (res.status === 200) { + const { url } = (await res.json()) as { url: string }; + const image = new Image(); + image.src = url; + image.onload = () => { + resolve(url); + }; + } else { + throw new Error("Error uploading image. Please try again."); + } + }), + { + loading: "Uploading image...", + success: "Image uploaded successfully.", + error: (e) => { + reject(e); + return e.message; + }, + }, + ); + }); +}; + +export const uploadFn = createImageUpload({ + onUpload, + validateFn: (file) => { + if (!file.type.includes("image/")) { + toast.error("File type not supported."); + return false; + } + if (file.size / 1024 / 1024 > 20) { + toast.error("File size too big (max 20MB)."); + return false; + } + return true; + }, +}); diff --git a/apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx b/apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx new file mode 100644 index 00000000..77da0f03 --- /dev/null +++ b/apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx @@ -0,0 +1,107 @@ +import { Check, ChevronDown } from "lucide-react"; +import { EditorBubbleItem, useEditor } from "novel"; + +import { Button } from "@repo/ui/shadcn/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover"; +export interface BubbleColorMenuItem { + name: string; + color: string; +} + +const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [ + { + name: "Default", + color: "var(--novel-highlight-default)", + }, + { + name: "Purple", + color: "var(--novel-highlight-purple)", + }, + { + name: "Red", + color: "var(--novel-highlight-red)", + }, + { + name: "Yellow", + color: "var(--novel-highlight-yellow)", + }, + { + name: "Blue", + color: "var(--novel-highlight-blue)", + }, + { + name: "Green", + color: "var(--novel-highlight-green)", + }, + { + name: "Orange", + color: "var(--novel-highlight-orange)", + }, + { + name: "Pink", + color: "var(--novel-highlight-pink)", + }, + { + name: "Gray", + color: "var(--novel-highlight-gray)", + }, +]; + +interface ColorSelectorProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const BgColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => { + const { editor } = useEditor(); + + if (!editor) return null; + + const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => editor.isActive("highlight", { color })); + + return ( + <Popover modal={true} open={open} onOpenChange={onOpenChange}> + <PopoverTrigger asChild> + <Button size="sm" className="gap-2 rounded-none" variant="ghost"> + <span + className="rounded-sm px-1" + style={{ + backgroundColor: activeHighlightItem?.color, + }} + > + A + </span> + <ChevronDown className="h-4 w-4" /> + </Button> + </PopoverTrigger> + + <PopoverContent + sideOffset={5} + className="my-1 border-none bg-[#1F2428] flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl " + align="start" + > + <div> + <div className="my-1 px-2 text-sm font-semibold text-muted-foreground">Background</div> + {HIGHLIGHT_COLORS.map(({ name, color }) => ( + <EditorBubbleItem + key={name} + onSelect={() => { + editor.commands.unsetHighlight(); + name !== "Default" && editor.commands.setHighlight({ color }); + }} + className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-[#21303D]" + > + <div className="flex items-center gap-2"> + <div className="rounded-sm px-2 py-px font-medium" style={{ backgroundColor: color }}> + A + </div> + <span>{name}</span> + </div> + {editor.isActive("highlight", { color }) && <Check className="h-4 w-4" />} + </EditorBubbleItem> + ))} + </div> + </PopoverContent> + </Popover> + ); +}; diff --git a/apps/web/app/(editor)/components/selectors/color-selector.tsx b/apps/web/app/(editor)/components/selectors/color-selector.tsx new file mode 100644 index 00000000..557c4255 --- /dev/null +++ b/apps/web/app/(editor)/components/selectors/color-selector.tsx @@ -0,0 +1,111 @@ +import { Check, ChevronDown } from "lucide-react"; +import { EditorBubbleItem, useEditor } from "novel"; + +import { Button } from "@repo/ui/shadcn/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover"; +export interface BubbleColorMenuItem { + name: string; + color: string; +} + +const TEXT_COLORS: BubbleColorMenuItem[] = [ + { + name: "Default", + color: "var(--novel-black)", + }, + { + name: "Purple", + color: "#9333EA", + }, + { + name: "Red", + color: "#E00000", + }, + { + name: "Yellow", + color: "#EAB308", + }, + { + name: "Blue", + color: "#2563EB", + }, + { + name: "Green", + color: "#008A00", + }, + { + name: "Orange", + color: "#FFA500", + }, + { + name: "Pink", + color: "#BA4081", + }, + { + name: "Gray", + color: "#A8A29E", + }, +]; + + +interface ColorSelectorProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => { + const { editor } = useEditor(); + + if (!editor) return null; + const activeColorItem = TEXT_COLORS.find(({ color }) => editor.isActive("textStyle", { color })); + + return ( + <Popover modal={true} open={open} onOpenChange={onOpenChange}> + <PopoverTrigger asChild> + <Button size="sm" className="gap-2 rounded-none" variant="ghost"> + <span + className="rounded-sm px-1" + style={{ + color: activeColorItem?.color + }} + > + A + </span> + <ChevronDown className="h-4 w-4" /> + </Button> + </PopoverTrigger> + + <PopoverContent + sideOffset={5} + className="my-1 border-none bg-[#1F2428] flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl " + align="start" + > + <div className="flex flex-col"> + <div className="my-1 px-2 text-sm font-semibold text-muted-foreground">Color</div> + {TEXT_COLORS.map(({ name, color }) => ( + <EditorBubbleItem + key={name} + onSelect={() => { + editor.commands.unsetColor(); + name !== "Default" && + editor + .chain() + .focus() + .setColor(color || "") + .run(); + }} + className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent" + > + <div className="flex items-center gap-2"> + <div className="rounded-sm px-2 py-px font-medium" style={{ color }}> + A + </div> + <span>{name}</span> + </div> + </EditorBubbleItem> + ))} + </div> + </PopoverContent> + </Popover> + ); +}; diff --git a/apps/web/app/(editor)/components/selectors/link-selector.tsx b/apps/web/app/(editor)/components/selectors/link-selector.tsx new file mode 100644 index 00000000..3dc28266 --- /dev/null +++ b/apps/web/app/(editor)/components/selectors/link-selector.tsx @@ -0,0 +1,95 @@ +import { Button } from "@repo/ui/shadcn/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover"; +import { cn } from "@repo/ui/lib/utils"; +import { Check, Trash } from "lucide-react"; +import { useEditor } from "novel"; +import { useEffect, useRef } from "react"; + +export function isValidUrl(url: string) { + try { + new URL(url); + return true; + } catch (_e) { + return false; + } +} +export function getUrlFromString(str: string) { + if (isValidUrl(str)) return str; + try { + if (str.includes(".") && !str.includes(" ")) { + return new URL(`https://${str}`).toString(); + } + } catch (_e) { + return null; + } +} +interface LinkSelectorProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => { + const inputRef = useRef<HTMLInputElement>(null); + const { editor } = useEditor(); + + // Autofocus on input by default + useEffect(() => { + inputRef.current?.focus(); + }); + if (!editor) return null; + + return ( + <Popover modal={true} open={open} onOpenChange={onOpenChange}> + <PopoverTrigger asChild> + <Button size="sm" variant="ghost" className="gap-2 rounded-none border-none"> + <p className="text-base">↗</p> + <p + className={cn("underline decoration-stone-400 underline-offset-4", { + "text-blue-500": editor.isActive("link"), + })} + > + Link + </p> + </Button> + </PopoverTrigger> + <PopoverContent align="start" className="w-60 p-0" sideOffset={10}> + <form + onSubmit={(e) => { + const target = e.currentTarget as HTMLFormElement; + e.preventDefault(); + const input = target[0] as HTMLInputElement; + const url = getUrlFromString(input.value); + url && editor.chain().focus().setLink({ href: url }).run(); + }} + className="flex p-1 " + > + <input + ref={inputRef} + type="text" + placeholder="Paste a link" + className="flex-1 bg-background p-1 text-sm outline-none" + defaultValue={editor.getAttributes("link").href || ""} + /> + {editor.getAttributes("link").href ? ( + <Button + size="icon" + variant="outline" + type="button" + className="flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800" + onClick={() => { + editor.chain().focus().unsetLink().run(); + inputRef.current.value = ""; + }} + > + <Trash className="h-4 w-4" /> + </Button> + ) : ( + <Button size="icon" className="h-8"> + <Check className="h-4 w-4" /> + </Button> + )} + </form> + </PopoverContent> + </Popover> + ); +}; diff --git a/apps/web/app/(editor)/components/selectors/node-selector.tsx b/apps/web/app/(editor)/components/selectors/node-selector.tsx new file mode 100644 index 00000000..c6092b68 --- /dev/null +++ b/apps/web/app/(editor)/components/selectors/node-selector.tsx @@ -0,0 +1,126 @@ +import { + Check, + CheckSquare, + ChevronDown, + Code, + Heading1, + Heading2, + Heading3, + ListOrdered, + type LucideIcon, + TextIcon, + TextQuote, +} from "lucide-react"; +import { EditorBubbleItem, useEditor } from "novel"; + +import { Button } from "@repo/ui/shadcn/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover"; + +export type SelectorItem = { + name: string; + icon: LucideIcon; + command: (editor: ReturnType<typeof useEditor>["editor"]) => void; + isActive: (editor: ReturnType<typeof useEditor>["editor"]) => boolean; +}; + +const items: SelectorItem[] = [ + { + name: "Text", + icon: TextIcon, + command: (editor) => editor.chain().focus().clearNodes().run(), + // I feel like there has to be a more efficient way to do this – feel free to PR if you know how! + isActive: (editor) => + editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"), + }, + { + name: "Heading 1", + icon: Heading1, + command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(), + isActive: (editor) => editor.isActive("heading", { level: 1 }), + }, + { + name: "Heading 2", + icon: Heading2, + command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(), + isActive: (editor) => editor.isActive("heading", { level: 2 }), + }, + { + name: "Heading 3", + icon: Heading3, + command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(), + isActive: (editor) => editor.isActive("heading", { level: 3 }), + }, + { + name: "To-do List", + icon: CheckSquare, + command: (editor) => editor.chain().focus().clearNodes().toggleTaskList().run(), + isActive: (editor) => editor.isActive("taskItem"), + }, + { + name: "Bullet List", + icon: ListOrdered, + command: (editor) => editor.chain().focus().clearNodes().toggleBulletList().run(), + isActive: (editor) => editor.isActive("bulletList"), + }, + { + name: "Numbered List", + icon: ListOrdered, + command: (editor) => editor.chain().focus().clearNodes().toggleOrderedList().run(), + isActive: (editor) => editor.isActive("orderedList"), + }, + { + name: "Quote", + icon: TextQuote, + command: (editor) => editor.chain().focus().clearNodes().toggleBlockquote().run(), + isActive: (editor) => editor.isActive("blockquote"), + }, + { + name: "Code", + icon: Code, + command: (editor) => editor.chain().focus().clearNodes().toggleCodeBlock().run(), + isActive: (editor) => editor.isActive("codeBlock"), + }, +]; +interface NodeSelectorProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => { + const { editor } = useEditor(); + if (!editor) return null; + const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? { + name: "Multiple", + }; + + return ( + <Popover modal={true} open={open} onOpenChange={onOpenChange}> + <PopoverTrigger asChild className="gap-2 rounded-none border-none hover:bg-accent focus:ring-0"> + <Button size="sm" variant="ghost" className="gap-2"> + <span className="whitespace-nowrap text-sm">{activeItem.name}</span> + <ChevronDown className="h-4 w-4" /> + </Button> + </PopoverTrigger> + <PopoverContent sideOffset={5} align="start" className="w-48 p-1 border-none bg-[#1F2428]"> + {items.map((item) => ( + <EditorBubbleItem + key={item.name} + onSelect={(editor) => { + item.command(editor); + onOpenChange(false); + }} + className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent" + > + <div className="flex items-center space-x-2"> + <div className="rounded-sm p-1"> + <item.icon className="h-3 w-3" /> + </div> + <span>{item.name}</span> + </div> + {activeItem.name === item.name && <Check className="h-4 w-4" />} + </EditorBubbleItem> + ))} + </PopoverContent> + </Popover> + ); +}; diff --git a/apps/web/app/(editor)/components/selectors/text-buttons.tsx b/apps/web/app/(editor)/components/selectors/text-buttons.tsx new file mode 100644 index 00000000..f75d7b3c --- /dev/null +++ b/apps/web/app/(editor)/components/selectors/text-buttons.tsx @@ -0,0 +1,63 @@ +import { Button } from "@repo/ui/shadcn/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover"; +import { BoldIcon, CodeIcon, ItalicIcon, StrikethroughIcon, UnderlineIcon } from "lucide-react"; +import { EditorBubbleItem, useEditor } from "novel"; +import type { SelectorItem } from "./node-selector"; +import { cn } from "@repo/ui/lib/utils"; + +export const TextButtons = () => { + const { editor } = useEditor(); + if (!editor) return null; + const items: SelectorItem[] = [ + { + name: "bold", + isActive: (editor) => editor.isActive("bold"), + command: (editor) => editor.chain().focus().toggleBold().run(), + icon: BoldIcon, + }, + { + name: "italic", + isActive: (editor) => editor.isActive("italic"), + command: (editor) => editor.chain().focus().toggleItalic().run(), + icon: ItalicIcon, + }, + { + name: "underline", + isActive: (editor) => editor.isActive("underline"), + command: (editor) => editor.chain().focus().toggleUnderline().run(), + icon: UnderlineIcon, + }, + { + name: "strike", + isActive: (editor) => editor.isActive("strike"), + command: (editor) => editor.chain().focus().toggleStrike().run(), + icon: StrikethroughIcon, + }, + { + name: "code", + isActive: (editor) => editor.isActive("code"), + command: (editor) => editor.chain().focus().toggleCode().run(), + icon: CodeIcon, + }, + ]; + return ( + <div className="flex"> + {items.map((item) => ( + <EditorBubbleItem + key={item.name} + onSelect={(editor) => { + item.command(editor); + }} + > + <Button size="sm" className="rounded-none" variant="ghost"> + <item.icon + className={cn("h-4 w-4", { + "text-blue-500": item.isActive(editor), + })} + /> + </Button> + </EditorBubbleItem> + ))} + </div> + ); +}; diff --git a/apps/web/app/(editor)/components/slash-command.tsx b/apps/web/app/(editor)/components/slash-command.tsx new file mode 100644 index 00000000..1bfb1690 --- /dev/null +++ b/apps/web/app/(editor)/components/slash-command.tsx @@ -0,0 +1,163 @@ +import { + CheckSquare, + Code, + Heading1, + Heading2, + Heading3, + ImageIcon, + List, + ListOrdered, + MessageSquarePlus, + Text, + TextQuote, + Youtube +} from "lucide-react"; +import { createSuggestionItems } from "novel/extensions"; +import { Command, renderItems } from "novel/extensions"; +import { uploadFn } from "./image-upload"; + +export const suggestionItems = createSuggestionItems([ + { + title: "Send Feedback", + description: "Let us know how we can improve.", + icon: <MessageSquarePlus stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).run(); + window.open("/feedback", "_blank"); + }, + }, + { + title: "Text", + description: "Just start typing with plain text.", + searchTerms: ["p", "paragraph"], + icon: <Text stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run(); + }, + }, + { + title: "To-do List", + description: "Track tasks with a to-do list.", + searchTerms: ["todo", "task", "list", "check", "checkbox"], + icon: <CheckSquare stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleTaskList().run(); + }, + }, + { + title: "Heading 1", + description: "Big section heading.", + searchTerms: ["title", "big", "large"], + icon: <Heading1 stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); + }, + }, + { + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: <Heading2 stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); + }, + }, + { + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: <Heading3 stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); + }, + }, + { + title: "Bullet List", + description: "Create a simple bullet list.", + searchTerms: ["unordered", "point"], + icon: <List stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleBulletList().run(); + }, + }, + { + title: "Numbered List", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: <ListOrdered stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + }, + }, + { + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: <TextQuote stroke="inherit" size={18} />, + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(), + }, + { + title: "Code", + description: "Capture a code snippet.", + searchTerms: ["codeblock"], + icon: <Code stroke="inherit" size={18} />, + command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + // { + // title: "Image", + // description: "Upload an image from your computer.", + // searchTerms: ["photo", "picture", "media"], + // icon: <ImageIcon stroke="inherit" size={18} />, + // command: ({ editor, range }) => { + // editor.chain().focus().deleteRange(range).run(); + // // upload image + // const input = document.createElement("input"); + // input.type = "file"; + // input.accept = "image/*"; + // input.onchange = async () => { + // if (input.files?.length) { + // const file = input.files[0]; + // const pos = editor.view.state.selection.from; + // uploadFn(file, editor.view, pos); + // } + // }; + // input.click(); + // }, + // }, + // { + // title: "Youtube", + // description: "Embed a Youtube video.", + // searchTerms: ["video", "youtube", "embed"], + // icon: <Youtube stroke="inherit" size={18} />, + // command: ({ editor, range }) => { + // const videoLink = prompt("Please enter Youtube Video Link"); + // //From https://regexr.com/3dj5t + // const ytregex = new RegExp( + // /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/, + // ); + + // if (ytregex.test(videoLink)) { + // editor + // .chain() + // .focus() + // .deleteRange(range) + // .setYoutubeVideo({ + // src: videoLink, + // }) + // .run(); + // } else { + // if (videoLink !== null) { + // alert("Please enter a correct Youtube Video Link"); + // } + // } + // }, + // }, +]); + +export const slashCommand = Command.configure({ + suggestion: { + items: () => suggestionItems, + render: renderItems, + }, +}); diff --git a/apps/web/app/(editor)/components/topbar.tsx b/apps/web/app/(editor)/components/topbar.tsx new file mode 100644 index 00000000..49b5179c --- /dev/null +++ b/apps/web/app/(editor)/components/topbar.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { + AnimatePresence, + useMotionValueEvent, + useScroll, + motion, +} from "framer-motion"; +import React, { useState } from "react"; + +function Topbar({ + charsCount, + saveStatus, +}: { + charsCount: number | undefined; + saveStatus: string; +}) { + const [visible, setVisible] = useState(true); + + const { scrollYProgress } = useScroll(); + useMotionValueEvent(scrollYProgress, "change", (current) => { + if (typeof current === "number") { + let direction = current! - scrollYProgress.getPrevious()!; + + if (direction < 0 || direction === 1) { + setVisible(true); + } else { + setVisible(false); + } + } + }); + return ( + <div className="fixed left-0 top-0 z-10"> + <AnimatePresence mode="wait"> + <motion.div + initial={{ + opacity: 1, + y: -150, + }} + animate={{ + y: visible ? 0 : -150, + opacity: visible ? 1 : 0, + }} + transition={{ + duration: 0.2, + }} + className="flex flex-col items-center" + > + <div className="gap-2 w-screen flex bg-[#171B1F] justify-center items-center pt-6 pb-4"> + <div className="rounded-lg bg-[#21303D] px-2 py-1 text-sm text-muted-foreground"> + Untitled + </div> + <div className="rounded-lg bg-[#21303D] px-2 py-1 text-sm text-muted-foreground"> + {saveStatus} + </div> + {charsCount && ( + <div className="rounded-lg bg-[#21303D] px-2 py-1 text-sm text-muted-foreground"> + {`${charsCount} words`} + </div> + )} + </div> + </motion.div> + </AnimatePresence> + </div> + ); +} + +export default Topbar; diff --git a/apps/web/app/(editor)/components/ui/asksvg.tsx b/apps/web/app/(editor)/components/ui/asksvg.tsx new file mode 100644 index 00000000..aa38fe08 --- /dev/null +++ b/apps/web/app/(editor)/components/ui/asksvg.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +function Asksvg() { + return ( + <svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M5.90925 4.63925C6.7875 3.8705 8.2125 3.8705 9.09075 4.63925C9.96975 5.408 9.96975 6.6545 9.09075 7.42325C8.9385 7.5575 8.76825 7.66775 8.58825 7.75475C8.0295 8.0255 7.50075 8.504 7.50075 9.125V9.6875M14.25 8C14.25 8.88642 14.0754 9.76417 13.7362 10.5831C13.397 11.4021 12.8998 12.1462 12.273 12.773C11.6462 13.3998 10.9021 13.897 10.0831 14.2362C9.26417 14.5754 8.38642 14.75 7.5 14.75C6.61358 14.75 5.73583 14.5754 4.91689 14.2362C4.09794 13.897 3.35382 13.3998 2.72703 12.773C2.10023 12.1462 1.60303 11.4021 1.26381 10.5831C0.924594 9.76417 0.75 8.88642 0.75 8C0.75 6.20979 1.46116 4.4929 2.72703 3.22703C3.9929 1.96116 5.70979 1.25 7.5 1.25C9.29021 1.25 11.0071 1.96116 12.273 3.22703C13.5388 4.4929 14.25 6.20979 14.25 8ZM7.5 11.9375H7.506V11.9435H7.5V11.9375Z" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> +</svg> + + ) +} + +export default Asksvg
\ No newline at end of file diff --git a/apps/web/app/(editor)/components/ui/autocompletesvg.tsx b/apps/web/app/(editor)/components/ui/autocompletesvg.tsx new file mode 100644 index 00000000..c433fcad --- /dev/null +++ b/apps/web/app/(editor)/components/ui/autocompletesvg.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +function Autocompletesvg() { + return ( + <svg width="15" height="10" viewBox="0 0 15 10" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M1.3125 1.0625H13.6875M1.3125 5H13.6875M1.3125 8.9375H7.5" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> +</svg> + + ) +} + +export default Autocompletesvg
\ No newline at end of file diff --git a/apps/web/app/(editor)/components/ui/crazy-spinner.tsx b/apps/web/app/(editor)/components/ui/crazy-spinner.tsx new file mode 100644 index 00000000..2e95deee --- /dev/null +++ b/apps/web/app/(editor)/components/ui/crazy-spinner.tsx @@ -0,0 +1,11 @@ +const CrazySpinner = () => { + return ( + <div className="flex justify-center items-center gap-1.5"> + <div className="h-1.5 w-1.5 animate-ping rounded-full bg-[#369DFD] [animation-delay:-0.4s]" /> + <div className="h-1.5 w-1.5 animate-ping rounded-full bg-[#369DFD] [animation-delay:-0.2s]" /> + <div className="h-1.5 w-1.5 animate-ping rounded-full bg-[#369DFD]" /> + </div> + ); +}; + +export default CrazySpinner; diff --git a/apps/web/app/(editor)/components/ui/magic.tsx b/apps/web/app/(editor)/components/ui/magic.tsx new file mode 100644 index 00000000..04dce39e --- /dev/null +++ b/apps/web/app/(editor)/components/ui/magic.tsx @@ -0,0 +1,8 @@ +export default function Magic({ className }: { className: string }) { + return ( +<svg width="18" height="19" className={className} viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M6.49998 3.25C6.63578 3.25003 6.76788 3.29429 6.87629 3.37608C6.98469 3.45788 7.06351 3.57275 7.10081 3.70333L7.77831 6.075C7.92418 6.58576 8.19785 7.05092 8.57345 7.42652C8.94906 7.80213 9.41421 8.07579 9.92498 8.22167L12.2966 8.89917C12.4271 8.93655 12.5419 9.0154 12.6236 9.1238C12.7053 9.2322 12.7495 9.36425 12.7495 9.5C12.7495 9.63574 12.7053 9.7678 12.6236 9.8762C12.5419 9.9846 12.4271 10.0634 12.2966 10.1008L9.92498 10.7783C9.41421 10.9242 8.94906 11.1979 8.57345 11.5735C8.19785 11.9491 7.92418 12.4142 7.77831 12.925L7.10081 15.2967C7.06343 15.4272 6.98458 15.5419 6.87618 15.6236C6.76778 15.7054 6.63572 15.7495 6.49998 15.7495C6.36423 15.7495 6.23218 15.7054 6.12378 15.6236C6.01538 15.5419 5.93653 15.4272 5.89914 15.2967L5.22164 12.925C5.07577 12.4142 4.80211 11.9491 4.4265 11.5735C4.05089 11.1979 3.58574 10.9242 3.07498 10.7783L0.70331 10.1008C0.572814 10.0634 0.458036 9.9846 0.376329 9.8762C0.294622 9.7678 0.250427 9.63574 0.250427 9.5C0.250427 9.36425 0.294622 9.2322 0.376329 9.1238C0.458036 9.0154 0.572814 8.93655 0.70331 8.89917L3.07498 8.22167C3.58574 8.07579 4.05089 7.80213 4.4265 7.42652C4.80211 7.05092 5.07577 6.58576 5.22164 6.075L5.89914 3.70333C5.93644 3.57275 6.01526 3.45788 6.12367 3.37608C6.23208 3.29429 6.36417 3.25003 6.49998 3.25ZM14 0.75C14.1394 0.749922 14.2749 0.79647 14.3848 0.882239C14.4947 0.968007 14.5728 1.08807 14.6066 1.22333L14.8216 2.08667C15.0183 2.87 15.63 3.48167 16.4133 3.67833L17.2766 3.89333C17.4122 3.9269 17.5325 4.00488 17.6186 4.11483C17.7046 4.22479 17.7514 4.36038 17.7514 4.5C17.7514 4.63962 17.7046 4.77521 17.6186 4.88517C17.5325 4.99512 17.4122 5.0731 17.2766 5.10667L16.4133 5.32167C15.63 5.51833 15.0183 6.13 14.8216 6.91333L14.6066 7.77667C14.5731 7.91219 14.4951 8.03257 14.3851 8.11861C14.2752 8.20465 14.1396 8.2514 14 8.2514C13.8604 8.2514 13.7248 8.20465 13.6148 8.11861C13.5049 8.03257 13.4269 7.91219 13.3933 7.77667L13.1783 6.91333C13.0822 6.52869 12.8833 6.17741 12.6029 5.89706C12.3226 5.61671 11.9713 5.41782 11.5866 5.32167L10.7233 5.10667C10.5878 5.0731 10.4674 4.99512 10.3814 4.88517C10.2953 4.77521 10.2486 4.63962 10.2486 4.5C10.2486 4.36038 10.2953 4.22479 10.3814 4.11483C10.4674 4.00488 10.5878 3.9269 10.7233 3.89333L11.5866 3.67833C11.9713 3.58218 12.3226 3.38329 12.6029 3.10294C12.8833 2.82258 13.0822 2.47131 13.1783 2.08667L13.3933 1.22333C13.4271 1.08807 13.5052 0.968007 13.6152 0.882239C13.7251 0.79647 13.8605 0.749922 14 0.75ZM12.75 12C12.8812 11.9999 13.0092 12.0412 13.1157 12.1179C13.2222 12.1946 13.3018 12.303 13.3433 12.4275L13.6716 13.4133C13.7966 13.7858 14.0883 14.0792 14.4616 14.2033L15.4475 14.5325C15.5716 14.5742 15.6795 14.6538 15.756 14.7601C15.8324 14.8664 15.8736 14.9941 15.8736 15.125C15.8736 15.2559 15.8324 15.3836 15.756 15.4899C15.6795 15.5962 15.5716 15.6758 15.4475 15.7175L14.4616 16.0467C14.0891 16.1717 13.7958 16.4633 13.6716 16.8367L13.3425 17.8225C13.3008 17.9466 13.2212 18.0545 13.1149 18.131C13.0086 18.2075 12.8809 18.2486 12.75 18.2486C12.619 18.2486 12.4914 18.2075 12.3851 18.131C12.2788 18.0545 12.1992 17.9466 12.1575 17.8225L11.8283 16.8367C11.7669 16.6527 11.6636 16.4856 11.5265 16.3485C11.3894 16.2114 11.2222 16.1081 11.0383 16.0467L10.0525 15.7175C9.92834 15.6758 9.82043 15.5962 9.74398 15.4899C9.66752 15.3836 9.6264 15.2559 9.6264 15.125C9.6264 14.9941 9.66752 14.8664 9.74398 14.7601C9.82043 14.6538 9.92834 14.5742 10.0525 14.5325L11.0383 14.2033C11.4108 14.0783 11.7041 13.7867 11.8283 13.4133L12.1575 12.4275C12.1989 12.3031 12.2784 12.1949 12.3848 12.1182C12.4911 12.0414 12.6189 12.0001 12.75 12Z" fill="#369DFD"/> +</svg> + + ); +} diff --git a/apps/web/app/(editor)/components/ui/rewritesvg.tsx b/apps/web/app/(editor)/components/ui/rewritesvg.tsx new file mode 100644 index 00000000..fad9eb90 --- /dev/null +++ b/apps/web/app/(editor)/components/ui/rewritesvg.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +function Rewritesvg() { + return ( + <svg width="17" height="14" viewBox="0 0 17 14" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M11.5172 5.01108H15.2612L12.8755 2.62383C12.1074 1.85573 11.1506 1.30337 10.1014 1.02228C9.05214 0.74119 7.94738 0.741272 6.89818 1.02252C5.84897 1.30377 4.8923 1.85627 4.12433 2.62448C3.35635 3.39269 2.80415 4.34954 2.52323 5.39883M1.73873 12.7331V8.98908M1.73873 8.98908H5.48273M1.73873 8.98908L4.12373 11.3763C4.89181 12.1444 5.84857 12.6968 6.89782 12.9779C7.94706 13.259 9.05182 13.2589 10.101 12.9776C11.1502 12.6964 12.1069 12.1439 12.8749 11.3757C13.6428 10.6075 14.1951 9.65062 14.476 8.60133M15.2612 1.26708V5.00958" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> +</svg> + ) +} + +export default Rewritesvg
\ No newline at end of file diff --git a/apps/web/app/(editor)/components/ui/translatesvg.tsx b/apps/web/app/(editor)/components/ui/translatesvg.tsx new file mode 100644 index 00000000..cde82da6 --- /dev/null +++ b/apps/web/app/(editor)/components/ui/translatesvg.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +function Translatesvg() { + return ( + <svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M6.375 14.75L10.3125 6.3125L14.25 14.75M7.5 12.5H13.125M0.75 3.21575C2.2428 3.02999 3.74569 2.93706 5.25 2.9375M5.25 2.9375C6.09 2.9375 6.92475 2.966 7.7505 3.023M5.25 2.9375V1.25M7.7505 3.023C6.882 6.9935 4.2675 10.31 0.75 12.1265M7.7505 3.023C8.4225 3.06875 9.08925 3.13325 9.75 3.21575M6.30825 9.587C5.07822 8.33647 4.10335 6.85849 3.438 5.2355" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> +</svg> + + ) +} + +export default Translatesvg
\ No newline at end of file diff --git a/apps/web/app/(editor)/editor.tsx b/apps/web/app/(editor)/editor.tsx new file mode 100644 index 00000000..5b4a60ce --- /dev/null +++ b/apps/web/app/(editor)/editor.tsx @@ -0,0 +1,54 @@ +"use client"; +import { defaultEditorContent } from "./lib/content"; +import { EditorContent, EditorRoot, type JSONContent } from "novel"; +import { ImageResizer } from "novel/extensions"; +import { useEffect, useState } from "react"; +import { defaultExtensions } from "./components/extensions"; + +import { slashCommand } from "./components/slash-command"; +import { Updates } from "./lib/debouncedsave"; +import { editorProps } from "./lib/editorprops"; +import EditorCommands from "./components/editorcommands"; +import Aigenerate from "./components/aigenerate"; +import { useMotionValueEvent, useScroll } from "framer-motion"; +import Topbar from "./components/topbar"; + +const Editor = () => { + const [initialContent, setInitialContent] = useState<null | JSONContent>( + null + ); + const [saveStatus, setSaveStatus] = useState("Saved"); + const [charsCount, setCharsCount] = useState(); + const [visible, setVisible] = useState(true); + + useEffect(() => { + const content = window.localStorage.getItem("novel-content"); + if (content) setInitialContent(JSON.parse(content)); + else setInitialContent(defaultEditorContent); + }, []); + + if (!initialContent) return null; + + return ( + <div className="relative w-full max-w-screen-xl"> + <Topbar charsCount={charsCount} saveStatus={saveStatus} /> + <EditorRoot> + <EditorContent + initialContent={initialContent} + extensions={[...defaultExtensions, slashCommand]} + className="min-h-[55vh] mt-[8vh] w-full max-w-screen-xl bg-[#171B1F] mb-[40vh]" + editorProps={editorProps} + onUpdate={({ editor }) => { + Updates({ editor, setCharsCount, setSaveStatus }); + }} + slotAfter={<ImageResizer />} + > + <EditorCommands /> + <Aigenerate /> + </EditorContent> + </EditorRoot> + </div> + ); +}; + +export default Editor; diff --git a/apps/web/app/(editor)/editor/page.tsx b/apps/web/app/(editor)/editor/page.tsx new file mode 100644 index 00000000..d0298065 --- /dev/null +++ b/apps/web/app/(editor)/editor/page.tsx @@ -0,0 +1,8 @@ +import TailwindAdvancedEditor from "../editor"; +export default function Page() { + return ( + <div className="flex min-h-screen flex-col items-center bg-[#171B1F]"> + <TailwindAdvancedEditor /> + </div> + ); +} diff --git a/apps/web/app/(editor)/layout.tsx b/apps/web/app/(editor)/layout.tsx new file mode 100644 index 00000000..1bf97715 --- /dev/null +++ b/apps/web/app/(editor)/layout.tsx @@ -0,0 +1,12 @@ +import "./styles/prosemirror.css"; +import "./styles/globals.css" +import type { ReactNode } from "react"; + + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + <div className="dark"> + {children} + </div> + ); +} diff --git a/apps/web/app/(editor)/lib/content.ts b/apps/web/app/(editor)/lib/content.ts new file mode 100644 index 00000000..6464cfa1 --- /dev/null +++ b/apps/web/app/(editor)/lib/content.ts @@ -0,0 +1,231 @@ +export const defaultEditorContent = { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Introducing Novel" }], + }, + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://github.com/steven-tey/novel", + target: "_blank", + }, + }, + ], + text: "Novel", + }, + { + type: "text", + text: " is a Notion-style WYSIWYG editor with AI-powered autocompletion. Built with ", + }, + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://tiptap.dev/", + target: "_blank", + }, + }, + ], + text: "Tiptap", + }, + { type: "text", text: " + " }, + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://sdk.vercel.ai/docs", + target: "_blank", + }, + }, + ], + text: "Vercel AI SDK", + }, + { type: "text", text: "." }, + ], + }, + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Installation" }], + }, + { + type: "codeBlock", + attrs: { language: null }, + content: [{ type: "text", text: "npm i novel" }], + }, + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Usage" }], + }, + { + type: "codeBlock", + attrs: { language: null }, + content: [ + { + type: "text", + text: 'import { Editor } from "novel";\n\nexport default function App() {\n return (\n <Editor />\n )\n}', + }, + ], + }, + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Features" }], + }, + { + type: "orderedList", + attrs: { tight: true, start: 1 }, + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Slash menu & bubble menu" }], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "AI autocomplete (type " }, + { type: "text", marks: [{ type: "code" }], text: "++" }, + { + type: "text", + text: " to activate, or select from slash menu)", + }, + ], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Image uploads (drag & drop / copy & paste, or select from slash menu) ", + }, + ], + }, + ], + }, + ], + }, + { + type: "image", + attrs: { + src: "https://public.blob.vercel-storage.com/pJrjXbdONOnAeZAZ/banner-2wQk82qTwyVgvlhTW21GIkWgqPGD2C.png", + alt: "banner.png", + title: "banner.png", + width: null, + height: null, + }, + }, + { type: "horizontalRule" }, + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Learn more" }], + }, + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Star us on " }, + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://github.com/steven-tey/novel", + target: "_blank", + }, + }, + ], + text: "GitHub", + }, + ], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Install the " }, + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://www.npmjs.com/package/novel", + target: "_blank", + }, + }, + ], + text: "NPM package", + }, + ], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://vercel.com/templates/next.js/novel", + target: "_blank", + }, + }, + ], + text: "Deploy your own", + }, + { type: "text", text: " to Vercel" }, + ], + }, + ], + }, + ], + }, + ], +}; diff --git a/apps/web/app/(editor)/lib/debouncedsave.ts b/apps/web/app/(editor)/lib/debouncedsave.ts new file mode 100644 index 00000000..6490c6c4 --- /dev/null +++ b/apps/web/app/(editor)/lib/debouncedsave.ts @@ -0,0 +1,20 @@ +import hljs from 'highlight.js' +import { debounce } from 'tldraw'; +import { useDebouncedCallback } from "use-debounce"; + +export const Updates = debounce(({editor, setCharsCount, setSaveStatus})=> { + const json = editor.getJSON(); + setCharsCount(editor.storage.characterCount.words()); + window.localStorage.setItem("html-content", highlightCodeblocks(editor.getHTML())); + window.localStorage.setItem("novel-content", JSON.stringify(json)); + window.localStorage.setItem("markdown", editor.storage.markdown.getMarkdown()); + setSaveStatus("Saved"); +}, 500) + +export const highlightCodeblocks = (content: string) => { + const doc = new DOMParser().parseFromString(content, 'text/html'); + doc.querySelectorAll('pre code').forEach((el) => { + hljs.highlightElement(el); + }); + return new XMLSerializer().serializeToString(doc); +};
\ No newline at end of file diff --git a/apps/web/app/(editor)/lib/editorprops.ts b/apps/web/app/(editor)/lib/editorprops.ts new file mode 100644 index 00000000..00d89264 --- /dev/null +++ b/apps/web/app/(editor)/lib/editorprops.ts @@ -0,0 +1,16 @@ +import { handleCommandNavigation } from "novel/extensions"; +import { handleImageDrop, handleImagePaste } from "novel/plugins"; +import { uploadFn } from "../components/image-upload"; +import { EditorView } from "prosemirror-view"; + +export const editorProps = { + handleDOMEvents: { + keydown: (_view: EditorView, event: KeyboardEvent) => handleCommandNavigation(event), + }, + handlePaste: (view: EditorView, event: ClipboardEvent) => handleImagePaste(view, event, uploadFn), + handleDrop: (view: EditorView, event: DragEvent, slice, moved:boolean) => handleImageDrop(view, event, moved, uploadFn), + attributes: { + class: + "prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full", + }, +}
\ No newline at end of file diff --git a/apps/web/app/(editor)/lib/use-local-storage.ts b/apps/web/app/(editor)/lib/use-local-storage.ts new file mode 100644 index 00000000..5f2ebeb9 --- /dev/null +++ b/apps/web/app/(editor)/lib/use-local-storage.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; + +const useLocalStorage = <T>( + key: string, + initialValue: T, + // eslint-disable-next-line no-unused-vars +): [T, (value: T) => void] => { + const [storedValue, setStoredValue] = useState(initialValue); + + useEffect(() => { + // Retrieve from localStorage + const item = window.localStorage.getItem(key); + if (item) { + setStoredValue(JSON.parse(item)); + } + }, [key]); + + const setValue = (value: T) => { + // Save state + setStoredValue(value); + // Save to localStorage + window.localStorage.setItem(key, JSON.stringify(value)); + }; + return [storedValue, setValue]; +}; + +export default useLocalStorage; diff --git a/apps/web/app/(editor)/styles/globals.css b/apps/web/app/(editor)/styles/globals.css new file mode 100644 index 00000000..336e2dae --- /dev/null +++ b/apps/web/app/(editor)/styles/globals.css @@ -0,0 +1,168 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + + --novel-highlight-default: #ffffff; + --novel-highlight-purple: #f6f3f8; + --novel-highlight-red: #fdebeb; + --novel-highlight-yellow: #fbf4a2; + --novel-highlight-blue: #c1ecf9; + --novel-highlight-green: #acf79f; + --novel-highlight-orange: #faebdd; + --novel-highlight-pink: #faf1f5; + --novel-highlight-gray: #f1f1ef; + } + + .dark { + --background: #171B1F; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + + --novel-highlight-default: #000000; + --novel-highlight-purple: #3f2c4b; + --novel-highlight-red: #5c1a1a; + --novel-highlight-yellow: #5c4b1a; + --novel-highlight-blue: #1a3d5c; + --novel-highlight-green: #1a5c20; + --novel-highlight-orange: #5c3a1a; + --novel-highlight-pink: #5c1a3a; + --novel-highlight-gray: #3a3a3a; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + + +pre { + background: #0d0d0d; + border-radius: 0.5rem; + color: #fff; + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + } + + .hljs-comment, + .hljs-quote { + color: #616161; + } + + .hljs-variable, + .hljs-template-variable, + .hljs-attribute, + .hljs-tag, + .hljs-name, + .hljs-regexp, + .hljs-link, + .hljs-name, + .hljs-selector-id, + .hljs-selector-class { + color: #f98181; + } + + .hljs-number, + .hljs-meta, + .hljs-built_in, + .hljs-builtin-name, + .hljs-literal, + .hljs-type, + .hljs-params { + color: #fbbc88; + } + + .hljs-string, + .hljs-symbol, + .hljs-bullet { + color: #b9f18d; + } + + .hljs-title, + .hljs-section { + color: #faf594; + } + + .hljs-keyword, + .hljs-selector-tag { + color: #70cff8; + } + + .hljs-emphasis { + font-style: italic; + } + + .hljs-strong { + font-weight: 700; + } +} +::-webkit-scrollbar{ + width: 0; +} + diff --git a/apps/web/app/(editor)/styles/prosemirror.css b/apps/web/app/(editor)/styles/prosemirror.css new file mode 100644 index 00000000..7298c98b --- /dev/null +++ b/apps/web/app/(editor)/styles/prosemirror.css @@ -0,0 +1,203 @@ +.ProseMirror { + @apply p-12 px-8 sm:px-12; +} + +.ProseMirror .is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: hsl(var(--muted-foreground)); + pointer-events: none; + height: 0; +} +.ProseMirror .is-empty::before { + content: attr(data-placeholder); + float: left; + color: hsl(var(--muted-foreground)); + pointer-events: none; + height: 0; +} + +/* Custom image styles */ + +.ProseMirror img { + transition: filter 0.1s ease-in-out; + + &:hover { + cursor: pointer; + filter: brightness(90%); + } + + &.ProseMirror-selectednode { + outline: 3px solid #5abbf7; + filter: brightness(90%); + } +} + +.img-placeholder { + position: relative; + + &:before { + content: ""; + box-sizing: border-box; + position: absolute; + top: 50%; + left: 50%; + width: 36px; + height: 36px; + border-radius: 50%; + border: 3px solid var(--novel-stone-200); + border-top-color: var(--novel-stone-800); + animation: spinning 0.6s linear infinite; + } +} + +@keyframes spinning { + to { + transform: rotate(360deg); + } +} + +/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ + +ul[data-type="taskList"] li > label { + margin-right: 0.2rem; + user-select: none; +} + +@media screen and (max-width: 768px) { + ul[data-type="taskList"] li > label { + margin-right: 0.5rem; + } +} + +ul[data-type="taskList"] li > label input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + background-color: hsl(var(--background)); + margin: 0; + cursor: pointer; + width: 1.2em; + height: 1.2em; + position: relative; + top: 5px; + border: 2px solid hsl(var(--border)); + margin-right: 0.3rem; + display: grid; + place-content: center; + + &:hover { + background-color: hsl(var(--accent)); + } + + &:active { + background-color: hsl(var(--accent)); + } + + &::before { + content: ""; + width: 0.65em; + height: 0.65em; + transform: scale(0); + transition: 120ms transform ease-in-out; + box-shadow: inset 1em 1em; + transform-origin: center; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + } + + &:checked::before { + transform: scale(1); + } +} + +ul[data-type="taskList"] li[data-checked="true"] > div > p { + color: var(--muted-foreground); + text-decoration: line-through; + text-decoration-thickness: 2px; +} + +/* Overwrite tippy-box original max-width */ + +.tippy-box { + max-width: 400px !important; +} + +.ProseMirror:not(.dragging) .ProseMirror-selectednode { + outline: none !important; + background-color: var(--novel-highlight-blue); + transition: background-color 0.2s; + box-shadow: none; +} + +.drag-handle { + position: fixed; + opacity: 1; + transition: opacity ease-in 0.2s; + border-radius: 0.25rem; + + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E"); + background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem); + background-repeat: no-repeat; + background-position: center; + width: 1.2rem; + height: 1.5rem; + z-index: 50; + cursor: grab; + + &:hover { + background-color: var(--novel-stone-100); + transition: background-color 0.2s; + } + + &:active { + background-color: var(--novel-stone-200); + transition: background-color 0.2s; + cursor: grabbing; + } + + &.hide { + opacity: 0; + pointer-events: none; + } + + @media screen and (max-width: 600px) { + display: none; + pointer-events: none; + } +} + +.dark .drag-handle { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E"); +} + +/* Custom Youtube Video CSS */ +iframe { + border: 8px solid #ffd00027; + border-radius: 4px; + min-width: 200px; + min-height: 200px; + display: block; + outline: 0px solid transparent; +} + +div[data-youtube-video] > iframe { + cursor: move; + aspect-ratio: 16 / 9; + width: 100%; +} + +.ProseMirror-selectednode iframe { + transition: outline 0.15s; + outline: 6px solid #fbbf24; +} + +@media only screen and (max-width: 480px) { + div[data-youtube-video] > iframe { + max-height: 50px; + } +} + +@media only screen and (max-width: 720px) { + div[data-youtube-video] > iframe { + max-height: 100px; + } +}
\ No newline at end of file diff --git a/apps/web/app/(landing)/package.json b/apps/web/app/(landing)/package.json deleted file mode 100644 index a7fabf2f..00000000 --- a/apps/web/app/(landing)/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "scripts": { - "vercel-build": "next build" - } -} diff --git a/apps/web/app/(landing)/page.tsx b/apps/web/app/(landing)/page.tsx index 562e9af9..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"; @@ -16,7 +16,7 @@ export default async function Home() { console.log(user); if (user) { - // await redirect("/home") + await redirect("/home"); } return ( diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts new file mode 100644 index 00000000..6c7180d9 --- /dev/null +++ b/apps/web/app/actions/doers.ts @@ -0,0 +1,268 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { db } from "../../server/db"; +import { contentToSpace, space, storedContent } from "../../server/db/schema"; +import { ServerActionReturnType } from "./types"; +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, +): ServerActionReturnType<number> => { + const data = await auth(); + + if (!data || !data.user) { + return { error: "Not authenticated", success: false }; + } + + if (typeof input === "object") { + input = (input as FormData).get("name") as string; + } + + try { + const resp = await db + .insert(space) + .values({ name: input, user: data.user.id }); + + revalidatePath("/home"); + return { success: true, data: 1 }; + } catch (e: unknown) { + const error = e as Error; + if ( + error.message.includes("D1_ERROR: UNIQUE constraint failed: space.name") + ) { + return { success: false, data: 0, error: "Space already exists" }; + } else { + return { + success: false, + data: 0, + error: "Failed to create space with error: " + error.message, + }; + } + } +}; + +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 new file mode 100644 index 00000000..dc71252e --- /dev/null +++ b/apps/web/app/actions/fetchers.ts @@ -0,0 +1,142 @@ +"use server"; + +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 "../../server/auth"; + +export const getSpaces = async (): ServerActionReturnType<Space[]> => { + 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 }; + }); + + 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 new file mode 100644 index 00000000..5c5afc5c --- /dev/null +++ b/apps/web/app/actions/types.ts @@ -0,0 +1,11 @@ +export type Space = { + id: number; + name: string; + numberOfMemories?: number; +}; + +export type ServerActionReturnType<T> = Promise<{ + error?: string; + success: boolean; + data?: T; +}>; 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 34099848..c19ce92b 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -1,6 +1,11 @@ import { type NextRequest } from "next/server"; -import { ChatHistory } from "@repo/shared-types"; +import { + ChatHistory, + ChatHistoryZod, + convertChatHistoryList, +} from "@repo/shared-types"; import { ensureAuth } from "../ensureAuth"; +import { z } from "zod"; export const runtime = "edge"; @@ -15,59 +20,69 @@ export async function POST(req: NextRequest) { return new Response("Missing BACKEND_SECURITY_KEY", { status: 500 }); } - const query = new URL(req.url).searchParams.get("q"); - const spaces = new URL(req.url).searchParams.get("spaces"); + const url = new URL(req.url); - const sourcesOnly = - new URL(req.url).searchParams.get("sourcesOnly") ?? "false"; + const query = url.searchParams.get("q"); + const spaces = url.searchParams.get("spaces"); - const chatHistory = (await req.json()) as { - chatHistory: ChatHistory[]; - }; + const sourcesOnly = url.searchParams.get("sourcesOnly") ?? "false"; - console.log("CHathistory", chatHistory); + const chatHistory = await req.json(); - if (!query) { + if (!query || query.trim.length < 0) { return new Response(JSON.stringify({ message: "Invalid query" }), { status: 400, }); } - try { - const resp = await fetch( - `https://cf-ai-backend.dhravya.workers.dev/chat?q=${query}&user=${session.user.email ?? session.user.name}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`, - { - headers: { - "X-Custom-Auth-Key": process.env.BACKEND_SECURITY_KEY!, - }, - method: "POST", - body: JSON.stringify({ - chatHistory: chatHistory.chatHistory ?? [], - }), + const validated = z + .object({ chatHistory: z.array(ChatHistoryZod) }) + .safeParse(chatHistory ?? []); + + if (!validated.success) { + return new Response( + JSON.stringify({ + message: "Invalid chat history", + error: validated.error, + }), + { status: 400 }, + ); + } + + const modelCompatible = await convertChatHistoryList( + validated.data.chatHistory, + ); + + const resp = await fetch( + `${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}`, + "Content-Type": "application/json", }, + method: "POST", + body: JSON.stringify({ + chatHistory: modelCompatible, + }), + }, + ); + + console.log("sourcesOnly", sourcesOnly); + + if (sourcesOnly == "true") { + const data = await resp.json(); + console.log("data", data); + return new Response(JSON.stringify(data), { status: 200 }); + } + + 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 }, ); + } - console.log("sourcesOnly", sourcesOnly); - - if (sourcesOnly == "true") { - const data = await resp.json(); - console.log("data", data); - return new Response(JSON.stringify(data), { status: 200 }); - } - - if (resp.status !== 200 || !resp.ok) { - const errorData = await resp.json(); - console.log(errorData); - return new Response( - JSON.stringify({ message: "Error in CF function", error: errorData }), - { status: resp.status }, - ); - } - - // Stream the response back to the client - const { readable, writable } = new TransformStream(); - resp && resp.body!.pipeTo(writable); - - return new Response(readable, { status: 200 }); - } catch {} + return new Response(resp.body, { status: 200 }); } diff --git a/apps/web/app/api/editorai/route.ts b/apps/web/app/api/editorai/route.ts new file mode 100644 index 00000000..6ee0aed2 --- /dev/null +++ b/apps/web/app/api/editorai/route.ts @@ -0,0 +1,20 @@ +import type { NextRequest } from "next/server"; +import { ensureAuth } from "../ensureAuth"; + +export const runtime = "edge"; + +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 response = await fetch(`${process.env.BACKEND_BASE_URL}/api/editorai?context=${res.context}&request=${res.request}`); + const result = await response.json(); + return new Response(JSON.stringify(result)); + } 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/helpers/lib/get-metadata.ts b/apps/web/app/helpers/lib/get-metadata.ts deleted file mode 100644 index 4609e49b..00000000 --- a/apps/web/app/helpers/lib/get-metadata.ts +++ /dev/null @@ -1,40 +0,0 @@ -"use server"; -import * as cheerio from "cheerio"; - -// TODO: THIS SHOULD PROBABLY ALSO FETCH THE OG-IMAGE -export async function getMetaData(url: string) { - const response = await fetch(url); - const html = await response.text(); - - const $ = cheerio.load(html); - - // Extract the base URL - const baseUrl = new URL(url).origin; - - // Extract title - const title = $("title").text().trim(); - - const description = $("meta[name=description]").attr("content") ?? ""; - - const _favicon = - $("link[rel=icon]").attr("href") ?? "https://supermemory.dhr.wtf/web.svg"; - - let favicon = - _favicon.trim().length > 0 - ? _favicon.trim() - : "https://supermemory.dhr.wtf/web.svg"; - if (favicon.startsWith("/")) { - favicon = baseUrl + favicon; - } else if (favicon.startsWith("./")) { - favicon = baseUrl + favicon.slice(1); - } - - // Prepare the metadata object - const metadata = { - title, - description, - image: favicon, - baseUrl, - }; - return metadata; -} diff --git a/apps/web/app/helpers/lib/get-theme-button.tsx b/apps/web/app/helpers/lib/get-theme-button.tsx deleted file mode 100644 index 020cc976..00000000 --- a/apps/web/app/helpers/lib/get-theme-button.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// Theming that works perfectly with app router (no flicker, jumps etc!) - -import dynamic from "next/dynamic"; - -// Don't SSR the toggle since the value on the server will be different than the client -export const getThemeToggler = () => - dynamic(() => import("@repo/ui/shadcn/theme-toggle"), { - ssr: false, - // Make sure to code a placeholder so the UI doesn't jump when the component loads - loading: () => <div className="w-6 h-6" />, - }); diff --git a/apps/web/app/helpers/lib/handle-errors.ts b/apps/web/app/helpers/lib/handle-errors.ts deleted file mode 100644 index 42cae589..00000000 --- a/apps/web/app/helpers/lib/handle-errors.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isRedirectError } from "next/dist/client/components/redirect"; -import { toast } from "sonner"; -import { z } from "zod"; - -export function getErrorMessage(err: unknown) { - const unknownError = "Something went wrong, please try again later."; - - if (err instanceof z.ZodError) { - const errors = err.issues.map((issue) => { - return issue.message; - }); - return errors.join("\n"); - } else if (err instanceof Error) { - return err.message; - } else if (isRedirectError(err)) { - throw err; - } else { - return unknownError; - } -} - -export function showErrorToast(err: unknown) { - const errorMessage = getErrorMessage(err); - return toast.error(errorMessage); -} diff --git a/apps/web/app/helpers/lib/searchParams.ts b/apps/web/app/helpers/lib/searchParams.ts deleted file mode 100644 index 9899eaf7..00000000 --- a/apps/web/app/helpers/lib/searchParams.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - createSearchParamsCache, - parseAsInteger, - parseAsString, - parseAsBoolean, - parseAsArrayOf, - parseAsJson, -} from "nuqs/server"; -import { z } from "zod"; - -export const homeSearchParamsCache = createSearchParamsCache({ - firstTime: parseAsBoolean.withDefault(false), -}); - -export const chatSearchParamsCache = createSearchParamsCache({ - firstTime: parseAsBoolean.withDefault(false), - q: parseAsString.withDefault(""), - spaces: parseAsArrayOf( - parseAsJson(() => - z.object({ - id: z.string(), - name: z.string(), - }), - ), - ).withDefault([]), -}); diff --git a/apps/web/app/helpers/server/auth.ts b/apps/web/app/helpers/server/auth.ts deleted file mode 100644 index 73119d87..00000000 --- a/apps/web/app/helpers/server/auth.ts +++ /dev/null @@ -1,29 +0,0 @@ -import NextAuth, { NextAuthResult } from "next-auth"; -import Google from "next-auth/providers/google"; -import { DrizzleAdapter } from "@auth/drizzle-adapter"; -import { db } from "./db"; - -export const { - handlers: { GET, POST }, - signIn, - signOut, - auth, -} = NextAuth({ - secret: process.env.BACKEND_SECURITY_KEY, - callbacks: { - session: ({ session, token, user }) => ({ - ...session, - user: { - ...session.user, - id: user.id, - }, - }), - }, - adapter: DrizzleAdapter(db), - providers: [ - Google({ - clientId: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - }), - ], -}); diff --git a/apps/web/app/helpers/server/db/index.ts b/apps/web/app/helpers/server/db/index.ts deleted file mode 100644 index 4d671bea..00000000 --- a/apps/web/app/helpers/server/db/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { drizzle } from "drizzle-orm/d1"; - -import * as schema from "./schema"; - -export const db = drizzle(process.env.DATABASE, { schema, logger: true }); diff --git a/apps/web/app/helpers/server/db/schema.ts b/apps/web/app/helpers/server/db/schema.ts deleted file mode 100644 index c4616eb2..00000000 --- a/apps/web/app/helpers/server/db/schema.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { relations, sql } from "drizzle-orm"; -import { - index, - int, - primaryKey, - sqliteTableCreator, - text, - integer, -} from "drizzle-orm/sqlite-core"; - -export const createTable = sqliteTableCreator((name) => `${name}`); - -export const users = createTable("user", { - id: text("id", { length: 255 }).notNull().primaryKey(), - name: text("name", { length: 255 }), - email: text("email", { length: 255 }).notNull(), - emailVerified: int("emailVerified", { mode: "timestamp" }).default( - sql`CURRENT_TIMESTAMP`, - ), - image: text("image", { length: 255 }), -}); - -export type User = typeof users.$inferSelect; - -export const usersRelations = relations(users, ({ many }) => ({ - accounts: many(accounts), - sessions: many(sessions), -})); - -export const accounts = createTable( - "account", - { - id: integer("id").notNull().primaryKey({ autoIncrement: true }), - userId: text("userId", { length: 255 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - type: text("type", { length: 255 }).notNull(), - provider: text("provider", { length: 255 }).notNull(), - providerAccountId: text("providerAccountId", { length: 255 }).notNull(), - refresh_token: text("refresh_token"), - access_token: text("access_token"), - expires_at: int("expires_at"), - token_type: text("token_type", { length: 255 }), - scope: text("scope", { length: 255 }), - id_token: text("id_token"), - session_state: text("session_state", { length: 255 }), - oauth_token_secret: text("oauth_token_secret"), - oauth_token: text("oauth_token"), - }, - (account) => ({ - userIdIdx: index("account_userId_idx").on(account.userId), - }), -); - -export const sessions = createTable( - "session", - { - id: integer("id").notNull().primaryKey({ autoIncrement: true }), - sessionToken: text("sessionToken", { length: 255 }).notNull(), - userId: text("userId", { length: 255 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - expires: int("expires", { mode: "timestamp" }).notNull(), - }, - (session) => ({ - userIdIdx: index("session_userId_idx").on(session.userId), - }), -); - -export const verificationTokens = createTable( - "verificationToken", - { - identifier: text("identifier", { length: 255 }).notNull(), - token: text("token", { length: 255 }).notNull(), - expires: int("expires", { mode: "timestamp" }).notNull(), - }, - (vt) => ({ - compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), - }), -); - -export const storedContent = createTable( - "storedContent", - { - id: integer("id").notNull().primaryKey({ autoIncrement: true }), - content: text("content").notNull(), - title: text("title", { length: 255 }), - description: text("description", { length: 255 }), - url: text("url").notNull(), - 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", - ), - image: text("image", { length: 255 }), - user: text("user", { length: 255 }).references(() => users.id, { - onDelete: "cascade", - }), - }, - (sc) => ({ - urlIdx: index("storedContent_url_idx").on(sc.url), - savedAtIdx: index("storedContent_savedAt_idx").on(sc.savedAt), - titleInx: index("storedContent_title_idx").on(sc.title), - userIdx: index("storedContent_user_idx").on(sc.user), - }), -); - -export const contentToSpace = createTable( - "contentToSpace", - { - contentId: integer("contentId") - .notNull() - .references(() => storedContent.id, { onDelete: "cascade" }), - spaceId: integer("spaceId") - .notNull() - .references(() => space.id, { onDelete: "cascade" }), - }, - (cts) => ({ - compoundKey: primaryKey({ columns: [cts.contentId, cts.spaceId] }), - }), -); - -export const space = createTable( - "space", - { - id: integer("id").notNull().primaryKey({ autoIncrement: true }), - name: text("name").notNull().unique().default("none"), - user: text("user", { length: 255 }).references(() => users.id, { - onDelete: "cascade", - }), - }, - (space) => ({ - nameIdx: index("spaces_name_idx").on(space.name), - userIdx: index("spaces_user_idx").on(space.user), - }), -); - -export type StoredContent = Omit<typeof storedContent.$inferSelect, "user">; -export type StoredSpace = typeof space.$inferSelect; -export type ChachedSpaceContent = StoredContent & { - space: number; -}; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 1b5558bb..52cd9d32 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,6 +2,7 @@ import "@repo/tailwind-config/globals.css"; import type { Metadata } from "next"; import { Inter } from "next/font/google"; +import { Toaster } from "@repo/ui/shadcn/toaster"; const inter = Inter({ subsets: ["latin"] }); @@ -63,7 +64,10 @@ export default function RootLayout({ return ( <html lang="en"> {/* TODO: when lightmode support is added, remove the 'dark' class from the body tag */} - <body className={`${inter.className}`}>{children}</body> + <body className={`${inter.className} dark`}> + {children} + <Toaster /> + </body> </html> ); } 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"; |