diff options
Diffstat (limited to 'apps/web/components/new')
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) |