aboutsummaryrefslogtreecommitdiff
path: root/apps/web/components/new
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components/new')
-rw-r--r--apps/web/components/new/add-document/connections.tsx2
-rw-r--r--apps/web/components/new/add-document/file.tsx21
-rw-r--r--apps/web/components/new/add-document/index.tsx2
-rw-r--r--apps/web/components/new/add-document/link.tsx2
-rw-r--r--apps/web/components/new/add-document/note.tsx21
-rw-r--r--apps/web/components/new/chat/index.tsx13
-rw-r--r--apps/web/components/new/chat/input/chain-of-thought.tsx2
-rw-r--r--apps/web/components/new/chat/input/index.tsx2
-rw-r--r--apps/web/components/new/chat/message/related-memories.tsx2
-rw-r--r--apps/web/components/new/chat/model-selector.tsx2
-rw-r--r--apps/web/components/new/document-cards/file-preview.tsx2
-rw-r--r--apps/web/components/new/document-cards/google-docs-preview.tsx2
-rw-r--r--apps/web/components/new/document-cards/mcp-preview.tsx13
-rw-r--r--apps/web/components/new/document-cards/note-preview.tsx53
-rw-r--r--apps/web/components/new/document-cards/tweet-preview.tsx2
-rw-r--r--apps/web/components/new/document-cards/website-preview.tsx2
-rw-r--r--apps/web/components/new/document-cards/youtube-preview.tsx2
-rw-r--r--apps/web/components/new/document-modal/document-icon.tsx4
-rw-r--r--apps/web/components/new/document-modal/index.tsx122
-rw-r--r--apps/web/components/new/document-modal/title.tsx2
-rw-r--r--apps/web/components/new/header.tsx12
-rw-r--r--apps/web/components/new/mcp-modal/index.tsx2
-rw-r--r--apps/web/components/new/mcp-modal/mcp-detail-view.tsx11
-rw-r--r--apps/web/components/new/memories-grid.tsx11
-rw-r--r--apps/web/components/new/onboarding/setup/chat-sidebar.tsx488
-rw-r--r--apps/web/components/new/onboarding/setup/header.tsx47
-rw-r--r--apps/web/components/new/onboarding/setup/integrations-step.tsx183
-rw-r--r--apps/web/components/new/onboarding/setup/relatable-question.tsx159
-rw-r--r--apps/web/components/new/onboarding/welcome/continue-step.tsx45
-rw-r--r--apps/web/components/new/onboarding/welcome/features-step.tsx98
-rw-r--r--apps/web/components/new/onboarding/welcome/greeting-step.tsx23
-rw-r--r--apps/web/components/new/onboarding/welcome/input-step.tsx96
-rw-r--r--apps/web/components/new/onboarding/welcome/profile-step.tsx303
-rw-r--r--apps/web/components/new/onboarding/welcome/welcome-step.tsx16
-rw-r--r--apps/web/components/new/onboarding/x-bookmarks-detail-view.tsx111
-rw-r--r--apps/web/components/new/settings/account.tsx2
-rw-r--r--apps/web/components/new/settings/connections-mcp.tsx2
-rw-r--r--apps/web/components/new/settings/integrations.tsx2
-rw-r--r--apps/web/components/new/settings/support.tsx2
-rw-r--r--apps/web/components/new/text-editor/extensions.tsx92
-rw-r--r--apps/web/components/new/text-editor/index.tsx174
-rw-r--r--apps/web/components/new/text-editor/slash-command.tsx308
-rw-r--r--apps/web/components/new/text-editor/suggestions.tsx103
43 files changed, 2491 insertions, 72 deletions
diff --git a/apps/web/components/new/add-document/connections.tsx b/apps/web/components/new/add-document/connections.tsx
index 5a89cf70..b146fb4c 100644
--- a/apps/web/components/new/add-document/connections.tsx
+++ b/apps/web/components/new/add-document/connections.tsx
@@ -10,7 +10,7 @@ import { Check, Loader, Trash2, Zap } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import type { z } from "zod"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
diff --git a/apps/web/components/new/add-document/file.tsx b/apps/web/components/new/add-document/file.tsx
index 8e7dc4c4..bb605f03 100644
--- a/apps/web/components/new/add-document/file.tsx
+++ b/apps/web/components/new/add-document/file.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react"
import { cn } from "@lib/utils"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { FileIcon } from "lucide-react"
import { useHotkeys } from "react-hotkeys-hook"
@@ -19,7 +19,12 @@ interface FileContentProps {
isOpen?: boolean
}
-export function FileContent({ onSubmit, onDataChange, isSubmitting, isOpen }: FileContentProps) {
+export function FileContent({
+ onSubmit,
+ onDataChange,
+ isSubmitting,
+ isOpen,
+}: FileContentProps) {
const [isDragging, setIsDragging] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [title, setTitle] = useState("")
@@ -33,8 +38,16 @@ export function FileContent({ onSubmit, onDataChange, isSubmitting, isOpen }: Fi
}
}
- const updateData = (newFile: File | null, newTitle: string, newDescription: string) => {
- onDataChange?.({ file: newFile, title: newTitle, description: newDescription })
+ const updateData = (
+ newFile: File | null,
+ newTitle: string,
+ newDescription: string,
+ ) => {
+ onDataChange?.({
+ file: newFile,
+ title: newTitle,
+ description: newDescription,
+ })
}
const handleFileChange = (file: File | null) => {
diff --git a/apps/web/components/new/add-document/index.tsx b/apps/web/components/new/add-document/index.tsx
index 9e117912..a99505bd 100644
--- a/apps/web/components/new/add-document/index.tsx
+++ b/apps/web/components/new/add-document/index.tsx
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo, useCallback } from "react"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import {
FileTextIcon,
GlobeIcon,
diff --git a/apps/web/components/new/add-document/link.tsx b/apps/web/components/new/add-document/link.tsx
index 544af86d..2efb67dc 100644
--- a/apps/web/components/new/add-document/link.tsx
+++ b/apps/web/components/new/add-document/link.tsx
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { useHotkeys } from "react-hotkeys-hook"
import { Image as ImageIcon, Loader2 } from "lucide-react"
import { toast } from "sonner"
diff --git a/apps/web/components/new/add-document/note.tsx b/apps/web/components/new/add-document/note.tsx
index 465e295a..4f833ca2 100644
--- a/apps/web/components/new/add-document/note.tsx
+++ b/apps/web/components/new/add-document/note.tsx
@@ -1,7 +1,7 @@
"use client"
import { useState, useEffect } from "react"
-import { useHotkeys } from "react-hotkeys-hook"
+import { TextEditor } from "../text-editor"
interface NoteContentProps {
onSubmit?: (content: string) => void
@@ -31,11 +31,6 @@ export function NoteContent({
onContentChange?.(newContent)
}
- useHotkeys("mod+enter", handleSubmit, {
- enabled: isOpen && canSubmit,
- enableOnFormTags: ["TEXTAREA"],
- })
-
// Reset content when modal closes
useEffect(() => {
if (!isOpen) {
@@ -45,12 +40,12 @@ export function NoteContent({
}, [isOpen, onContentChange])
return (
- <textarea
- value={content}
- onChange={(e) => handleContentChange(e.target.value)}
- placeholder="Write your note here..."
- disabled={isSubmitting}
- className="w-full h-full p-4 mb-4! rounded-[14px] bg-[#14161A] shadow-inside-out resize-none disabled:opacity-50 outline-none"
- />
+ <div className="p-4 overflow-y-auto flex-1 w-full h-full mb-4! bg-[#14161A] shadow-inside-out rounded-[14px]">
+ <TextEditor
+ content={undefined}
+ onContentChange={handleContentChange}
+ onSubmit={handleSubmit}
+ />
+ </div>
)
}
diff --git a/apps/web/components/new/chat/index.tsx b/apps/web/components/new/chat/index.tsx
index 3460c456..08f4e2ef 100644
--- a/apps/web/components/new/chat/index.tsx
+++ b/apps/web/components/new/chat/index.tsx
@@ -14,7 +14,7 @@ import {
SquarePenIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import ChatInput from "./input"
import ChatModelSelector from "./model-selector"
import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo"
@@ -110,7 +110,6 @@ export function ChatSidebar({
},
},
}),
- maxSteps: 10,
onFinish: async (result) => {
if (result.message.role !== "assistant") return
@@ -285,14 +284,20 @@ export function ChatSidebar({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
+ const activeElement = document.activeElement as HTMLElement | null
+ const isInEditableContext =
+ activeElement?.tagName === "INPUT" ||
+ activeElement?.tagName === "TEXTAREA" ||
+ activeElement?.isContentEditable ||
+ activeElement?.closest('[contenteditable="true"]')
+
if (
e.key.toLowerCase() === "t" &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
isChatOpen &&
- document.activeElement?.tagName !== "INPUT" &&
- document.activeElement?.tagName !== "TEXTAREA"
+ !isInEditableContext
) {
e.preventDefault()
handleNewChat()
diff --git a/apps/web/components/new/chat/input/chain-of-thought.tsx b/apps/web/components/new/chat/input/chain-of-thought.tsx
index 0371a97e..b1923146 100644
--- a/apps/web/components/new/chat/input/chain-of-thought.tsx
+++ b/apps/web/components/new/chat/input/chain-of-thought.tsx
@@ -2,7 +2,7 @@ import { useAuth } from "@lib/auth-context"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import type { UIMessage } from "@ai-sdk/react"
import { cn } from "@lib/utils"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
interface MemoryResult {
documentId?: string
diff --git a/apps/web/components/new/chat/input/index.tsx b/apps/web/components/new/chat/input/index.tsx
index 4294add5..40c1949d 100644
--- a/apps/web/components/new/chat/input/index.tsx
+++ b/apps/web/components/new/chat/input/index.tsx
@@ -3,7 +3,7 @@
import { ChevronUpIcon } from "lucide-react"
import NovaOrb from "@/components/nova/nova-orb"
import { cn } from "@lib/utils"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { useRef, useState } from "react"
import { motion } from "motion/react"
import { SendButton, StopButton } from "./actions"
diff --git a/apps/web/components/new/chat/message/related-memories.tsx b/apps/web/components/new/chat/message/related-memories.tsx
index f3b406db..ad83d03e 100644
--- a/apps/web/components/new/chat/message/related-memories.tsx
+++ b/apps/web/components/new/chat/message/related-memories.tsx
@@ -1,6 +1,6 @@
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import type { UIMessage } from "@ai-sdk/react"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
interface MemoryResult {
diff --git a/apps/web/components/new/chat/model-selector.tsx b/apps/web/components/new/chat/model-selector.tsx
index 9cecafc2..c1952bea 100644
--- a/apps/web/components/new/chat/model-selector.tsx
+++ b/apps/web/components/new/chat/model-selector.tsx
@@ -3,7 +3,7 @@
import { useState } from "react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { ChevronDownIcon } from "lucide-react"
import { models, type ModelId, modelNames } from "@/lib/models"
diff --git a/apps/web/components/new/document-cards/file-preview.tsx b/apps/web/components/new/document-cards/file-preview.tsx
index 6f72c0e3..93e53463 100644
--- a/apps/web/components/new/document-cards/file-preview.tsx
+++ b/apps/web/components/new/document-cards/file-preview.tsx
@@ -3,7 +3,7 @@
import { useState } from "react"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { PDF } from "@ui/assets/icons"
import { FileText, Image, Video } from "lucide-react"
diff --git a/apps/web/components/new/document-cards/google-docs-preview.tsx b/apps/web/components/new/document-cards/google-docs-preview.tsx
index ee3e6c72..6c783c7d 100644
--- a/apps/web/components/new/document-cards/google-docs-preview.tsx
+++ b/apps/web/components/new/document-cards/google-docs-preview.tsx
@@ -2,7 +2,7 @@
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
diff --git a/apps/web/components/new/document-cards/mcp-preview.tsx b/apps/web/components/new/document-cards/mcp-preview.tsx
index 55c98169..a8a792fb 100644
--- a/apps/web/components/new/document-cards/mcp-preview.tsx
+++ b/apps/web/components/new/document-cards/mcp-preview.tsx
@@ -2,7 +2,7 @@
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { ClaudeDesktopIcon, MCPIcon } from "@ui/assets/icons"
@@ -13,11 +13,16 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) {
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center justify-between gap-1">
- <p className={cn(dmSansClassName(), "text-[12px] font-semibold flex items-center gap-1")}>
- <ClaudeDesktopIcon className="size-3" />
+ <p
+ className={cn(
+ dmSansClassName(),
+ "text-[12px] font-semibold flex items-center gap-1",
+ )}
+ >
+ <ClaudeDesktopIcon className="size-3" />
Claude Desktop
</p>
- <MCPIcon className="size-6" />
+ <MCPIcon className="size-6" />
</div>
<div className="space-y-[6px]">
{document.title && (
diff --git a/apps/web/components/new/document-cards/note-preview.tsx b/apps/web/components/new/document-cards/note-preview.tsx
index 288b6fa5..2becc237 100644
--- a/apps/web/components/new/document-cards/note-preview.tsx
+++ b/apps/web/components/new/document-cards/note-preview.tsx
@@ -2,12 +2,54 @@
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
+import { useMemo } from "react"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
+type TipTapNode = {
+ type: string
+ text?: string
+ content?: TipTapNode[]
+ attrs?: Record<string, unknown>
+}
+
+function extractTextFromTipTapContent(content: string): string {
+ try {
+ const json = JSON.parse(content) as TipTapNode
+ return extractTextFromNode(json)
+ } catch {
+ return content
+ }
+}
+
+function extractTextFromNode(node: TipTapNode): string {
+ if (node.type === "text" && node.text) {
+ return node.text
+ }
+
+ if (!node.content) {
+ return ""
+ }
+
+ const texts: string[] = []
+ for (const child of node.content) {
+ const text = extractTextFromNode(child)
+ if (text) {
+ texts.push(text)
+ }
+ }
+
+ const blockTypes = ["paragraph", "heading", "listItem", "blockquote", "codeBlock"]
+ if (blockTypes.includes(node.type)) {
+ return `${texts.join("")}\n`
+ }
+
+ return texts.join("")
+}
+
function NoteIcon() {
return (
<svg
@@ -79,6 +121,11 @@ function NoteIcon() {
}
export function NotePreview({ document }: { document: DocumentWithMemories }) {
+ const previewText = useMemo(() => {
+ if (!document.content) return ""
+ return extractTextFromTipTapContent(document.content).trim()
+ }, [document.content])
+
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center gap-1">
@@ -98,9 +145,9 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
{document.title}
</p>
)}
- {document.content && (
+ {previewText && (
<p className="text-[10px] text-[#737373] line-clamp-4">
- {document.content}
+ {previewText}
</p>
)}
</div>
diff --git a/apps/web/components/new/document-cards/tweet-preview.tsx b/apps/web/components/new/document-cards/tweet-preview.tsx
index 7ba9b392..291bc18a 100644
--- a/apps/web/components/new/document-cards/tweet-preview.tsx
+++ b/apps/web/components/new/document-cards/tweet-preview.tsx
@@ -11,7 +11,7 @@ import {
TweetSkeleton,
} from "react-tweet"
import { cn } from "@lib/utils"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
export function TweetPreview({
data,
diff --git a/apps/web/components/new/document-cards/website-preview.tsx b/apps/web/components/new/document-cards/website-preview.tsx
index d61b1ed6..ab3c5ad3 100644
--- a/apps/web/components/new/document-cards/website-preview.tsx
+++ b/apps/web/components/new/document-cards/website-preview.tsx
@@ -3,7 +3,7 @@
import { useState } from "react"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
diff --git a/apps/web/components/new/document-cards/youtube-preview.tsx b/apps/web/components/new/document-cards/youtube-preview.tsx
index 101376f8..47c9cb5e 100644
--- a/apps/web/components/new/document-cards/youtube-preview.tsx
+++ b/apps/web/components/new/document-cards/youtube-preview.tsx
@@ -2,7 +2,7 @@
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { extractYouTubeVideoId } from "../utils"
diff --git a/apps/web/components/new/document-modal/document-icon.tsx b/apps/web/components/new/document-modal/document-icon.tsx
index f86228eb..cb279e49 100644
--- a/apps/web/components/new/document-modal/document-icon.tsx
+++ b/apps/web/components/new/document-modal/document-icon.tsx
@@ -80,7 +80,7 @@ const PDFIcon = ({ className }: { className: string }) => {
width="8.25216"
height="10.2522"
filterUnits="userSpaceOnUse"
- color-interpolation-filters="sRGB"
+ colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
@@ -158,7 +158,7 @@ const TextDocumentIcon = ({ className }: { className: string }) => {
width="18.0253"
height="13.8376"
filterUnits="userSpaceOnUse"
- color-interpolation-filters="sRGB"
+ colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
diff --git a/apps/web/components/new/document-modal/index.tsx b/apps/web/components/new/document-modal/index.tsx
index 74d4e178..bd8ec05d 100644
--- a/apps/web/components/new/document-modal/index.tsx
+++ b/apps/web/components/new/document-modal/index.tsx
@@ -2,19 +2,24 @@
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
-import { ArrowUpRightIcon, XIcon } from "lucide-react"
+import { ArrowUpRightIcon, XIcon, Loader2 } from "lucide-react"
import type { z } from "zod"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@lib/utils"
import dynamic from "next/dynamic"
import { Title } from "./title"
import { Summary as DocumentSummary } from "./summary"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { GraphListMemories, type MemoryEntry } from "./graph-list-memories"
import { YoutubeVideo } from "./content/yt-video"
import { TweetContent } from "./content/tweet"
-import { isTwitterUrl } from "@/utils/url-helpers"
+import { isTwitterUrl } from "@/lib/url-helpers"
import { NotionDoc } from "./content/notion-doc"
+import { TextEditor } from "../text-editor"
+import { useState, useEffect, useCallback, useMemo } from "react"
+import { motion, AnimatePresence } from "motion/react"
+import { Button } from "@repo/ui/components/button"
+import { useDocumentMutations } from "@/hooks/use-document-mutations"
// Dynamically importing to prevent DOMMatrix error
const PdfViewer = dynamic(
@@ -43,7 +48,49 @@ export function DocumentModal({
isOpen,
onClose,
}: DocumentModalProps) {
- console.log(_document)
+ const { updateMutation } = useDocumentMutations()
+
+ const { initialEditorContent, initialEditorString } = useMemo(() => {
+ const content = _document?.content as string | null | undefined
+ return {
+ initialEditorContent: content ?? undefined,
+ initialEditorString: content ?? "",
+ }
+ }, [_document?.content])
+
+ const [draftContentString, setDraftContentString] =
+ useState(initialEditorString)
+ const [editorResetNonce, setEditorResetNonce] = useState(0)
+ const [lastSavedContent, setLastSavedContent] = useState<string | null>(null)
+
+ const resetEditor = useCallback(() => {
+ setDraftContentString(initialEditorString)
+ setEditorResetNonce((n) => n + 1)
+ setLastSavedContent(null)
+ }, [initialEditorString])
+
+ useEffect(() => {
+ setDraftContentString(initialEditorString)
+ setEditorResetNonce((n) => n + 1)
+ setLastSavedContent(null)
+ }, [initialEditorString])
+
+ useEffect(() => {
+ if (!isOpen) resetEditor()
+ }, [isOpen, resetEditor])
+
+ const hasUnsavedChanges =
+ draftContentString !== initialEditorString &&
+ draftContentString !== lastSavedContent
+
+ const handleSave = useCallback(() => {
+ if (!_document?.id) return
+ updateMutation.mutate(
+ { documentId: _document.id, content: draftContentString },
+ { onSuccess: (_data, variables) => setLastSavedContent(variables.content) },
+ )
+ }, [_document?.id, draftContentString, updateMutation])
+
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
@@ -79,7 +126,7 @@ export function DocumentModal({
</a>
)}
<DialogPrimitive.Close
- className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
+ className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
data-slot="dialog-close"
>
<XIcon stroke="#737373" />
@@ -91,7 +138,7 @@ export function DocumentModal({
<div
id="document-preview"
className={cn(
- "bg-[#14161A] rounded-[14px] overflow-hidden flex flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
+ "bg-[#14161A] rounded-[14px] overflow-hidden flex flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative",
)}
>
{(_document?.type === "tweet" ||
@@ -104,9 +151,66 @@ export function DocumentModal({
/>
)}
{_document?.type === "text" && (
- <div className="p-4 overflow-y-auto flex-1">
- {_document.content}
- </div>
+ <>
+ <div className="p-4 overflow-y-auto flex-1 scrollbar-thin">
+ <TextEditor
+ key={`${_document.id}-${editorResetNonce}`}
+ content={initialEditorContent}
+ onContentChange={setDraftContentString}
+ onSubmit={handleSave}
+ />
+ </div>
+ <AnimatePresence>
+ {hasUnsavedChanges && (
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: 20 }}
+ transition={{ duration: 0.2 }}
+ className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-3 px-4 py-2 bg-[#1B1F24] rounded-full shadow-[0_4px_20px_rgba(0,0,0,0.4),inset_1px_1px_1px_rgba(255,255,255,0.1)]"
+ >
+ <span className="text-sm text-[#737373]">
+ Unsaved changes
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={resetEditor}
+ disabled={updateMutation.isPending}
+ className="text-[#737373]/80 hover:text-white rounded-full px-3"
+ >
+ Cancel
+ </Button>
+ <Button
+ variant="insideOut"
+ size="sm"
+ onClick={handleSave}
+ disabled={updateMutation.isPending}
+ className="hover:text-white rounded-full px-4"
+ >
+ {updateMutation.isPending ? (
+ <>
+ <Loader2 className="size-4 animate-spin mr-1" />
+ Saving...
+ </>
+ ) : (
+ <>
+ Save
+ <span
+ className={cn(
+ "bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm px-1 py-0.5 text-[10px] flex items-center justify-center",
+ dmSansClassName(),
+ )}
+ >
+ ⌘+Enter
+ </span>
+ </>
+ )}
+ </Button>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </>
)}
{_document?.type === "pdf" && <PdfViewer url={_document.url} />}
{_document?.type === "notion_doc" && (
diff --git a/apps/web/components/new/document-modal/title.tsx b/apps/web/components/new/document-modal/title.tsx
index b8bdc8bb..066c437b 100644
--- a/apps/web/components/new/document-modal/title.tsx
+++ b/apps/web/components/new/document-modal/title.tsx
@@ -1,5 +1,5 @@
import { cn } from "@lib/utils"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import type { DocumentTypeEnum } from "@repo/validation/schemas"
import type { z } from "zod"
import { getDocumentIcon } from "@/components/new/document-modal/document-icon"
diff --git a/apps/web/components/new/header.tsx b/apps/web/components/new/header.tsx
index a5aa47d3..3bb2d115 100644
--- a/apps/web/components/new/header.tsx
+++ b/apps/web/components/new/header.tsx
@@ -18,7 +18,7 @@ import {
} from "lucide-react"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
-import { dmSansClassName } from "@/utils/fonts"
+import { dmSansClassName } from "@/lib/fonts"
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"
import { useProjectName } from "@/hooks/use-project-name"
import {
@@ -259,10 +259,12 @@ export function Header({ onAddMemory, onOpenMCP }: HeaderProps) {
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
- <DropdownMenuItem onClick={() => {
- authClient.signOut()
- router.push("/login/new")
- }}>
+ <DropdownMenuItem
+ onClick={() => {
+ authClient.signOut()
+ router.push("/login/new")
+ }}
+ >
<LogOut className="h-4 w-4" />
Logout
</DropdownMenuItem>
diff --git a/apps/web/components/new/mcp-modal/index.tsx b/apps/web/components/new/mcp-modal/index.tsx
index d555ae62..6816c21e 100644
--- a/apps/web/components/new/mcp-modal/index.tsx
+++ b/apps/web/components/new/mcp-modal/index.tsx
@@ -1,4 +1,4 @@
-import { dmSans125ClassName, dmSansClassName } from "@/utils/fonts"
+import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { Dialog, DialogContent, DialogFooter } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import * as DialogPrimitive from "@radix-ui/react-dialog"
diff --git a/apps/web/components/new/mcp-modal/mcp-detail-view.tsx b/apps/web/components/new/mcp-modal/mcp-detail-view.tsx
index f1254bcb..5cc2fa68 100644
--- a/apps/web/components/new/mcp-modal/mcp-detail-view.tsx
+++ b/apps/web/components/new/mcp-modal/mcp-detail-view.tsx
@@ -14,7 +14,7 @@ import Image from "next/image"
import { toast } from "sonner"
import { analytics } from "@/lib/analytics"
import { cn } from "@lib/utils"
-import { dmMonoClassName, dmSansClassName } from "@/utils/fonts"
+import { dmMonoClassName, dmSansClassName } from "@/lib/fonts"
import { SyncLogoIcon } from "@ui/assets/icons"
const clients = {
@@ -78,7 +78,14 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
>
<div
className="absolute left-4 top-0 w-px bg-[#1E293B] z-10"
- style={{ height: activeStep === 3 ? isEmbedded ? "100%" : "calc(100% - 4rem)" : "100%" }}
+ style={{
+ height:
+ activeStep === 3
+ ? isEmbedded
+ ? "100%"
+ : "calc(100% - 4rem)"
+ : "100%",
+ }}
/>
<div className="flex items-start space-x-4 z-20">
<button
diff --git a/apps/web/components/new/memories-grid.tsx b/apps/web/components/new/memories-grid.tsx
index e5972d51..e6095645 100644
--- a/apps/web/components/new/memories-grid.tsx
+++ b/apps/web/components/new/memories-grid.tsx
@@ -6,11 +6,8 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useCallback, memo, useMemo, useState, useRef } from "react"
import type { z } from "zod"
-import {
- Masonry,
- useInfiniteLoader,
-} from "masonic"
-import { dmSansClassName } from "@/utils/fonts"
+import { Masonry, useInfiniteLoader } from "masonic"
+import { dmSansClassName } from "@/lib/fonts"
import { SuperLoader } from "@/components/superloader"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
@@ -151,9 +148,7 @@ export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) {
}
return (
- <div
- className="h-full"
- >
+ <div className="h-full">
<Button
className={cn(
dmSansClassName(),
diff --git a/apps/web/components/new/onboarding/setup/chat-sidebar.tsx b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx
new file mode 100644
index 00000000..59742271
--- /dev/null
+++ b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx
@@ -0,0 +1,488 @@
+"use client"
+
+import { useState, useEffect, useCallback, useRef } from "react"
+import { motion, AnimatePresence } from "motion/react"
+import NovaOrb from "@/components/nova/nova-orb"
+import { Button } from "@ui/components/button"
+import { PanelRightCloseIcon, SendIcon } from "lucide-react"
+import { collectValidUrls } from "@/lib/url-helpers"
+import { $fetch } from "@lib/api"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import { useAuth } from "@lib/auth-context"
+
+interface ChatSidebarProps {
+ formData: {
+ twitter: string
+ linkedin: string
+ description: string
+ otherLinks: string[]
+ } | null
+}
+
+export function ChatSidebar({ formData }: ChatSidebarProps) {
+ const { user } = useAuth()
+ const [message, setMessage] = useState("")
+ const [isChatOpen, setIsChatOpen] = useState(true)
+ const [messages, setMessages] = useState<
+ {
+ message: string
+ type?: "formData" | "exa" | "memory" | "waiting"
+ memories?: {
+ url: string
+ title: string
+ description: string
+ fullContent: string
+ }[]
+ url?: string
+ title?: string
+ description?: string
+ }[]
+ >([])
+ const [isLoading, setIsLoading] = useState(false)
+ const displayedMemoriesRef = useRef<Set<string>>(new Set())
+
+ const handleSend = () => {
+ console.log("Message:", message)
+ setMessage("")
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault()
+ handleSend()
+ }
+ }
+
+ const toggleChat = () => {
+ setIsChatOpen(!isChatOpen)
+ }
+
+ const pollForMemories = useCallback(
+ async (documentIds: string[]) => {
+ const maxAttempts = 30 // 30 attempts * 3 seconds = 90 seconds max
+ const pollInterval = 3000 // 3 seconds
+
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ try {
+ const response = await $fetch("@get/documents/:id", {
+ params: { id: documentIds[0] ?? "" },
+ disableValidation: true,
+ })
+
+ console.log("response", response)
+
+ if (response.data) {
+ const document = response.data
+
+ if (document.memories && document.memories.length > 0) {
+ const newMemories: {
+ url: string
+ title: string
+ description: string
+ fullContent: string
+ }[] = []
+
+ document.memories.forEach(
+ (memory: { memory: string; title?: string }) => {
+ if (!displayedMemoriesRef.current.has(memory.memory)) {
+ displayedMemoriesRef.current.add(memory.memory)
+ newMemories.push({
+ url: document.url || "",
+ title: memory.title || document.title || "Memory",
+ description: memory.memory || "",
+ fullContent: memory.memory || "",
+ })
+ }
+ },
+ )
+
+ if (newMemories.length > 0 && messages.length < 10) {
+ setMessages((prev) => [
+ ...prev,
+ {
+ message: newMemories
+ .map((memory) => memory.description)
+ .join("\n"),
+ type: "memory" as const,
+ memories: newMemories,
+ },
+ ])
+ }
+ }
+
+ if (document.memories && document.memories.length > 0) {
+ break
+ }
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, pollInterval))
+ } catch (error) {
+ console.warn("Error polling for memories:", error)
+ await new Promise((resolve) => setTimeout(resolve, pollInterval))
+ }
+ }
+ },
+ [messages.length],
+ )
+
+ useEffect(() => {
+ if (!formData) return
+
+ const formDataMessages: typeof messages = []
+
+ if (formData.twitter) {
+ formDataMessages.push({
+ message: formData.twitter,
+ url: formData.twitter,
+ title: "X/Twitter",
+ description: formData.twitter,
+ type: "formData" as const,
+ })
+ }
+
+ if (formData.linkedin) {
+ formDataMessages.push({
+ message: formData.linkedin,
+ url: formData.linkedin,
+ title: "LinkedIn",
+ description: formData.linkedin,
+ type: "formData" as const,
+ })
+ }
+
+ if (formData.otherLinks.length > 0) {
+ formData.otherLinks.forEach((link) => {
+ formDataMessages.push({
+ message: link,
+ url: link,
+ title: "Link",
+ description: link,
+ type: "formData" as const,
+ })
+ })
+ }
+
+ if (formData.description?.trim()) {
+ formDataMessages.push({
+ message: formData.description,
+ title: "Likes",
+ description: formData.description,
+ type: "formData" as const,
+ })
+ }
+
+ setMessages(formDataMessages)
+
+ const hasContent =
+ formData.twitter ||
+ formData.linkedin ||
+ formData.otherLinks.length > 0 ||
+ formData.description?.trim()
+
+ if (!hasContent) return
+
+ const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
+
+ const processContent = async () => {
+ setIsLoading(true)
+
+ try {
+ const documentIds: string[] = []
+
+ if (formData.description?.trim()) {
+ try {
+ const descDocResponse = await $fetch("@post/documents", {
+ body: {
+ content: formData.description,
+ containerTags: ["sm_project_default"],
+ metadata: {
+ sm_source: "consumer",
+ description_source: "user_input",
+ },
+ },
+ })
+
+ if (descDocResponse.data?.id) {
+ documentIds.push(descDocResponse.data.id)
+ }
+ } catch (error) {
+ console.warn("Error creating description document:", error)
+ }
+ }
+
+ if (formData.twitter) {
+ try {
+ const researchResponse = await fetch("/api/onboarding/research", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ xUrl: formData.twitter,
+ name: user?.name,
+ email: user?.email,
+ }),
+ })
+
+ if (researchResponse.ok) {
+ const { text } = await researchResponse.json()
+
+ if (text?.trim()) {
+ const xDocResponse = await $fetch("@post/documents", {
+ body: {
+ content: text,
+ containerTags: ["sm_project_default"],
+ metadata: {
+ sm_source: "consumer",
+ onboarding_source: "x_research",
+ x_url: formData.twitter,
+ },
+ },
+ })
+
+ if (xDocResponse.data?.id) {
+ documentIds.push(xDocResponse.data.id)
+ }
+ }
+ }
+ } catch (error) {
+ console.warn("Error fetching X research:", error)
+ }
+ }
+
+ if (urls.length > 0) {
+ const response = await fetch("/api/onboarding/extract-content", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ urls }),
+ })
+ const { results } = await response.json()
+
+ for (const result of results) {
+ try {
+ const docResponse = await $fetch("@post/documents", {
+ body: {
+ content: result.text || result.description || "",
+ containerTags: ["sm_project_default"],
+ metadata: {
+ sm_source: "consumer",
+ exa_url: result.url,
+ exa_title: result.title,
+ },
+ },
+ })
+
+ if (docResponse.data?.id) {
+ documentIds.push(docResponse.data.id)
+ }
+ } catch (error) {
+ console.warn("Error creating document:", error)
+ }
+ }
+ }
+
+ if (documentIds.length > 0) {
+ await pollForMemories(documentIds)
+ }
+ } catch (error) {
+ console.warn("Error processing content:", error)
+ }
+ setIsLoading(false)
+ }
+
+ processContent()
+ }, [formData, pollForMemories, user])
+
+ return (
+ <AnimatePresence mode="wait">
+ {!isChatOpen ? (
+ <motion.div
+ key="closed"
+ className={cn(
+ "absolute top-0 right-0 flex items-start justify-start m-4",
+ dmSansClassName(),
+ )}
+ layoutId="chat-toggle-button"
+ >
+ <motion.button
+ onClick={toggleChat}
+ className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border border-[#17181A] text-white cursor-pointer"
+ style={{
+ background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
+ }}
+ >
+ <NovaOrb size={24} className="blur-none! z-10" />
+ Chat with Nova
+ </motion.button>
+ </motion.div>
+ ) : (
+ <motion.div
+ key="open"
+ className={cn(
+ "w-[450px] h-[calc(100vh-110px)] bg-[#0A0E14] backdrop-blur-md flex flex-col rounded-2xl m-4",
+ dmSansClassName(),
+ )}
+ initial={{ x: "100px", opacity: 0 }}
+ animate={{ x: 0, opacity: 1 }}
+ exit={{ x: "100px", opacity: 0 }}
+ transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
+ >
+ <motion.button
+ onClick={toggleChat}
+ className="absolute top-4 right-4 flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer"
+ style={{
+ background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
+ }}
+ layoutId="chat-toggle-button"
+ >
+ <PanelRightCloseIcon className="size-4" />
+ Close chat
+ </motion.button>
+ <div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end">
+ {messages.map((msg, i) => (
+ <div
+ key={`message-${i}-${msg.message}`}
+ className="flex items-start gap-2"
+ >
+ {msg.type === "waiting" ? (
+ <div className="flex items-center gap-2 text-white/50">
+ <NovaOrb size={30} className="blur-none!" />
+ <span className="text-sm">{msg.message}</span>
+ </div>
+ ) : (
+ <>
+ <div
+ className={cn(
+ "flex flex-col items-center justify-center w-[30px] h-full",
+ i !== 0 && "",
+ )}
+ >
+ {i === 0 && (
+ <div className="w-3 h-3 bg-[#293952]/40 rounded-full mb-1" />
+ )}
+ <div className="w-px flex-1 bg-[#293952]/40" />
+ </div>
+ {msg.type === "formData" && (
+ <div className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-1 flex-1">
+ {msg.title && (
+ <h3
+ className="text-sm font-medium"
+ style={{
+ background:
+ "linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
+ WebkitBackgroundClip: "text",
+ WebkitTextFillColor: "transparent",
+ backgroundClip: "text",
+ }}
+ >
+ {msg.title}
+ </h3>
+ )}
+ {msg.url && (
+ <a
+ href={msg.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-xs text-blue-400 hover:underline break-all block"
+ >
+ {msg.url}
+ </a>
+ )}
+ {msg.title === "Likes" && msg.description && (
+ <p className="text-xs text-white/70 mt-1">
+ {msg.description}
+ </p>
+ )}
+ </div>
+ )}
+ {msg.type === "memory" && (
+ <div className="space-y-2 w-full max-h-60 overflow-y-auto scrollbar-thin">
+ {msg.memories?.map((memory) => (
+ <div
+ key={memory.url + memory.title}
+ className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-2"
+ >
+ {memory.title && (
+ <h3
+ className="text-sm font-medium"
+ style={{
+ background:
+ "linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
+ WebkitBackgroundClip: "text",
+ WebkitTextFillColor: "transparent",
+ backgroundClip: "text",
+ }}
+ >
+ {memory.title}
+ </h3>
+ )}
+ {memory.url && (
+ <a
+ href={memory.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-xs text-blue-400 hover:underline break-all"
+ >
+ {memory.url}
+ </a>
+ )}
+ {memory.description && (
+ <p className="text-xs text-white/50 mt-1">
+ {memory.description}
+ </p>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </>
+ )}
+ </div>
+ ))}
+ {messages.length === 0 && !isLoading && !formData && (
+ <div className="flex items-center gap-2 text-white/50">
+ <NovaOrb size={28} className="blur-none!" />
+ <span className="text-sm">Waiting for your input</span>
+ </div>
+ )}
+ {isLoading && (
+ <div className="flex items-center gap-2 text-foreground/50">
+ <NovaOrb size={28} className="blur-none!" />
+ <span className="text-sm">Extracting memories...</span>
+ </div>
+ )}
+ </div>
+
+ <div className="p-4">
+ <form
+ className="flex flex-col gap-3 bg-[#0D121A] rounded-xl p-2 relative"
+ onSubmit={(e) => {
+ e.preventDefault()
+ if (message.trim()) {
+ handleSend()
+ }
+ }}
+ >
+ <input
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Chat with your Supermemory"
+ className="w-full text-white placeholder:text-white/20 rounded-sm outline-none resize-none text-base leading-relaxed bg-transparent px-2 h-10"
+ />
+ <div className="flex justify-end absolute bottom-3 right-2">
+ <Button
+ type="submit"
+ disabled={!message.trim()}
+ className="text-white/20 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all"
+ size="icon"
+ >
+ <SendIcon className="size-4" />
+ </Button>
+ </div>
+ </form>
+ </div>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ )
+}
diff --git a/apps/web/components/new/onboarding/setup/header.tsx b/apps/web/components/new/onboarding/setup/header.tsx
new file mode 100644
index 00000000..4981c6aa
--- /dev/null
+++ b/apps/web/components/new/onboarding/setup/header.tsx
@@ -0,0 +1,47 @@
+import { motion } from "motion/react"
+import { Logo } from "@ui/assets/Logo"
+import { useAuth } from "@lib/auth-context"
+import { useEffect, useState } from "react"
+import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
+
+export function SetupHeader() {
+ const { user } = useAuth()
+ const [name, setName] = useState<string>("")
+
+ useEffect(() => {
+ const storedName =
+ localStorage.getItem("username") || localStorage.getItem("userName") || ""
+ setName(storedName)
+ }, [])
+
+ const userName = name ? `${name.split(" ")[0]}'s` : "My"
+
+ return (
+ <motion.div
+ className="flex p-6 justify-between items-center"
+ initial={{ opacity: 0, y: -10 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.6, ease: "easeOut" }}
+ >
+ <div className="flex items-center z-10!">
+ <Logo className="h-7" />
+ {name && (
+ <div className="flex flex-col items-start justify-center ml-2">
+ <p className="text-[#8B8B8B] text-[11px] leading-tight">
+ {userName}
+ </p>
+ <p className="text-white font-bold text-xl leading-none -mt-1">
+ supermemory
+ </p>
+ </div>
+ )}
+ </div>
+ {user && (
+ <Avatar className="border border-border h-8 w-8 md:h-10 md:w-10 z-10!">
+ <AvatarImage src={user?.image ?? ""} />
+ <AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
+ </Avatar>
+ )}
+ </motion.div>
+ )
+}
diff --git a/apps/web/components/new/onboarding/setup/integrations-step.tsx b/apps/web/components/new/onboarding/setup/integrations-step.tsx
new file mode 100644
index 00000000..49e3062a
--- /dev/null
+++ b/apps/web/components/new/onboarding/setup/integrations-step.tsx
@@ -0,0 +1,183 @@
+"use client"
+
+import { useState } from "react"
+import { Button } from "@ui/components/button"
+import { MCPDetailView } from "@/components/new/mcp-modal/mcp-detail-view"
+import { XBookmarksDetailView } from "@/components/new/onboarding/x-bookmarks-detail-view"
+import { useRouter } from "next/navigation"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
+
+const integrationCards = [
+ {
+ title: "Capture",
+ description: "Add the Chrome extension for one-click saves",
+ icon: (
+ <div className="rounded-full flex items-center justify-center">
+ <img
+ src="/onboarding/chrome.png"
+ alt="Chrome"
+ className="w-20 h-auto"
+ />
+ </div>
+ ),
+ },
+ {
+ title: "Connect to AI",
+ description: "Set up once and use your memory in Cursor, Claude, etc",
+ icon: (
+ <div className="rounded flex items-center justify-center">
+ <img src="/onboarding/mcp.png" alt="MCP" className="size-28 h-auto" />
+ </div>
+ ),
+ },
+ {
+ title: "Connect",
+ description: "Link Notion, Google Drive, or OneDrive to import your docs",
+ icon: (
+ <div className="rounded flex items-center justify-center">
+ <img
+ src="/onboarding/connectors.png"
+ alt="Connectors"
+ className="w-20 h-auto"
+ />
+ </div>
+ ),
+ },
+ {
+ title: "Import",
+ description:
+ "Bring in X/Twitter bookmarks, and turn them into useful memories",
+ icon: (
+ <div className="rounded flex items-center justify-center">
+ <img src="/onboarding/x.png" alt="X" className="size-14" />
+ </div>
+ ),
+ },
+]
+
+export function IntegrationsStep() {
+ const router = useRouter()
+ const [selectedCard, setSelectedCard] = useState<string | null>(null)
+ const { markOnboardingCompleted } = useOnboardingStorage()
+
+ const handleContinue = () => {
+ markOnboardingCompleted()
+ router.push("/new")
+ }
+
+ if (selectedCard === "Connect to AI") {
+ return <MCPDetailView onBack={() => setSelectedCard(null)} />
+ }
+ if (selectedCard === "Import") {
+ return <XBookmarksDetailView onBack={() => setSelectedCard(null)} />
+ }
+ return (
+ <div className="flex flex-col items-center justify-center h-full p-8">
+ <div className="text-center mb-6 flex flex-col items-center justify-center space-y-2">
+ <h1 className="text-white text-[32px] font-medium">
+ Build your personal memory
+ </h1>
+ <p
+ className={cn(
+ "text-white text-sm opacity-60 max-w-xs",
+ dmSansClassName(),
+ )}
+ >
+ Your supermemory comes alive when you <br /> capture and connect
+ what's important
+ </p>
+ </div>
+
+ <div className="grid grid-cols-2 gap-3 max-w-lg w-full mb-12">
+ {integrationCards.map((card) => {
+ const isClickable =
+ card.title === "Connect to AI" ||
+ card.title === "Capture" ||
+ card.title === "Import"
+
+ if (isClickable) {
+ return (
+ <button
+ key={card.title}
+ type="button"
+ className={cn(
+ "bg-[#080B0F] relative rounded-lg p-3 hover:border-[#3374FF] hover:border-[0.1px] transition-colors duration-300 border-[0.1px] border-[#0D121A] cursor-pointer text-left w-full hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:175%_auto] hover:bg-[center_top_2rem] hover:bg-no-repeat",
+ "hover:border-b-0 border-b-0",
+ )}
+ onClick={() => {
+ if (card.title === "Capture") {
+ window.open(
+ "https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
+ "_blank",
+ )
+ } else {
+ setSelectedCard(card.title)
+ }
+ }}
+ >
+ <div className="flex-1 mt-10">
+ <h3 className="text-white text-sm font-medium">
+ {card.title}
+ </h3>
+ <p
+ className={cn(
+ "text-[#8B8B8B] text-xs leading-relaxed",
+ dmSansClassName(),
+ )}
+ >
+ {card.description}
+ </p>
+ </div>
+ <div className="absolute top-0 right-0">{card.icon}</div>
+ </button>
+ )
+ }
+
+ return (
+ <div
+ key={card.title}
+ className={cn(
+ "bg-[#080B0F] relative rounded-lg p-3 hover:border-[#3374FF] hover:border-[0.1px] transition-colors duration-300 border-[0.1px] border-[#0D121A] hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:175%_auto] hover:bg-[center_top_2rem] hover:bg-no-repeat",
+ "hover:border-b-0 border-b-0",
+ )}
+ >
+ <div className="flex-1 mt-10">
+ <h3 className="text-white text-sm font-medium">{card.title}</h3>
+ <p
+ className={cn(
+ "text-[#8B8B8B] text-xs leading-relaxed",
+ dmSansClassName(),
+ )}
+ >
+ {card.description}
+ </p>
+ </div>
+ <div className="absolute top-0 right-0">{card.icon}</div>
+ </div>
+ )
+ })}
+ </div>
+
+ <div className="flex justify-between w-full max-w-4xl">
+ <Button
+ variant="link"
+ className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
+ onClick={() =>
+ router.push("/new/onboarding?flow=setup&step=relatable")
+ }
+ >
+ ← Back
+ </Button>
+ <Button
+ variant="link"
+ className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
+ onClick={handleContinue}
+ >
+ Continue →
+ </Button>
+ </div>
+ </div>
+ )
+}
diff --git a/apps/web/components/new/onboarding/setup/relatable-question.tsx b/apps/web/components/new/onboarding/setup/relatable-question.tsx
new file mode 100644
index 00000000..5fbe9bb4
--- /dev/null
+++ b/apps/web/components/new/onboarding/setup/relatable-question.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import { useState } from "react"
+import { motion, AnimatePresence } from "motion/react"
+import { Button } from "@ui/components/button"
+import { useRouter } from "next/navigation"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+
+const relatableOptions = [
+ {
+ emoji: "😔",
+ text: "I always forget what I save in my twitter bookmarks",
+ },
+ {
+ emoji: "😭",
+ text: "Going through e-books manually is so tedious",
+ },
+ {
+ emoji: "🥲",
+ text: "I always have to feed every AI app with my data",
+ },
+ {
+ emoji: "😵‍💫",
+ text: "Referring meeting notes makes my AI chat hallucinate",
+ },
+ {
+ emoji: "🫤",
+ text: "I save nothing on my browser, it's just useless",
+ },
+]
+
+export function RelatableQuestion() {
+ const router = useRouter()
+ const [selectedOptions, setSelectedOptions] = useState<number[]>([])
+
+ const handleContinueOrSkip = () => {
+ router.push("/new/onboarding?flow=setup&step=integrations")
+ }
+
+ return (
+ <motion.div
+ className="flex flex-col items-center justify-center h-full"
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: -20 }}
+ transition={{ duration: 0.6 }}
+ >
+ <motion.h1
+ className="text-white text-[32px] font-medium mb-6 text-center"
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.6, delay: 0.2 }}
+ >
+ Which of these sound most relatable?
+ </motion.h1>
+
+ <div
+ className={cn(
+ "flex flex-wrap justify-center gap-4 max-w-3xl",
+ dmSansClassName(),
+ )}
+ >
+ {relatableOptions.map((option, index) => (
+ <div
+ key={option.text}
+ className={cn(
+ "rounded-lg max-w-[140px] min-h-[159px] transition-all duration-300",
+ selectedOptions.includes(index)
+ ? "p-px bg-linear-to-b from-[#3374FF] to-[#1A63FF00]"
+ : "p-0 border border-[#0D121A] hover:border-[#4C608B66]",
+ )}
+ >
+ <button
+ className={`
+ group relative w-full h-full rounded-lg p-2 cursor-pointer transition-all duration-300 overflow-hidden
+ bg-[#080B0F] hover:bg-no-repeat
+ `}
+ onClick={() => {
+ setSelectedOptions((prev) =>
+ prev.includes(index)
+ ? prev.filter((i) => i !== index)
+ : [...prev, index],
+ )
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault()
+ setSelectedOptions((prev) =>
+ prev.includes(index)
+ ? prev.filter((i) => i !== index)
+ : [...prev, index],
+ )
+ }
+ }}
+ type="button"
+ >
+ <AnimatePresence>
+ {selectedOptions.includes(index) && (
+ <motion.div
+ className="absolute inset-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[550%_auto] bg-top bg-no-repeat"
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ exit={{ opacity: 0 }}
+ />
+ )}
+ </AnimatePresence>
+ <div className="relative flex flex-col items-start justify-between h-full">
+ <span
+ className={`text-2xl ${
+ selectedOptions.includes(index)
+ ? "opacity-100"
+ : "opacity-70 group-hover:opacity-100"
+ }`}
+ >
+ {option.emoji}
+ </span>
+ <p
+ className={`text-white text-sm leading-[135%] align-bottom text-left transition-opacity duration-300 ${
+ selectedOptions.includes(index)
+ ? "opacity-100"
+ : "opacity-50 group-hover:opacity-100"
+ }`}
+ >
+ {option.text}
+ </p>
+ </div>
+ </button>
+ </div>
+ ))}
+ </div>
+ <div className="flex gap-4 my-8">
+ <div key={selectedOptions.length === 0 ? "skip" : "continue"}>
+ <Button
+ className={cn(
+ "font-medium text-white hover:no-underline cursor-pointer",
+ selectedOptions.length !== 0 ? "rounded-xl" : "",
+ )}
+ variant={selectedOptions.length !== 0 ? "onboarding" : "link"}
+ size="lg"
+ onClick={handleContinueOrSkip}
+ style={
+ selectedOptions.length !== 0
+ ? {
+ background:
+ "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
+ }
+ : undefined
+ }
+ >
+ {selectedOptions.length === 0
+ ? "Skip for now →"
+ : "Remember this →"}
+ </Button>
+ </div>
+ </div>
+ </motion.div>
+ )
+}
diff --git a/apps/web/components/new/onboarding/welcome/continue-step.tsx b/apps/web/components/new/onboarding/welcome/continue-step.tsx
new file mode 100644
index 00000000..86b4593a
--- /dev/null
+++ b/apps/web/components/new/onboarding/welcome/continue-step.tsx
@@ -0,0 +1,45 @@
+import { dmSansClassName } from "@/lib/fonts"
+import { cn } from "@lib/utils"
+import { Button } from "@ui/components/button"
+import { motion } from "motion/react"
+import { useRouter } from "next/navigation"
+
+export function ContinueStep() {
+ const router = useRouter()
+
+ const handleContinue = () => {
+ router.push("/new/onboarding?flow=welcome&step=features")
+ }
+
+ return (
+ <motion.div
+ className="text-center"
+ initial={{ opacity: 0, y: 0, scale: 0.9 }}
+ animate={{ opacity: 1, y: 0, scale: 1 }}
+ exit={{ opacity: 0, y: 0, scale: 0.9 }}
+ transition={{ duration: 1, ease: "easeOut" }}
+ layout
+ >
+ <p
+ className={cn(
+ "text-[#8A8A8A] text-sm mb-6 max-w-sm",
+ dmSansClassName(),
+ )}
+ >
+ I'm built with Supermemory's super fast memory API,
+ <br /> so you never have to worry about forgetting <br /> what matters
+ across your AI apps.
+ </p>
+ <Button
+ variant="onboarding"
+ onClick={handleContinue}
+ style={{
+ background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
+ width: "147px",
+ }}
+ >
+ Continue →
+ </Button>
+ </motion.div>
+ )
+}
diff --git a/apps/web/components/new/onboarding/welcome/features-step.tsx b/apps/web/components/new/onboarding/welcome/features-step.tsx
new file mode 100644
index 00000000..2671efc1
--- /dev/null
+++ b/apps/web/components/new/onboarding/welcome/features-step.tsx
@@ -0,0 +1,98 @@
+import { motion } from "motion/react"
+import { Button } from "@ui/components/button"
+import { useRouter } from "next/navigation"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+
+export function FeaturesStep() {
+ const router = useRouter()
+
+ const handleContinue = () => {
+ router.push("/new/onboarding?flow=welcome&step=memories")
+ }
+ return (
+ <motion.div
+ initial={{ opacity: 0, y: 40 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.6, ease: "easeOut", delay: 0.3 }}
+ className="text-center max-w-88 space-y-6"
+ layout
+ >
+ <h2 className="text-white text-[32px] font-medium leading-[110%]">
+ What I can do for you
+ </h2>
+
+ <div className={cn("space-y-4 mb-[24px] mx-4", dmSansClassName())}>
+ <div className="flex items-start space-x-2">
+ <div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
+ <img
+ src="/onboarding/human-brain.png"
+ alt="Brain icon"
+ className="w-14 h-14"
+ />
+ </div>
+ <div className="text-left">
+ <p className="text-white font-light">Remember every context</p>
+ <p className="text-[#8A8A8A] text-[14px]">
+ I keep track of what you've saved and shared with your
+ supermemory.
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-2">
+ <div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
+ <img
+ src="/onboarding/search.png"
+ alt="Search icon"
+ className="w-14 h-14"
+ />
+ </div>
+ <div className="text-left">
+ <p className="text-white font-light">Find when you need it</p>
+ <p className="text-[#8A8A8A] text-[14px]">
+ I surface the right memories inside <br /> your supermemory,
+ superfast.
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-2">
+ <div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
+ <img
+ src="/onboarding/plant.png"
+ alt="Growth icon"
+ className="w-14 h-14"
+ />
+ </div>
+ <div className="text-left">
+ <p className="text-white font-light">Grow with your supermemory</p>
+ <p className="text-[#8A8A8A] text-[14px]">
+ I learn and personalize over time, so every interaction feels
+ natural.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <motion.div
+ animate={{
+ opacity: 1,
+ y: 0,
+ }}
+ transition={{ duration: 1, ease: "easeOut", delay: 1 }}
+ initial={{ opacity: 0, y: 10 }}
+ >
+ <Button
+ variant="onboarding"
+ style={{
+ background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
+ }}
+ onClick={handleContinue}
+ >
+ Add memories →
+ </Button>
+ </motion.div>
+ </motion.div>
+ )
+}
diff --git a/apps/web/components/new/onboarding/welcome/greeting-step.tsx b/apps/web/components/new/onboarding/welcome/greeting-step.tsx
new file mode 100644
index 00000000..744e3719
--- /dev/null
+++ b/apps/web/components/new/onboarding/welcome/greeting-step.tsx
@@ -0,0 +1,23 @@
+import { motion } from "motion/react"
+
+interface GreetingStepProps {
+ name: string
+}
+
+export function GreetingStep({ name }: GreetingStepProps) {
+ const userName = name ? `${name.split(" ")[0]}` : ""
+ return (
+ <motion.div
+ className="text-center"
+ initial={{ opacity: 0, y: 0 }}
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: 0 }}
+ transition={{ duration: 1, ease: "easeOut" }}
+ layout
+ >
+ <h2 className="text-white text-[32px] font-medium mb-2">
+ Hi {userName}, I'm Nova
+ </h2>
+ </motion.div>
+ )
+}
diff --git a/apps/web/components/new/onboarding/welcome/input-step.tsx b/apps/web/components/new/onboarding/welcome/input-step.tsx
new file mode 100644
index 00000000..077d4944
--- /dev/null
+++ b/apps/web/components/new/onboarding/welcome/input-step.tsx
@@ -0,0 +1,96 @@
+import { motion } from "motion/react"
+import { LabeledInput } from "@ui/input/labeled-input"
+import { Button } from "@ui/components/button"
+
+interface InputStepProps {
+ name: string
+ setName: (name: string) => void
+ handleSubmit: () => void
+ isSubmitting: boolean
+}
+
+export function InputStep({
+ name,
+ setName,
+ handleSubmit,
+ isSubmitting,
+}: InputStepProps) {
+ return (
+ <motion.div
+ className="text-center min-w-[250px] flex flex-col"
+ style={{ gap: "24px" }}
+ initial={{
+ opacity: 0,
+ y: 10,
+ }}
+ animate={{
+ opacity: 1,
+ y: 0,
+ }}
+ exit={{
+ opacity: 0,
+ y: -10,
+ transition: {
+ duration: 0.5,
+ ease: "easeOut",
+ bounce: 0,
+ },
+ }}
+ transition={{
+ duration: 0.8,
+ ease: "easeOut",
+ delay: 1,
+ }}
+ layout
+ >
+ <h2 className="text-white text-[32px] font-medium leading-[110%]">
+ What should I call you?
+ </h2>
+ <div className="flex items-center w-full relative">
+ <LabeledInput
+ inputType="text"
+ inputPlaceholder="your name"
+ className="w-full flex-1"
+ inputProps={{
+ defaultValue: name,
+ onKeyDown: (e) => {
+ if (e.key === "Enter") {
+ handleSubmit()
+ }
+ },
+ className: "!text-white placeholder:!text-[#525966] !h-[40px] pl-4",
+ }}
+ onChange={(e) => setName((e.target as HTMLInputElement).value)}
+ style={{
+ background:
+ "linear-gradient(0deg, rgba(91, 126, 245, 0.04) 0%, rgba(91, 126, 245, 0.04) 100%)",
+ }}
+ />
+ <Button
+ className={`rounded-[8px] w-8 h-8 p-2 absolute right-1 border-[0.5px] border-[#161F2C] hover:cursor-pointer hover:scale-[0.95] active:scale-[0.95] transition-transform ${
+ isSubmitting ? "scale-[0.90]" : ""
+ }`}
+ size="icon"
+ onClick={handleSubmit}
+ style={{
+ background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
+ }}
+ >
+ <svg
+ width="12"
+ height="9"
+ viewBox="0 0 12 9"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Next</title>
+ <path
+ d="M8.05099 9.60156L6.93234 8.49987L9.00014 6.44902L9.62726 6.04224L9.54251 5.788L8.79675 5.90665H0.0170898V4.31343H8.79675L9.54251 4.43207L9.62726 4.17783L9.00014 3.77105L6.93234 1.72021L8.05099 0.601562L11.9832 4.53377V5.68631L8.05099 9.60156Z"
+ fill="#FAFAFA"
+ />
+ </svg>
+ </Button>
+ </div>
+ </motion.div>
+ )
+}
diff --git a/apps/web/components/new/onboarding/welcome/profile-step.tsx b/apps/web/components/new/onboarding/welcome/profile-step.tsx
new file mode 100644
index 00000000..65eb21c6
--- /dev/null
+++ b/apps/web/components/new/onboarding/welcome/profile-step.tsx
@@ -0,0 +1,303 @@
+import { motion } from "motion/react"
+import { Button } from "@ui/components/button"
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import {
+ parseXHandle,
+ parseLinkedInHandle,
+ toXProfileUrl,
+ toLinkedInProfileUrl,
+} from "@/lib/url-helpers"
+
+interface ProfileStepProps {
+ onSubmit: (data: {
+ twitter: string
+ linkedin: string
+ description: string
+ otherLinks: string[]
+ }) => void
+}
+
+type ValidationError = {
+ twitter: string | null
+ linkedin: string | null
+}
+
+export function ProfileStep({ onSubmit }: ProfileStepProps) {
+ const router = useRouter()
+ const [otherLinks, setOtherLinks] = useState([""])
+ const [twitterHandle, setTwitterHandle] = useState("")
+ const [linkedinProfile, setLinkedinProfile] = useState("")
+ const [description, setDescription] = useState("")
+ const [isSubmitting] = useState(false)
+ const [errors, setErrors] = useState<ValidationError>({
+ twitter: null,
+ linkedin: null,
+ })
+
+ const addOtherLink = () => {
+ if (otherLinks.length < 3) {
+ setOtherLinks([...otherLinks, ""])
+ }
+ }
+
+ const updateOtherLink = (index: number, value: string) => {
+ const updated = [...otherLinks]
+ updated[index] = value
+ setOtherLinks(updated)
+ }
+
+ const validateTwitterHandle = (handle: string): string | null => {
+ if (!handle.trim()) return null
+
+ // Basic validation: handle should be alphanumeric, underscore, or hyphen
+ // X/Twitter handles can contain letters, numbers, and underscores, max 15 chars
+ const handlePattern = /^[a-zA-Z0-9_]{1,15}$/
+ if (!handlePattern.test(handle.trim())) {
+ return "Enter your handle or profile link"
+ }
+
+ return null
+ }
+
+ const validateLinkedInHandle = (handle: string): string | null => {
+ if (!handle.trim()) return null
+
+ // Basic validation: LinkedIn handles are typically alphanumeric with hyphens
+ // They can be quite long, so we'll be lenient
+ const handlePattern = /^[a-zA-Z0-9-]+$/
+ if (!handlePattern.test(handle.trim())) {
+ return "Enter your handle or profile link"
+ }
+
+ return null
+ }
+
+ const handleTwitterChange = (value: string) => {
+ const parsedHandle = parseXHandle(value)
+ setTwitterHandle(parsedHandle)
+ const error = validateTwitterHandle(parsedHandle)
+ setErrors((prev) => ({ ...prev, twitter: error }))
+ }
+
+ const handleLinkedInChange = (value: string) => {
+ const parsedHandle = parseLinkedInHandle(value)
+ setLinkedinProfile(parsedHandle)
+ const error = validateLinkedInHandle(parsedHandle)
+ setErrors((prev) => ({ ...prev, linkedin: error }))
+ }
+
+ return (
+ <motion.div
+ initial={{ opacity: 0, y: 40 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ duration: 0.6, ease: "easeOut", delay: 0.3 }}
+ className="text-center w-full "
+ >
+ <h2 className="text-white text-[32px] font-medium mb-4 mt-[-36px]">
+ Let's add your memories
+ </h2>
+
+ <div className="space-y-4 max-w-[329px] mx-auto overflow-visible gap-4">
+ <div className="text-left gap-[6px] flex flex-col" id="x-twitter-field">
+ <label
+ htmlFor="twitter-handle"
+ className="text-white text-sm font-medium block pl-2"
+ >
+ X/Twitter
+ </label>
+ <div className="relative flex items-center">
+ <div
+ className={`flex items-center border rounded-xl overflow-hidden h-[40px] w-full ${
+ errors.twitter
+ ? "border-[#52596633] bg-[#290F0A]"
+ : "border-onboarding/20"
+ }`}
+ >
+ <div className="px-3 py-2 bg-[#070E1B] text-white/60 text-sm border-r border-onboarding/20 whitespace-nowrap">
+ x.com/
+ </div>
+ <input
+ id="twitter-handle"
+ type="text"
+ placeholder="handle"
+ value={twitterHandle}
+ onChange={(e) => handleTwitterChange(e.target.value)}
+ onBlur={() => {
+ if (twitterHandle.trim()) {
+ const error = validateTwitterHandle(twitterHandle)
+ setErrors((prev) => ({ ...prev, twitter: error }))
+ }
+ }}
+ className={`flex-1 px-4 py-2 bg-[#070E1B] text-white placeholder-onboarding focus:outline-none transition-colors ${
+ errors.twitter ? "bg-[#290F0A]" : ""
+ }`}
+ />
+ </div>
+ {errors.twitter && (
+ <div className="absolute left-full ml-3">
+ <div
+ className={cn(
+ "relative shrink-0 px-3 py-2 bg-[#290F0A] text-[#C73B1B] rounded-xl",
+ dmSansClassName(),
+ )}
+ >
+ <div className="absolute left-0.5 top-1/2 -translate-x-full -translate-y-1/2 w-0 h-0 border-t-[6px] border-b-[6px] border-r-8 border-t-transparent border-b-transparent border-[#290F0A]" />
+ <p className="text-xs whitespace-nowrap">{errors.twitter}</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div className="text-left gap-[6px] flex flex-col" id="linkedin-field">
+ <label
+ htmlFor="linkedin-profile"
+ className="text-white text-sm font-medium block pl-2"
+ >
+ LinkedIn
+ </label>
+ <div className="relative flex items-center">
+ <div
+ className={`flex items-center border rounded-xl overflow-hidden h-[40px] w-full ${
+ errors.linkedin
+ ? "border-[#52596633] bg-[#290F0A]"
+ : "border-onboarding/20"
+ }`}
+ >
+ <div className="px-3 py-2 bg-[#070E1B] text-white/60 text-sm border-r border-onboarding/20 whitespace-nowrap w-[140px]">
+ linkedin.com/in/
+ </div>
+ <input
+ id="linkedin-profile"
+ type="text"
+ placeholder="username"
+ value={linkedinProfile}
+ onChange={(e) => handleLinkedInChange(e.target.value)}
+ onBlur={() => {
+ if (linkedinProfile.trim()) {
+ const error = validateLinkedInHandle(linkedinProfile)
+ setErrors((prev) => ({ ...prev, linkedin: error }))
+ }
+ }}
+ className={`flex-1 px-4 py-2 bg-[#070E1B] text-white placeholder-onboarding focus:outline-none transition-colors ${
+ errors.linkedin ? "bg-[#290F0A]" : ""
+ }`}
+ />
+ </div>
+ {errors.linkedin && (
+ <div className="absolute left-full ml-3">
+ <div
+ className={cn(
+ "relative shrink-0 px-3 py-2 bg-[#290F0A] text-[#C73B1B] rounded-xl",
+ dmSansClassName(),
+ )}
+ >
+ <div className="absolute left-0.5 top-1/2 -translate-x-full -translate-y-1/2 w-0 h-0 border-t-[6px] border-b-[6px] border-r-8 border-t-transparent border-b-transparent border-[#290F0A]" />
+ <p className="text-xs whitespace-nowrap">{errors.linkedin}</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div
+ className="text-left gap-[6px] flex flex-col"
+ id="other-links-field"
+ >
+ <div className="flex items-center justify-between">
+ <label
+ htmlFor="other-links"
+ className="text-white text-sm font-medium pl-2"
+ >
+ Other links
+ </label>
+ <span className="text-onboarding text-[10px]">Upto 3</span>
+ </div>
+ <div className="flex flex-col gap-1.5">
+ {otherLinks.map((link, index) => (
+ <div
+ key={`other-link-${index}`}
+ className="flex items-center relative"
+ >
+ <input
+ id={`other-links-${index}`}
+ type="text"
+ placeholder="Add your website, GitHub, Notion..."
+ value={link}
+ onChange={(e) => updateOtherLink(index, e.target.value)}
+ className="flex-1 px-4 py-2 bg-[#070E1B] border border-onboarding/20 rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors h-[40px]"
+ />
+ {index === otherLinks.length - 1 && otherLinks.length < 3 && (
+ <button
+ type="button"
+ onClick={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ addOtherLink()
+ }}
+ className="size-8 m-1 absolute right-0 top-0 bg-black border border-[#161F2C] rounded-lg flex items-center justify-center text-white hover:bg-[#161F2C] transition-colors text-xl"
+ >
+ +
+ </button>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+
+ <div
+ className="text-left gap-[6px] flex flex-col"
+ id="description-field"
+ >
+ <label
+ htmlFor="description"
+ className="text-white text-sm font-medium block pl-2"
+ >
+ What do you do? What do you like?
+ </label>
+ <textarea
+ id="description"
+ placeholder="Tell me the basics in your words. A few lines about your work, interests, etc."
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ rows={2}
+ className="w-full px-4 py-2 bg-[#070E1B] border border-onboarding/20 rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors min-h-16"
+ />
+ </div>
+ </div>
+
+ <motion.div
+ animate={{
+ opacity: 1,
+ y: 0,
+ }}
+ transition={{ duration: 1, ease: "easeOut", delay: 1 }}
+ initial={{ opacity: 0, y: 10 }}
+ className="mt-[24px] pb-30"
+ >
+ <Button
+ variant="onboarding"
+ disabled={isSubmitting}
+ style={{
+ background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
+ }}
+ onClick={() => {
+ const formData = {
+ twitter: toXProfileUrl(twitterHandle),
+ linkedin: toLinkedInProfileUrl(linkedinProfile),
+ description: description,
+ otherLinks: otherLinks.filter((l) => l.trim()),
+ }
+ onSubmit(formData)
+ router.push("/new/onboarding?flow=setup&step=relatable")
+ }}
+ >
+ {isSubmitting ? "Fetching..." : "Remember this →"}
+ </Button>
+ </motion.div>
+ </motion.div>
+ )
+}
diff --git a/apps/web/components/new/onboarding/welcome/welcome-step.tsx b/apps/web/components/new/onboarding/welcome/welcome-step.tsx
new file mode 100644
index 00000000..5c0d998f
--- /dev/null
+++ b/apps/web/components/new/onboarding/welcome/welcome-step.tsx
@@ -0,0 +1,16 @@
+import { motion } from "motion/react"
+
+export function WelcomeStep() {
+ return (
+ <motion.div
+ className="text-center"
+ initial={{ opacity: 0, y: 0 }}
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: 0 }}
+ transition={{ duration: 1, ease: "easeOut" }}
+ layout
+ >
+ <h2 className="text-white text-[32px] font-medium mb-2">Welcome to...</h2>
+ </motion.div>
+ )
+}
diff --git a/apps/web/components/new/onboarding/x-bookmarks-detail-view.tsx b/apps/web/components/new/onboarding/x-bookmarks-detail-view.tsx
new file mode 100644
index 00000000..acd4ebf8
--- /dev/null
+++ b/apps/web/components/new/onboarding/x-bookmarks-detail-view.tsx
@@ -0,0 +1,111 @@
+"use client"
+
+import { Button } from "@ui/components/button"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import Image from "next/image"
+
+interface XBookmarksDetailViewProps {
+ onBack: () => void
+}
+
+const steps = [
+ {
+ number: 1,
+ title: "Install the Chrome Extension and login with your supermemory",
+ image: "/onboarding/chrome-ext-1.png",
+ },
+ {
+ number: 2,
+ title: "Visit the bookmarks tab on X and one-click import your bookmarks",
+ image: "/onboarding/chrome-ext-2.png",
+ },
+ {
+ number: 3,
+ title: "Talk to your bookmarks via Nova & see it in your memory graph",
+ image: "/onboarding/chrome-ext-3.png",
+ },
+]
+
+export function XBookmarksDetailView({ onBack }: XBookmarksDetailViewProps) {
+ const handleInstall = () => {
+ window.open(
+ "https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnangednailhoegogi",
+ "_blank",
+ )
+ }
+
+ return (
+ <div className="flex flex-col h-full p-8">
+ <div className="mb-6">
+ <Button
+ variant="link"
+ className="text-white hover:text-gray-300 p-0 hover:no-underline cursor-pointer"
+ onClick={onBack}
+ >
+ ← Back
+ </Button>
+ </div>
+
+ <div className="flex flex-col items-start justify-start flex-1">
+ <div>
+ <h1 className="text-white text-[20px] font-medium mb-3 text-start">
+ Import your X bookmarks via the Chrome Extension
+ </h1>
+
+ <p
+ className={cn(
+ "text-[#8B8B8B] text-sm mb-6 text-start max-w-2xl",
+ dmSansClassName(),
+ )}
+ >
+ Bring your X bookmarks into Supermemory in just a few clicks.
+ They'll be automatically embedded so you can easily find what you
+ need, right when you need it.
+ </p>
+
+ <div className="grid grid-cols-3 gap-4 mb-6 max-w-5xl w-full">
+ {steps.map((step) => (
+ <div
+ key={step.number}
+ className="flex flex-col items-center text-center bg-[#080B0F] p-3 rounded-[10px]"
+ >
+ <div className="rounded-2xl p-6 mb-3 w-full aspect-4/4 flex items-center justify-center relative overflow-hidden">
+ <Image
+ src={step.image}
+ alt={`Step ${step.number}`}
+ fill
+ className="object-cover"
+ unoptimized
+ />
+ </div>
+ <div className="flex flex-col items-start justify-start">
+ <div className="mb-2">
+ <span className="text-white text-sm font-medium">
+ Step {step.number}
+ </span>
+ </div>
+ <p
+ className={cn(
+ "text-[#8B8B8B] text-sm text-start",
+ dmSansClassName(),
+ )}
+ >
+ {step.title}
+ </p>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ <Button
+ className="rounded-xl px-4 py-2 text-white h-10 cursor-pointer mx-auto bg-black"
+ onClick={handleInstall}
+ >
+ Install Chrome Extension →
+ </Button>
+ </div>
+ </div>
+ )
+}
diff --git a/apps/web/components/new/settings/account.tsx b/apps/web/components/new/settings/account.tsx
index 10967947..3be01f9f 100644
--- a/apps/web/components/new/settings/account.tsx
+++ b/apps/web/components/new/settings/account.tsx
@@ -1,6 +1,6 @@
"use client"
-import { dmSans125ClassName } from "@/utils/fonts"
+import { dmSans125ClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { useAuth } from "@lib/auth-context"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
diff --git a/apps/web/components/new/settings/connections-mcp.tsx b/apps/web/components/new/settings/connections-mcp.tsx
index 760dcf22..5a619f65 100644
--- a/apps/web/components/new/settings/connections-mcp.tsx
+++ b/apps/web/components/new/settings/connections-mcp.tsx
@@ -1,6 +1,6 @@
"use client"
-import { dmSans125ClassName } from "@/utils/fonts"
+import { dmSans125ClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { fetchSubscriptionStatus } from "@lib/queries"
diff --git a/apps/web/components/new/settings/integrations.tsx b/apps/web/components/new/settings/integrations.tsx
index 0ce96bf7..deccf5e4 100644
--- a/apps/web/components/new/settings/integrations.tsx
+++ b/apps/web/components/new/settings/integrations.tsx
@@ -1,6 +1,6 @@
"use client"
-import { dmSans125ClassName } from "@/utils/fonts"
+import { dmSans125ClassName } from "@/lib/fonts"
import { analytics } from "@/lib/analytics"
import { cn } from "@lib/utils"
import { authClient } from "@lib/auth"
diff --git a/apps/web/components/new/settings/support.tsx b/apps/web/components/new/settings/support.tsx
index 691f5c38..5868f356 100644
--- a/apps/web/components/new/settings/support.tsx
+++ b/apps/web/components/new/settings/support.tsx
@@ -1,6 +1,6 @@
"use client"
-import { dmSans125ClassName } from "@/utils/fonts"
+import { dmSans125ClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { ArrowUpRight } from "lucide-react"
diff --git a/apps/web/components/new/text-editor/extensions.tsx b/apps/web/components/new/text-editor/extensions.tsx
new file mode 100644
index 00000000..9db26c2b
--- /dev/null
+++ b/apps/web/components/new/text-editor/extensions.tsx
@@ -0,0 +1,92 @@
+import StarterKit from "@tiptap/starter-kit"
+import Placeholder from "@tiptap/extension-placeholder"
+import Link from "@tiptap/extension-link"
+import Image from "@tiptap/extension-image"
+import TaskList from "@tiptap/extension-task-list"
+import TaskItem from "@tiptap/extension-task-item"
+import { cx } from "class-variance-authority"
+
+const placeholder = Placeholder.configure({
+ placeholder: 'Write, paste anything or type "/" for commands...',
+})
+
+const taskList = TaskList.configure({
+ HTMLAttributes: {
+ class: cx("not-prose pl-2"),
+ },
+})
+
+const taskItem = TaskItem.configure({
+ HTMLAttributes: {
+ class: cx("flex items-start my-4"),
+ },
+ nested: true,
+})
+
+const link = Link.configure({
+ HTMLAttributes: {
+ class: cx(
+ "text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer",
+ ),
+ },
+ openOnClick: false,
+})
+
+const image = Image.configure({
+ HTMLAttributes: {
+ class: cx("rounded-lg border border-muted"),
+ },
+})
+
+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-sm bg-muted 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: {
+ HTMLAttributes: {
+ class: cx("mt-4 mb-6 border-t border-muted-foreground"),
+ },
+ },
+ dropcursor: {
+ color: "#DBEAFE",
+ width: 4,
+ },
+ gapcursor: false,
+})
+
+export const defaultExtensions = [
+ starterKit,
+ placeholder,
+ link,
+ image,
+ taskList,
+ taskItem,
+]
diff --git a/apps/web/components/new/text-editor/index.tsx b/apps/web/components/new/text-editor/index.tsx
new file mode 100644
index 00000000..6446863d
--- /dev/null
+++ b/apps/web/components/new/text-editor/index.tsx
@@ -0,0 +1,174 @@
+"use client"
+
+import { useEditor, EditorContent } from "@tiptap/react"
+import { BubbleMenu } from "@tiptap/react/menus"
+import type { Editor } from "@tiptap/core"
+import { Markdown } from "@tiptap/markdown"
+import { useRef, useEffect, useCallback } from "react"
+import { defaultExtensions } from "./extensions"
+import { slashCommand } from "./suggestions"
+import { Bold, Italic, Code } from "lucide-react"
+import { useDebouncedCallback } from "use-debounce"
+import { cn } from "@lib/utils"
+
+const extensions = [...defaultExtensions, slashCommand, Markdown]
+
+export function TextEditor({
+ content: initialContent,
+ onContentChange,
+ onSubmit,
+}: {
+ content: string | undefined
+ onContentChange: (content: string) => void
+ onSubmit: () => void
+}) {
+ const containerRef = useRef<HTMLDivElement>(null)
+ const editorRef = useRef<Editor | null>(null)
+ const onSubmitRef = useRef(onSubmit)
+ const hasUserEditedRef = useRef(false)
+
+ useEffect(() => {
+ onSubmitRef.current = onSubmit
+ }, [onSubmit])
+
+ const debouncedUpdates = useDebouncedCallback((editor: Editor) => {
+ if (!hasUserEditedRef.current) return
+ const json = editor.getJSON()
+ const markdown = editor.storage.markdown?.manager?.serialize(json) ?? ""
+ onContentChange?.(markdown)
+ }, 500)
+
+ const editor = useEditor({
+ extensions,
+ content: initialContent,
+ contentType: "markdown",
+ immediatelyRender: true,
+ onCreate: ({ editor }) => {
+ editorRef.current = editor
+ },
+ onUpdate: ({ editor }) => {
+ editorRef.current = editor
+ debouncedUpdates(editor)
+ },
+ editorProps: {
+ handleKeyDown: (_view, event) => {
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
+ event.preventDefault()
+ onSubmitRef.current?.()
+ return true
+ }
+ hasUserEditedRef.current = true
+ return false
+ },
+ handleTextInput: () => {
+ hasUserEditedRef.current = true
+ return false
+ },
+ handlePaste: () => {
+ hasUserEditedRef.current = true
+ return false
+ },
+ handleDrop: () => {
+ hasUserEditedRef.current = true
+ return false
+ },
+ },
+ })
+
+ useEffect(() => {
+ if (editor && initialContent) {
+ hasUserEditedRef.current = false
+ editor.commands.setContent(initialContent, { contentType: "markdown" })
+ }
+ }, [editor, initialContent])
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent<HTMLDivElement>) => {
+ const target = e.target as HTMLElement
+ if (target.closest(".ProseMirror")) {
+ return
+ }
+ if (target.closest("button, a")) {
+ return
+ }
+
+ const proseMirror = containerRef.current?.querySelector(
+ ".ProseMirror",
+ ) as HTMLElement
+ if (proseMirror && editorRef.current) {
+ setTimeout(() => {
+ proseMirror.focus()
+ editorRef.current?.commands.focus("end")
+ }, 0)
+ }
+ },
+ [],
+ )
+
+ useEffect(() => {
+ return () => {
+ editor?.destroy()
+ }
+ }, [editor])
+
+ return (
+ <>
+ {/* biome-ignore lint/a11y/useSemanticElements: div is needed as container for editor, cannot use button */}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: we need to use a div to get the focus on the editor */}
+ <div
+ role="button"
+ tabIndex={0}
+ ref={containerRef}
+ onClick={handleClick}
+ className="w-full h-full outline-none prose prose-invert max-w-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:focus:outline-none [&_.ProseMirror-focused]:outline-none text-editor-prose cursor-text"
+ >
+ <EditorContent editor={editor} />
+ </div>
+ {editor && (
+ <BubbleMenu
+ editor={editor}
+ options={{ placement: "bottom-start", offset: 8 }}
+ >
+ <div className="flex items-center gap-1 rounded-[8px] bg-[#1b1f24] p-2 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.25),inset_1px_1px_1px_0px_rgba(255,255,255,0.1)]">
+ <button
+ type="button"
+ onClick={() =>
+ editor.chain().focus().toggleBold().run()
+ }
+ className={cn(
+ "flex items-center justify-center rounded-[4px] p-1.5 hover:bg-[#2e353d] cursor-pointer text-[#fafafa]",
+ editor.isActive("bold") && "bg-[#2e353d]",
+ )}
+ >
+ <Bold size={16} />
+ </button>
+ <button
+ type="button"
+ onClick={() =>
+ editor.chain().focus().toggleItalic().run()
+ }
+ className={cn(
+ "flex items-center justify-center rounded-[4px] p-1.5 hover:bg-[#2e353d] cursor-pointer text-[#fafafa]",
+ editor.isActive("italic") && "bg-[#2e353d]",
+ )}
+ >
+ <Italic size={16} />
+ </button>
+ <button
+ type="button"
+ onClick={() =>
+ editor.chain().focus().toggleCode().run()
+ }
+ className={cn(
+ "flex items-center justify-center rounded-[4px] p-1.5 hover:bg-[#2e353d] cursor-pointer text-[#fafafa]",
+ editor.isActive("code") && "bg-[#2e353d]",
+ )}
+ >
+ <Code size={16} />
+ </button>
+ </div>
+ </BubbleMenu>
+ )}
+ </>
+ )
+}
diff --git a/apps/web/components/new/text-editor/slash-command.tsx b/apps/web/components/new/text-editor/slash-command.tsx
new file mode 100644
index 00000000..31991093
--- /dev/null
+++ b/apps/web/components/new/text-editor/slash-command.tsx
@@ -0,0 +1,308 @@
+"use client"
+
+import { Extension, type Editor, type Range } from "@tiptap/core"
+import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion"
+import { useEffect, useLayoutEffect, useState, useRef } from "react"
+import { createPortal } from "react-dom"
+import { createRoot, type Root } from "react-dom/client"
+import {
+ useFloating,
+ offset,
+ flip,
+ shift,
+ autoUpdate,
+} from "@floating-ui/react"
+import { cn } from "@lib/utils"
+
+export interface SuggestionItem {
+ title: string
+ description: string
+ searchTerms?: string[]
+ icon: React.ReactNode
+ command: (props: { editor: Editor; range: Range }) => void
+}
+
+interface CommandListProps {
+ items: SuggestionItem[]
+ command: (item: SuggestionItem) => void
+ selectedIndex: number
+}
+
+function CommandList({ items, command, selectedIndex }: CommandListProps) {
+ const containerRef = useRef<HTMLDivElement>(null)
+
+ useEffect(() => {
+ const selectedElement = containerRef.current?.querySelector(
+ `[data-index="${selectedIndex}"]`,
+ )
+ selectedElement?.scrollIntoView({ block: "nearest" })
+ }, [selectedIndex])
+
+ if (items.length === 0) {
+ return (
+ <div className="z-50 h-auto max-h-[330px] overflow-y-auto rounded-[8px] bg-[#1b1f24] p-2 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.25),inset_1px_1px_1px_0px_rgba(255,255,255,0.1)]">
+ <div className="px-2 text-muted-foreground">No results</div>
+ </div>
+ )
+ }
+
+ return (
+ <div
+ ref={containerRef}
+ className="z-50 h-auto max-h-[330px] overflow-y-auto rounded-[8px] bg-[#1b1f24] p-2 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.25),inset_1px_1px_1px_0px_rgba(255,255,255,0.1)]"
+ >
+ {items.map((item, index) => (
+ <button
+ type="button"
+ key={item.title}
+ data-index={index}
+ onClick={() => command(item)}
+ className={cn(
+ "flex w-full items-center gap-2 rounded-[4px] px-3 py-2 text-left hover:bg-[#2e353d]",
+ index === selectedIndex && "bg-[#2e353d]",
+ )}
+ >
+ <div className="flex size-[20px] shrink-0 items-center justify-center text-[#fafafa]">
+ {item.icon}
+ </div>
+ <p className="font-medium text-[16px] leading-[1.35] tracking-[-0.16px] text-[#fafafa]">
+ {item.title}
+ </p>
+ </button>
+ ))}
+ </div>
+ )
+}
+
+interface CommandMenuProps {
+ items: SuggestionItem[]
+ command: (item: SuggestionItem) => void
+ clientRect: (() => DOMRect | null) | null
+ selectedIndex: number
+}
+
+function CommandMenu({
+ items,
+ command,
+ clientRect,
+ selectedIndex,
+}: CommandMenuProps) {
+ const [mounted, setMounted] = useState(false)
+
+ const { refs, floatingStyles } = useFloating({
+ placement: "bottom-start",
+ middleware: [offset(8), flip(), shift()],
+ whileElementsMounted: autoUpdate,
+ })
+
+ useLayoutEffect(() => {
+ setMounted(true)
+ }, [])
+
+ useEffect(() => {
+ const rect = clientRect?.()
+ if (rect) {
+ refs.setReference({
+ getBoundingClientRect: () => rect,
+ })
+ }
+ }, [clientRect, refs])
+
+ if (!mounted) return null
+
+ return createPortal(
+ <div ref={refs.setFloating} style={floatingStyles} className="z-50">
+ <CommandList
+ items={items}
+ command={command}
+ selectedIndex={selectedIndex}
+ />
+ </div>,
+ document.body,
+ )
+}
+
+export function createSlashCommand(items: SuggestionItem[]) {
+ let component: {
+ updateProps: (props: CommandMenuProps) => void
+ destroy: () => void
+ element: HTMLElement
+ } | null = null
+ let root: Root | null = null
+ let selectedIndex = 0
+ let currentItems: SuggestionItem[] = []
+
+ const renderMenu = (props: {
+ items: SuggestionItem[]
+ command: (item: SuggestionItem) => void
+ clientRect: (() => DOMRect | null) | null
+ }) => {
+ root?.render(
+ <CommandMenu
+ items={props.items}
+ command={props.command}
+ clientRect={props.clientRect}
+ selectedIndex={selectedIndex}
+ />,
+ )
+ }
+
+ const suggestion: Omit<SuggestionOptions<SuggestionItem>, "editor"> = {
+ char: "/",
+ items: ({ query }) => {
+ return items.filter(
+ (item) =>
+ item.title.toLowerCase().includes(query.toLowerCase()) ||
+ item.searchTerms?.some((term) =>
+ term.toLowerCase().includes(query.toLowerCase()),
+ ),
+ )
+ },
+ command: ({ editor, range, props }) => {
+ props.command({ editor, range })
+ },
+ render: () => {
+ let currentCommand: ((item: SuggestionItem) => void) | null = null
+ let currentClientRect: (() => DOMRect | null) | null = null
+
+ return {
+ onStart: (props) => {
+ selectedIndex = 0
+ currentItems = props.items as SuggestionItem[]
+ currentCommand = props.command as (
+ item: SuggestionItem,
+ ) => void
+ currentClientRect = props.clientRect ?? null
+
+ const element = document.createElement("div")
+ document.body.appendChild(element)
+
+ root = createRoot(element)
+ if (currentCommand) {
+ renderMenu({
+ items: currentItems,
+ command: currentCommand,
+ clientRect: currentClientRect,
+ })
+ }
+
+ component = {
+ element,
+ updateProps: (newProps: CommandMenuProps) => {
+ root?.render(
+ <CommandMenu
+ items={newProps.items}
+ command={newProps.command}
+ clientRect={newProps.clientRect}
+ selectedIndex={newProps.selectedIndex}
+ />,
+ )
+ },
+ destroy: () => {
+ root?.unmount()
+ element.remove()
+ root = null
+ },
+ }
+ },
+
+ onUpdate: (props) => {
+ currentItems = props.items as SuggestionItem[]
+ currentCommand = props.command as (
+ item: SuggestionItem,
+ ) => void
+ currentClientRect = props.clientRect ?? null
+
+ if (selectedIndex >= currentItems.length) {
+ selectedIndex = Math.max(0, currentItems.length - 1)
+ }
+
+ if (currentCommand) {
+ component?.updateProps({
+ items: currentItems,
+ command: currentCommand,
+ clientRect: currentClientRect,
+ selectedIndex,
+ })
+ }
+ },
+
+ onKeyDown: (props) => {
+ const { event } = props
+
+ if (event.key === "Escape") {
+ component?.destroy()
+ component = null
+ return true
+ }
+
+ if (event.key === "ArrowUp") {
+ selectedIndex =
+ selectedIndex <= 0
+ ? currentItems.length - 1
+ : selectedIndex - 1
+ if (currentCommand) {
+ component?.updateProps({
+ items: currentItems,
+ command: currentCommand,
+ clientRect: currentClientRect,
+ selectedIndex,
+ })
+ }
+ return true
+ }
+
+ if (event.key === "ArrowDown") {
+ selectedIndex =
+ selectedIndex >= currentItems.length - 1
+ ? 0
+ : selectedIndex + 1
+ if (currentCommand) {
+ component?.updateProps({
+ items: currentItems,
+ command: currentCommand,
+ clientRect: currentClientRect,
+ selectedIndex,
+ })
+ }
+ return true
+ }
+
+ if (event.key === "Enter") {
+ const item = currentItems[selectedIndex]
+ if (item && currentCommand) {
+ currentCommand(item)
+ }
+ return true
+ }
+
+ return false
+ },
+
+ onExit: () => {
+ component?.destroy()
+ component = null
+ },
+ }
+ },
+ }
+
+ return Extension.create({
+ name: "slashCommand",
+
+ addOptions() {
+ return {
+ suggestion,
+ }
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ Suggestion({
+ editor: this.editor,
+ ...this.options.suggestion,
+ }),
+ ]
+ },
+ })
+}
diff --git a/apps/web/components/new/text-editor/suggestions.tsx b/apps/web/components/new/text-editor/suggestions.tsx
new file mode 100644
index 00000000..d31f7732
--- /dev/null
+++ b/apps/web/components/new/text-editor/suggestions.tsx
@@ -0,0 +1,103 @@
+import {
+ Heading1,
+ Heading2,
+ Heading3,
+ List,
+ ListOrdered,
+ TextQuote,
+ Text,
+} from "lucide-react"
+import { createSlashCommand, type SuggestionItem } from "./slash-command"
+
+export const suggestionItems: SuggestionItem[] = [
+ {
+ title: "Heading 1",
+ description: "Big section heading.",
+ searchTerms: ["title", "big", "large"],
+ icon: <Heading1 size={20} />,
+ 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 size={20} />,
+ 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 size={20} />,
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 3 })
+ .run()
+ },
+ },
+ {
+ title: "Text",
+ description: "Just start typing with plain text.",
+ searchTerms: ["p", "paragraph"],
+ icon: <Text size={18} />,
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleNode("paragraph", "paragraph")
+ .run()
+ },
+ },
+ {
+ title: "Bullet List",
+ description: "Create a simple bullet list.",
+ searchTerms: ["unordered", "point"],
+ icon: <List size={20} />,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleBulletList().run()
+ },
+ },
+ {
+ title: "Numbered List",
+ description: "Create a list with numbering.",
+ searchTerms: ["ordered"],
+ icon: <ListOrdered size={20} />,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run()
+ },
+ },
+ {
+ title: "Block Quote",
+ description: "Capture a quote.",
+ searchTerms: ["blockquote"],
+ icon: <TextQuote size={20} />,
+ command: ({ editor, range }) =>
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleNode("paragraph", "paragraph")
+ .toggleBlockquote()
+ .run(),
+ },
+]
+
+export const slashCommand = createSlashCommand(suggestionItems)