mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
minor fixes
This commit is contained in:
parent
e1af1d0b59
commit
a843edde5e
100 changed files with 772 additions and 15434 deletions
12
apps/web/app/(app)/layout.tsx
Normal file
12
apps/web/app/(app)/layout.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client"
|
||||
|
||||
import { MobileBanner } from "@/components/new/mobile-banner"
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<MobileBanner />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ export default function OnboardingPage() {
|
|||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/new/onboarding/welcome?step=input")
|
||||
router.replace("/onboarding/welcome?step=input")
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
|
|
@ -47,21 +47,21 @@ export default function SetupLayout({ children }: { children: ReactNode }) {
|
|||
const goToStep = useCallback(
|
||||
(step: SetupStep) => {
|
||||
analytics.onboardingStepViewed({ step, trigger: "user" })
|
||||
router.push(`/new/onboarding/setup?step=${step}`)
|
||||
router.push(`/onboarding/setup?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const goToWelcome = useCallback(
|
||||
(step = "input") => {
|
||||
router.push(`/new/onboarding/welcome?step=${step}`)
|
||||
router.push(`/onboarding/welcome?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const finishOnboarding = useCallback(() => {
|
||||
resetOnboarding()
|
||||
router.push("/new")
|
||||
router.push("/")
|
||||
}, [router, resetOnboarding])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -92,7 +92,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
|
|||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
analytics.onboardingStepViewed({ step: "welcome", trigger: "auto" })
|
||||
router.replace("/new/onboarding/welcome?step=welcome")
|
||||
router.replace("/onboarding/welcome?step=welcome")
|
||||
}
|
||||
}, 2000),
|
||||
)
|
||||
|
|
@ -104,7 +104,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
|
|||
step: "username",
|
||||
trigger: "auto",
|
||||
})
|
||||
router.replace("/new/onboarding/welcome?step=username")
|
||||
router.replace("/onboarding/welcome?step=username")
|
||||
}
|
||||
}, 2000),
|
||||
)
|
||||
|
|
@ -128,14 +128,14 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
|
|||
const goToStep = useCallback(
|
||||
(step: WelcomeStep) => {
|
||||
analytics.onboardingStepViewed({ step, trigger: "user" })
|
||||
router.push(`/new/onboarding/welcome?step=${step}`)
|
||||
router.push(`/onboarding/welcome?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const goToSetup = useCallback(
|
||||
(step = "relatable") => {
|
||||
router.push(`/new/onboarding/setup?step=${step}`)
|
||||
router.push(`/onboarding/setup?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { Header } from "@/components/new/header"
|
||||
import { ChatSidebar } from "@/components/new/chat"
|
||||
import { MemoriesGrid } from "@/components/new/memories-grid"
|
||||
|
|
@ -23,11 +24,20 @@ import {
|
|||
} from "@/stores/quick-note-draft"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useDocumentMutations } from "@/hooks/use-document-mutations"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import {
|
||||
addDocumentParam,
|
||||
mcpParam,
|
||||
searchParam,
|
||||
qParam,
|
||||
docParam,
|
||||
fullscreenParam,
|
||||
chatParam,
|
||||
} from "@/lib/search-params"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -36,17 +46,56 @@ export default function NewPage() {
|
|||
const isMobile = useIsMobile()
|
||||
const { selectedProject } = useProject()
|
||||
const { viewMode } = useViewMode()
|
||||
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
|
||||
const [isMCPModalOpen, setIsMCPModalOpen] = useState(false)
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
const [selectedDocument, setSelectedDocument] =
|
||||
useState<DocumentWithMemories | null>(null)
|
||||
const [isDocumentModalOpen, setIsDocumentModalOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [isFullScreenNoteOpen, setIsFullScreenNoteOpen] = useState(false)
|
||||
// URL-driven modal states
|
||||
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
|
||||
const [isMCPOpen, setIsMCPOpen] = useQueryState("mcp", mcpParam)
|
||||
const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam)
|
||||
const [searchPrefill, setSearchPrefill] = useQueryState("q", qParam)
|
||||
const [docId, setDocId] = useQueryState("doc", docParam)
|
||||
const [isFullscreen, setIsFullscreen] = useQueryState("fullscreen", fullscreenParam)
|
||||
const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam)
|
||||
|
||||
// Ephemeral local state (not worth URL-encoding)
|
||||
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
|
||||
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
|
||||
const [searchPrefill, setSearchPrefill] = useState("")
|
||||
const [selectedDocument, setSelectedDocument] =
|
||||
useState<DocumentWithMemories | null>(null)
|
||||
|
||||
// Clear document when docId is removed (e.g. back button)
|
||||
useEffect(() => {
|
||||
if (!docId) setSelectedDocument(null)
|
||||
}, [docId])
|
||||
|
||||
// Resolve document from cache when loading with ?doc=<id> (deep link / refresh)
|
||||
useEffect(() => {
|
||||
if (!docId || selectedDocument) return
|
||||
|
||||
const tryResolve = () => {
|
||||
const queries = queryClient.getQueriesData<{
|
||||
pages: DocumentsResponse[]
|
||||
}>({ queryKey: ["documents-with-memories"] })
|
||||
for (const [, data] of queries) {
|
||||
if (!data?.pages) continue
|
||||
for (const page of data.pages) {
|
||||
const doc = page.documents?.find((d) => d.id === docId)
|
||||
if (doc) {
|
||||
setSelectedDocument(doc)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (tryResolve()) return
|
||||
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe(() => {
|
||||
if (tryResolve()) unsubscribe()
|
||||
})
|
||||
return unsubscribe
|
||||
}, [docId, selectedDocument, queryClient])
|
||||
|
||||
const resetDraft = useQuickNoteDraftReset(selectedProject)
|
||||
const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
|
||||
|
|
@ -54,7 +103,7 @@ export default function NewPage() {
|
|||
const { noteMutation } = useDocumentMutations({
|
||||
onClose: () => {
|
||||
resetDraft()
|
||||
setIsFullScreenNoteOpen(false)
|
||||
setIsFullscreen(false)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -74,7 +123,6 @@ export default function NewPage() {
|
|||
const spaceId = selectedProject || "sm_project_default"
|
||||
const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}`
|
||||
|
||||
// Check Cache API for a fresh response
|
||||
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
const cached = await cache.match(cacheKey)
|
||||
if (cached) {
|
||||
|
|
@ -107,7 +155,6 @@ export default function NewPage() {
|
|||
|
||||
const data = await response.json()
|
||||
|
||||
// Store in Cache API with timestamp
|
||||
const cacheResponse = new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -124,26 +171,24 @@ export default function NewPage() {
|
|||
|
||||
useHotkeys("c", () => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setIsAddDocumentOpen(true)
|
||||
setAddDoc("note")
|
||||
})
|
||||
useHotkeys("mod+k", (e) => {
|
||||
e.preventDefault()
|
||||
analytics.searchOpened({ source: "hotkey" })
|
||||
setIsSearchOpen(true)
|
||||
})
|
||||
const [isChatOpen, setIsChatOpen] = useState(!isMobile)
|
||||
|
||||
useEffect(() => {
|
||||
setIsChatOpen(!isMobile)
|
||||
}, [isMobile])
|
||||
|
||||
const handleOpenDocument = useCallback((document: DocumentWithMemories) => {
|
||||
if (document.id) {
|
||||
analytics.documentModalOpened({ document_id: document.id })
|
||||
}
|
||||
setSelectedDocument(document)
|
||||
setIsDocumentModalOpen(true)
|
||||
}, [])
|
||||
const handleOpenDocument = useCallback(
|
||||
(document: DocumentWithMemories) => {
|
||||
if (document.id) {
|
||||
analytics.documentModalOpened({ document_id: document.id })
|
||||
setSelectedDocument(document)
|
||||
setDocId(document.id)
|
||||
}
|
||||
},
|
||||
[setDocId],
|
||||
)
|
||||
|
||||
const handleQuickNoteSave = useCallback(
|
||||
(content: string) => {
|
||||
|
|
@ -187,23 +232,33 @@ export default function NewPage() {
|
|||
[selectedProject, noteMutation, fullscreenInitialContent],
|
||||
)
|
||||
|
||||
const handleMaximize = useCallback((content: string) => {
|
||||
analytics.fullscreenNoteModalOpened()
|
||||
setFullscreenInitialContent(content)
|
||||
setIsFullScreenNoteOpen(true)
|
||||
}, [])
|
||||
const handleMaximize = useCallback(
|
||||
(content: string) => {
|
||||
analytics.fullscreenNoteModalOpened()
|
||||
setFullscreenInitialContent(content)
|
||||
setIsFullscreen(true)
|
||||
},
|
||||
[setIsFullscreen],
|
||||
)
|
||||
|
||||
const handleHighlightsChat = useCallback((seed: string) => {
|
||||
setQueuedChatSeed(seed)
|
||||
setIsChatOpen(true)
|
||||
}, [])
|
||||
const handleHighlightsChat = useCallback(
|
||||
(seed: string) => {
|
||||
setQueuedChatSeed(seed)
|
||||
setIsChatOpen(true)
|
||||
},
|
||||
[setIsChatOpen],
|
||||
)
|
||||
|
||||
const handleHighlightsShowRelated = useCallback((query: string) => {
|
||||
analytics.searchOpened({ source: "highlight_related" })
|
||||
setSearchPrefill(query)
|
||||
setIsSearchOpen(true)
|
||||
}, [])
|
||||
const handleHighlightsShowRelated = useCallback(
|
||||
(query: string) => {
|
||||
analytics.searchOpened({ source: "highlight_related" })
|
||||
setSearchPrefill(query)
|
||||
setIsSearchOpen(true)
|
||||
},
|
||||
[setSearchPrefill, setIsSearchOpen],
|
||||
)
|
||||
|
||||
const chatOpen = isChatOpen !== null ? isChatOpen : !isMobile
|
||||
const isGraphMode = viewMode === "graph" && !isMobile
|
||||
|
||||
return (
|
||||
|
|
@ -227,11 +282,11 @@ export default function NewPage() {
|
|||
<Header
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setIsAddDocumentOpen(true)
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenMCP={() => {
|
||||
analytics.mcpModalOpened()
|
||||
setIsMCPModalOpen(true)
|
||||
setIsMCPOpen(true)
|
||||
}}
|
||||
onOpenChat={() => setIsChatOpen(true)}
|
||||
onOpenSearch={() => {
|
||||
|
|
@ -240,7 +295,7 @@ export default function NewPage() {
|
|||
}}
|
||||
/>
|
||||
<main
|
||||
key={`main-container-${isChatOpen}-${viewMode}`}
|
||||
key={`main-container-${chatOpen}-${viewMode}`}
|
||||
className={cn(
|
||||
"z-10 relative",
|
||||
isGraphMode && "h-[calc(100vh-86px)] overflow-hidden",
|
||||
|
|
@ -249,12 +304,12 @@ export default function NewPage() {
|
|||
<div className={cn("relative z-10 flex flex-col md:flex-row h-full")}>
|
||||
{viewMode === "graph" && !isMobile ? (
|
||||
<div className="flex-1">
|
||||
<GraphLayoutView isChatOpen={isChatOpen} />
|
||||
<GraphLayoutView isChatOpen={chatOpen} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
|
||||
<MemoriesGrid
|
||||
isChatOpen={isChatOpen}
|
||||
isChatOpen={chatOpen}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
quickNoteProps={{
|
||||
onSave: handleQuickNoteSave,
|
||||
|
|
@ -273,8 +328,8 @@ export default function NewPage() {
|
|||
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ChatSidebar
|
||||
isChatOpen={isChatOpen}
|
||||
setIsChatOpen={setIsChatOpen}
|
||||
isChatOpen={chatOpen}
|
||||
setIsChatOpen={(open) => setIsChatOpen(open)}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
|
|
@ -286,8 +341,8 @@ export default function NewPage() {
|
|||
|
||||
{isMobile && (
|
||||
<ChatSidebar
|
||||
isChatOpen={isChatOpen}
|
||||
setIsChatOpen={setIsChatOpen}
|
||||
isChatOpen={chatOpen}
|
||||
setIsChatOpen={(open) => setIsChatOpen(open)}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
|
|
@ -295,12 +350,13 @@ export default function NewPage() {
|
|||
)}
|
||||
|
||||
<AddDocumentModal
|
||||
isOpen={isAddDocumentOpen}
|
||||
onClose={() => setIsAddDocumentOpen(false)}
|
||||
isOpen={addDoc !== null}
|
||||
onClose={() => setAddDoc(null)}
|
||||
defaultTab={addDoc ?? undefined}
|
||||
/>
|
||||
<MCPModal
|
||||
isOpen={isMCPModalOpen}
|
||||
onClose={() => setIsMCPModalOpen(false)}
|
||||
isOpen={isMCPOpen}
|
||||
onClose={() => setIsMCPOpen(false)}
|
||||
/>
|
||||
<DocumentsCommandPalette
|
||||
open={isSearchOpen}
|
||||
|
|
@ -310,16 +366,24 @@ export default function NewPage() {
|
|||
}}
|
||||
projectId={selectedProject}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenMCP={() => {
|
||||
analytics.mcpModalOpened()
|
||||
setIsMCPOpen(true)
|
||||
}}
|
||||
initialSearch={searchPrefill}
|
||||
/>
|
||||
<DocumentModal
|
||||
document={selectedDocument}
|
||||
isOpen={isDocumentModalOpen}
|
||||
onClose={() => setIsDocumentModalOpen(false)}
|
||||
isOpen={docId !== null}
|
||||
onClose={() => setDocId(null)}
|
||||
/>
|
||||
<FullscreenNoteModal
|
||||
isOpen={isFullScreenNoteOpen}
|
||||
onClose={() => setIsFullScreenNoteOpen(false)}
|
||||
isOpen={isFullscreen}
|
||||
onClose={() => setIsFullscreen(false)}
|
||||
initialContent={fullscreenInitialContent}
|
||||
onSave={handleFullScreenSave}
|
||||
isSaving={noteMutation.isPending}
|
||||
|
|
@ -182,7 +182,7 @@ export default function SettingsPage() {
|
|||
<header className="flex justify-between items-center px-4 md:px-6 py-3 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/new")}
|
||||
onClick={() => router.push("/")}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Logo className="h-7" />
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { LoginPage } from "@repo/ui/pages/login"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function Page() {
|
||||
return <LoginPage />
|
||||
redirect("/login/new")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { usePersistentChat } from "@/stores"
|
||||
import { ChatMessages } from "@/components/views/chat/chat-messages"
|
||||
|
||||
export default function ChatPage() {
|
||||
const params = useParams()
|
||||
const { setCurrentChatId } = usePersistentChat()
|
||||
|
||||
const chatId = params.id as string
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId) {
|
||||
setCurrentChatId(chatId)
|
||||
}
|
||||
}, [chatId, setCurrentChatId])
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
<ChatMessages />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { GraphDialog } from "@/components/graph-dialog"
|
||||
import { Header } from "@/components/header"
|
||||
import { AddMemoryView } from "@/components/views/add-memory"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export default function NavigationLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const flagEnabled = true
|
||||
|
||||
useEffect(() => {
|
||||
if (flagEnabled && !pathname.includes("/new")) {
|
||||
router.replace("/new")
|
||||
}
|
||||
}, [flagEnabled, router, pathname])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
const isInputField =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable ||
|
||||
target.closest('[contenteditable="true"]')
|
||||
|
||||
if (isInputField) return
|
||||
|
||||
// add memory shortcut
|
||||
if (
|
||||
event.key === "c" &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
event.preventDefault()
|
||||
setShowAddMemoryView(true)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeydown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeydown)
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<div className="relative h-screen flex flex-col">
|
||||
<div className="sticky top-0 z-50 bg-background/80 backdrop-blur-md border-b border-white/10">
|
||||
<Header onAddMemory={() => setShowAddMemoryView(true)} />
|
||||
</div>
|
||||
<div className="flex-1">{children}</div>
|
||||
{showAddMemoryView && (
|
||||
<AddMemoryView
|
||||
initialTab="note"
|
||||
onClose={() => setShowAddMemoryView(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<GraphDialog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { ChevronsDown, LoaderIcon } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { InstallPrompt } from "@/components/install-prompt"
|
||||
import { ChromeExtensionButton } from "@/components/chrome-extension-button"
|
||||
import { ChatInput } from "@/components/chat-input"
|
||||
import { BackgroundPlus } from "@ui/components/grid-plus"
|
||||
import { Memories } from "@/components/memories"
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react"
|
||||
|
||||
export default function Page() {
|
||||
const { user, session } = useAuth()
|
||||
const router = useRouter()
|
||||
const flagEnabled = true
|
||||
|
||||
// TODO: remove this flow after the feature flag is removed
|
||||
// Old app: localStorage-backed onboarding
|
||||
const {
|
||||
shouldShowOnboarding: shouldShowOldOnboarding,
|
||||
isLoading: oldOnboardingLoading,
|
||||
} = useOnboardingStorage()
|
||||
|
||||
// New app: DB-backed onboarding (org.metadata.isOnboarded)
|
||||
const {
|
||||
shouldShowOnboarding: shouldShowNewOnboarding,
|
||||
isLoading: newOnboardingLoading,
|
||||
} = useOrgOnboarding()
|
||||
|
||||
// Select the appropriate onboarding state based on feature flag
|
||||
const isOnboardingLoading = useMemo(() => {
|
||||
if (flagEnabled) {
|
||||
return newOnboardingLoading
|
||||
}
|
||||
return oldOnboardingLoading
|
||||
}, [flagEnabled, newOnboardingLoading, oldOnboardingLoading])
|
||||
|
||||
const shouldShowOnboarding = useMemo(() => {
|
||||
if (flagEnabled) {
|
||||
return shouldShowNewOnboarding()
|
||||
}
|
||||
return shouldShowOldOnboarding()
|
||||
}, [flagEnabled, shouldShowNewOnboarding, shouldShowOldOnboarding])
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
const authenticateChromeExtension = url.searchParams.get(
|
||||
"extension-auth-success",
|
||||
)
|
||||
|
||||
if (authenticateChromeExtension) {
|
||||
const sessionToken = session?.token
|
||||
const userData = {
|
||||
email: user?.email,
|
||||
name: user?.name,
|
||||
userId: user?.id,
|
||||
}
|
||||
|
||||
if (sessionToken && userData?.email) {
|
||||
const encodedToken = encodeURIComponent(sessionToken)
|
||||
window.postMessage(
|
||||
{ token: encodedToken, userData },
|
||||
window.location.origin,
|
||||
)
|
||||
url.searchParams.delete("extension-auth-success")
|
||||
window.history.replaceState({}, "", url.toString())
|
||||
}
|
||||
}
|
||||
}, [user, session])
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !isOnboardingLoading && shouldShowOnboarding) {
|
||||
if (flagEnabled) {
|
||||
router.push("/new/onboarding?step=input&flow=welcome")
|
||||
} else {
|
||||
router.push("/onboarding")
|
||||
}
|
||||
}
|
||||
}, [user, shouldShowOnboarding, isOnboardingLoading, router, flagEnabled])
|
||||
|
||||
if (!user || isOnboardingLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#0f1419]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoaderIcon className="w-8 h-8 text-orange-500 animate-spin" />
|
||||
<p className="text-white/60">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (shouldShowOnboarding) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col h-[80vh] rounded-lg overflow-hidden relative">
|
||||
<BackgroundPlus />
|
||||
<div className="p-4 flex-1 flex items-center justify-center">
|
||||
<ChatInput />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground justify-center py-2 opacity-75">
|
||||
<ChevronsDown className="size-4" />
|
||||
<p>Scroll down to see memories</p>
|
||||
</div>
|
||||
</div>
|
||||
<Memories />
|
||||
|
||||
<InstallPrompt />
|
||||
<ChromeExtensionButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
"use client"
|
||||
import { BillingView } from "@/components/views/billing"
|
||||
export default function BillingPage() {
|
||||
return (
|
||||
<div className="py-6 max-w-2xl">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">
|
||||
Billing & Subscription
|
||||
</h1>
|
||||
<BillingView />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
"use client"
|
||||
import { IntegrationsView } from "@/components/views/integrations"
|
||||
export default function IntegrationsPage() {
|
||||
return (
|
||||
<div className="py-6 max-w-4xl">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">Integrations</h1>
|
||||
<IntegrationsView />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { cn } from "@repo/lib/utils"
|
||||
|
||||
export default function SettingsPageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const navItems = [
|
||||
{ label: "Profile", path: "/settings" },
|
||||
{ label: "Integrations", path: "/settings/integrations" },
|
||||
{ label: "Billing", path: "/settings/billing" },
|
||||
{ label: "Support", path: "/settings/support" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden max-w-screen-lg mx-auto mt-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-2xl">
|
||||
<nav className="flex gap-[2px] px-1 py-1 text-sm rounded-[8px] bg-muted-foreground/10 text-foreground max-w-fit">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.path
|
||||
return (
|
||||
<Button
|
||||
key={item.path}
|
||||
onClick={() => router.push(item.path)}
|
||||
variant="settingsNav"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"transition-all duration-200",
|
||||
isActive
|
||||
? "opacity-100 bg-card"
|
||||
: "opacity-60 hover:opacity-100 hover:bg-card ",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
"use client"
|
||||
import { ProfileView } from "@/components/views/profile"
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<div className="py-6 max-w-xl">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">
|
||||
Profile Settings
|
||||
</h1>
|
||||
<ProfileView />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"
|
||||
import { ExternalLink, Mail, MessageCircle } from "lucide-react"
|
||||
|
||||
export default function SupportPage() {
|
||||
return (
|
||||
<div className="py-6 max-w-2xl">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">
|
||||
Support & Help
|
||||
</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Contact Options */}
|
||||
<div className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<HeadingH3Bold className="text-foreground">Get Help</HeadingH3Bold>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Need assistance? We're here to help! Choose the best way to reach
|
||||
us.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
className="w-full justify-start bg-blue-500/20 hover:bg-blue-500/30 text-blue-600 dark:text-blue-400 border-blue-500/30"
|
||||
onClick={() => window.open("https://x.com/supermemory", "_blank")}
|
||||
variant="outline"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
Message us on X (Twitter)
|
||||
<ExternalLink className="w-4 h-4 ml-auto" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full justify-start bg-green-500/20 hover:bg-green-500/30 text-green-600 dark:text-green-400 border-green-500/30"
|
||||
onClick={() =>
|
||||
window.open("mailto:dhravya@supermemory.ai", "_blank")
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Email us at dhravya@supermemory.ai
|
||||
<ExternalLink className="w-4 h-4 ml-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<HeadingH3Bold className="text-foreground">
|
||||
Frequently Asked Questions
|
||||
</HeadingH3Bold>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-foreground font-medium text-sm">
|
||||
How do I upgrade to Pro?
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Go to the Billing tab in settings and click "Upgrade to Pro".
|
||||
You'll be redirected to our secure payment processor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-foreground font-medium text-sm">
|
||||
What's included in the Pro plan?
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Pro includes unlimited memories (vs 200 in free), 10 connections
|
||||
to external services like Google Drive and Notion, advanced
|
||||
search features, and priority support.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-foreground font-medium text-sm">
|
||||
How do connections work?
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Connections let you sync documents from Google Drive, Notion,
|
||||
and OneDrive automatically. supermemory will index and make them
|
||||
searchable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-foreground font-medium text-sm">
|
||||
Can I cancel my subscription anytime?
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Yes! You can cancel anytime from the Billing tab. Your Pro
|
||||
features will remain active until the end of your billing
|
||||
period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Section */}
|
||||
<div className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||
<HeadingH3Bold className="text-foreground">
|
||||
Feedback & Feature Requests
|
||||
</HeadingH3Bold>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Have ideas for new features or improvements? We'd love to hear from
|
||||
you!
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="w-full justify-start bg-purple-500/20 hover:bg-purple-500/30 text-purple-600 dark:text-purple-400 border-purple-500/30"
|
||||
onClick={() => window.open("https://x.com/supermemory", "_blank")}
|
||||
variant="outline"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
Share your feedback on X
|
||||
<ExternalLink className="w-4 h-4 ml-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,12 +9,9 @@ import { QueryProvider } from "../components/query-client"
|
|||
import { AutumnProvider } from "autumn-js/react"
|
||||
import { Suspense } from "react"
|
||||
import { Toaster } from "@ui/components/sonner"
|
||||
import { MobilePanelProvider } from "@/lib/mobile-panel-context"
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||
import { ThemeProvider } from "@/lib/theme-provider"
|
||||
|
||||
import { ViewModeProvider } from "@/lib/view-mode-context"
|
||||
|
||||
const font = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
|
|
@ -60,18 +57,14 @@ export default function RootLayout({
|
|||
>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<ViewModeProvider>
|
||||
<MobilePanelProvider>
|
||||
<PostHogProvider>
|
||||
<ErrorTrackingProvider>
|
||||
<NuqsAdapter>
|
||||
<Suspense>{children}</Suspense>
|
||||
<Toaster />
|
||||
</NuqsAdapter>
|
||||
</ErrorTrackingProvider>
|
||||
</PostHogProvider>
|
||||
</MobilePanelProvider>
|
||||
</ViewModeProvider>
|
||||
<PostHogProvider>
|
||||
<ErrorTrackingProvider>
|
||||
<NuqsAdapter>
|
||||
<Suspense>{children}</Suspense>
|
||||
<Toaster />
|
||||
</NuqsAdapter>
|
||||
</ErrorTrackingProvider>
|
||||
</PostHogProvider>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
</AutumnProvider>
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { MobileBanner } from "@/components/new/mobile-banner"
|
||||
|
||||
export default function NewLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const flagEnabled = true
|
||||
|
||||
useEffect(() => {
|
||||
if (!flagEnabled) {
|
||||
router.push("/")
|
||||
}
|
||||
}, [flagEnabled, router])
|
||||
|
||||
if (!flagEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileBanner />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
"use client"
|
||||
import { useEffect } from "react"
|
||||
import { TextEffect } from "@/components/text-effect"
|
||||
|
||||
export function AnimatedText({
|
||||
children,
|
||||
trigger,
|
||||
delay,
|
||||
}: {
|
||||
children: string
|
||||
trigger: boolean
|
||||
delay: number
|
||||
}) {
|
||||
const blurSlideVariants = {
|
||||
container: {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.01 },
|
||||
},
|
||||
exit: {
|
||||
transition: { staggerChildren: 0.01, staggerDirection: 1 },
|
||||
},
|
||||
},
|
||||
item: {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
filter: "blur(10px) brightness(0%)",
|
||||
y: 0,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px) brightness(100%)",
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -30,
|
||||
filter: "blur(10px) brightness(0%)",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<TextEffect
|
||||
className="inline-flex font-medium"
|
||||
style={{ letterSpacing: "-3px" }}
|
||||
per="char"
|
||||
variants={blurSlideVariants}
|
||||
trigger={trigger}
|
||||
delay={delay}
|
||||
>
|
||||
{children}
|
||||
</TextEffect>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { Textarea } from "@ui/components/textarea"
|
||||
import { useOnboarding } from "./onboarding-context"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { NavMenu } from "./nav-menu"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
export function BioForm() {
|
||||
const [bio, setBio] = useState("")
|
||||
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding()
|
||||
|
||||
function handleNext() {
|
||||
const trimmed = bio.trim()
|
||||
if (!trimmed) {
|
||||
nextStep()
|
||||
return
|
||||
}
|
||||
|
||||
nextStep()
|
||||
void $fetch("@post/documents", {
|
||||
body: {
|
||||
content: trimmed,
|
||||
containerTags: ["sm_project_default"],
|
||||
metadata: { sm_source: "consumer" },
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error("Failed to save onboarding bio memory:", error)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="space-y-4 relative">
|
||||
<div className="absolute top-0 right-0">
|
||||
<AnimatePresence mode="sync">
|
||||
{bio ? (
|
||||
<motion.div
|
||||
key="save"
|
||||
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
|
||||
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
size="lg"
|
||||
className="text-white/60 font-medium! text-base md:text-lg w-fit px-0! cursor-pointer"
|
||||
onClick={handleNext}
|
||||
>
|
||||
Save & Continue
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="skip"
|
||||
initial={{ opacity: 0, filter: "blur(5px)" }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, filter: "blur(5px)" }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
size="lg"
|
||||
className="text-white/60 font-medium! text-base md:text-lg w-fit px-0! cursor-pointer"
|
||||
onClick={handleNext}
|
||||
>
|
||||
Skip For Now
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<NavMenu>
|
||||
<p className="text-base text-white/60">
|
||||
Step {getStepNumberFor("bio")} of {totalSteps}
|
||||
</p>
|
||||
</NavMenu>
|
||||
<h1 className="text-2xl md:text-4xl text-white font-medium">
|
||||
Tell Supermemory about yourself
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-white/80">
|
||||
share with Supermemory what you do, who you are, and what you're
|
||||
interested in
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
autoFocus
|
||||
className="font-sans mt-6 text-base! placeholder:text-white/80 text-white tracking-normal font-medium border bg-white/30 border-zinc-200 rounded-lg !field-sizing-normal !min-h-[calc(3*1.5rem+1rem)] w-full"
|
||||
placeholder="I'm a software engineer from San Francisco..."
|
||||
rows={3}
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,904 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
MicIcon,
|
||||
PlusIcon,
|
||||
MousePointer2,
|
||||
LoaderIcon,
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { NavMenu } from "./nav-menu"
|
||||
import { useOnboarding } from "./onboarding-context"
|
||||
import { motion, AnimatePresence, type ResolvedValues } from "motion/react"
|
||||
import { useEffect, useMemo, useRef, useState, useLayoutEffect } from "react"
|
||||
import React from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
|
||||
type CursorAction =
|
||||
| { type: "startAt"; target: React.RefObject<HTMLElement | null> }
|
||||
| { type: "startAtPercent"; xPercent: number; yPercent: number }
|
||||
| {
|
||||
type: "move"
|
||||
target: React.RefObject<HTMLElement | null>
|
||||
duration: number
|
||||
}
|
||||
| { type: "move"; xPercent: number; yPercent: number; duration: number }
|
||||
| { type: "pause"; duration: number }
|
||||
| { type: "click" }
|
||||
| { type: "call"; fn: () => void }
|
||||
|
||||
interface CursorProps {
|
||||
actions: CursorAction[]
|
||||
className?: string
|
||||
onPositionChange?: (clientX: number, clientY: number) => void
|
||||
}
|
||||
|
||||
function useContainerRect(ref: React.RefObject<HTMLDivElement | null>) {
|
||||
const rectRef = React.useRef<DOMRect | null>(null)
|
||||
|
||||
useLayoutEffect(
|
||||
function setup() {
|
||||
if (!ref.current) return
|
||||
|
||||
function measure() {
|
||||
if (ref.current) {
|
||||
rectRef.current = ref.current.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
|
||||
measure()
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(function onResize() {
|
||||
measure()
|
||||
})
|
||||
if (ref.current) {
|
||||
resizeObserver.observe(ref.current)
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
measure()
|
||||
}
|
||||
|
||||
window.addEventListener("resize", measure)
|
||||
window.addEventListener("scroll", onScroll, true)
|
||||
|
||||
return function cleanup() {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
window.removeEventListener("resize", measure)
|
||||
window.removeEventListener("scroll", onScroll, true)
|
||||
}
|
||||
},
|
||||
[ref],
|
||||
)
|
||||
|
||||
return rectRef
|
||||
}
|
||||
|
||||
function Cursor({ actions, className, onPositionChange }: CursorProps) {
|
||||
const [position, setPosition] = useState({ x: "0px", y: "0px" })
|
||||
const [scale, setScale] = useState(1)
|
||||
const [currentMoveDuration, setCurrentMoveDuration] = useState(0.7) // Default move duration
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const timeoutsRef = useRef<number[]>([])
|
||||
const containerRectRef = useContainerRect(containerRef)
|
||||
const lastUpdateRef = useRef(0)
|
||||
|
||||
function moveToElement(
|
||||
elementRef: React.RefObject<HTMLElement | null>,
|
||||
duration: number,
|
||||
) {
|
||||
if (!containerRef.current || !elementRef.current) return
|
||||
|
||||
setCurrentMoveDuration(duration / 1000) // Convert to seconds for Framer Motion
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const elementRect = elementRef.current.getBoundingClientRect()
|
||||
// Position the TOP-LEFT of the cursor at the center of the target element
|
||||
const x = elementRect.left - containerRect.left + elementRect.width / 2
|
||||
const y = elementRect.top - containerRect.top + elementRect.height / 2
|
||||
|
||||
setPosition({ x: `${x}px`, y: `${y}px` })
|
||||
}
|
||||
|
||||
function setPositionByPercent(xPercent: number, yPercent: number) {
|
||||
if (!containerRef.current) return
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
// Percentages indicate where the TOP-LEFT of the cursor should be placed
|
||||
const x = (containerRect.width * xPercent) / 100
|
||||
const y = (containerRect.height * yPercent) / 100
|
||||
setCurrentMoveDuration(0) // snap without animating
|
||||
setPosition({ x: `${x}px`, y: `${y}px` })
|
||||
}
|
||||
|
||||
function moveToPercent(xPercent: number, yPercent: number, duration: number) {
|
||||
if (!containerRef.current) return
|
||||
setCurrentMoveDuration(duration / 1000)
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const x = (containerRect.width * xPercent) / 100
|
||||
const y = (containerRect.height * yPercent) / 100
|
||||
setPosition({ x: `${x}px`, y: `${y}px` })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Clear any existing timeouts before scheduling new ones
|
||||
timeoutsRef.current.forEach((id) => {
|
||||
clearTimeout(id)
|
||||
})
|
||||
timeoutsRef.current = []
|
||||
|
||||
let timeAccumulator = 0
|
||||
|
||||
function schedule(callback: () => void, delay: number): number {
|
||||
const id = window.setTimeout(callback, delay)
|
||||
timeoutsRef.current.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
function executeActions(): void {
|
||||
actions.forEach((action) => {
|
||||
// startAt should apply immediately at its place in the sequence and not advance time
|
||||
if (action.type === "startAt") {
|
||||
moveToElement(action.target, 0)
|
||||
return
|
||||
}
|
||||
if (action.type === "startAtPercent") {
|
||||
setPositionByPercent(action.xPercent, action.yPercent)
|
||||
return
|
||||
}
|
||||
|
||||
schedule(() => {
|
||||
switch (action.type) {
|
||||
case "move":
|
||||
if ("target" in action) {
|
||||
moveToElement(action.target, action.duration)
|
||||
} else {
|
||||
moveToPercent(action.xPercent, action.yPercent, action.duration)
|
||||
}
|
||||
break
|
||||
case "click":
|
||||
setScale(0.9)
|
||||
schedule(function resetClickScale() {
|
||||
setScale(1)
|
||||
}, 100) // Fixed 100ms click duration
|
||||
break
|
||||
case "call":
|
||||
try {
|
||||
action.fn()
|
||||
} catch (_) {
|
||||
// no-op on errors to avoid breaking demo
|
||||
}
|
||||
break
|
||||
case "pause":
|
||||
// Pause doesn't require any action, just time passing
|
||||
break
|
||||
}
|
||||
}, timeAccumulator)
|
||||
|
||||
// Add this action's duration to the accumulator for the next action
|
||||
if (action.type === "click") {
|
||||
timeAccumulator += 100
|
||||
} else if (action.type === "pause") {
|
||||
timeAccumulator += action.duration
|
||||
} else if (action.type === "move") {
|
||||
timeAccumulator += action.duration
|
||||
} else {
|
||||
// 'call' and startAt/startAtPercent don't consume time
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// make sure refs are ready
|
||||
schedule(executeActions, 100)
|
||||
|
||||
return function cleanup(): void {
|
||||
timeoutsRef.current.forEach((id) => {
|
||||
clearTimeout(id)
|
||||
})
|
||||
timeoutsRef.current = []
|
||||
}
|
||||
}, [actions])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`absolute inset-0 pointer-events-none ${className || ""}`}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
scale: scale,
|
||||
}}
|
||||
transition={{
|
||||
x: { duration: currentMoveDuration, ease: "easeInOut" },
|
||||
y: { duration: currentMoveDuration, ease: "easeInOut" },
|
||||
scale: { duration: 0.1, ease: "easeInOut" },
|
||||
}}
|
||||
className="absolute top-0 left-0"
|
||||
style={{ zIndex: 10 }}
|
||||
onUpdate={(latest: ResolvedValues) => {
|
||||
if (!onPositionChange) return
|
||||
const containerRect = containerRectRef.current
|
||||
if (!containerRect) return
|
||||
const now = performance.now()
|
||||
if (now - lastUpdateRef.current < 50) return // ~20fps throttle
|
||||
lastUpdateRef.current = now
|
||||
const latestX =
|
||||
typeof latest.x === "number"
|
||||
? latest.x
|
||||
: Number.parseFloat(String(latest.x || 0))
|
||||
const latestY =
|
||||
typeof latest.y === "number"
|
||||
? latest.y
|
||||
: Number.parseFloat(String(latest.y || 0))
|
||||
const clientX = containerRect.left + latestX
|
||||
const clientY = containerRect.top + latestY
|
||||
onPositionChange(clientX, clientY)
|
||||
}}
|
||||
>
|
||||
<MousePointer2
|
||||
className="size-6 drop-shadow"
|
||||
strokeWidth={1.5}
|
||||
fill="white"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SnippetDemo() {
|
||||
const snippetRootRef = useRef<HTMLDivElement>(null)
|
||||
const sentenceRef = useRef<HTMLSpanElement>(null)
|
||||
const [currentEndIndex, setCurrentEndIndex] = useState<number>(0)
|
||||
const lastStableIndexRef = useRef<number>(0)
|
||||
const [cursorActions, setCursorActions] = useState<CursorAction[]>([])
|
||||
const [menuOpen, setMenuOpen] = useState<boolean>(false)
|
||||
const [hoveredMenuIndex, setHoveredMenuIndex] = useState<number | null>(null)
|
||||
const menuItemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const menuItem6Ref = useRef<HTMLElement | null>(null)
|
||||
const charRectsRef = useRef<DOMRect[]>([])
|
||||
|
||||
const targetText =
|
||||
'There\'s an Italian dish called saltimbocca, which means "leap into the mouth."'
|
||||
|
||||
function getIndexFromClientPoint(clientX: number, clientY: number): number {
|
||||
const rects = charRectsRef.current
|
||||
if (!sentenceRef.current || rects.length === 0) return 0
|
||||
let bestIdx = 0
|
||||
let bestDist = Number.POSITIVE_INFINITY
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
const r = rects[i]
|
||||
if (!r) continue
|
||||
const cx = r.left + r.width / 2
|
||||
const cy = r.top + r.height / 2
|
||||
const dx = clientX - cx
|
||||
const dy = clientY - cy
|
||||
const d = dx * dx + dy * dy
|
||||
if (d < bestDist) {
|
||||
bestDist = d
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
return bestIdx
|
||||
}
|
||||
|
||||
function getMenuItemIndexFromPoint(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): number | null {
|
||||
const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null
|
||||
if (!el) return null
|
||||
|
||||
const menuItem = el.closest("[data-menu-idx]") as HTMLElement | null
|
||||
if (menuItem) {
|
||||
const idx = Number.parseInt(menuItem.dataset.menuIdx || "", 10)
|
||||
return Number.isFinite(idx) ? idx : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
useLayoutEffect(
|
||||
function setupCharRectsMeasurement() {
|
||||
function measureCharRects(): void {
|
||||
if (!sentenceRef.current) {
|
||||
charRectsRef.current = []
|
||||
return
|
||||
}
|
||||
const spans = sentenceRef.current.querySelectorAll("span[data-idx]")
|
||||
const rects: DOMRect[] = []
|
||||
spans.forEach(function collect(node) {
|
||||
rects.push((node as HTMLElement).getBoundingClientRect())
|
||||
})
|
||||
charRectsRef.current = rects
|
||||
}
|
||||
|
||||
measureCharRects()
|
||||
|
||||
let ro1: ResizeObserver | null = null
|
||||
let ro2: ResizeObserver | null = null
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
ro1 = new ResizeObserver(function onResize() {
|
||||
measureCharRects()
|
||||
})
|
||||
ro2 = new ResizeObserver(function onResize() {
|
||||
measureCharRects()
|
||||
})
|
||||
if (snippetRootRef.current) ro1.observe(snippetRootRef.current)
|
||||
if (sentenceRef.current) ro2.observe(sentenceRef.current)
|
||||
}
|
||||
|
||||
function onScroll(): void {
|
||||
measureCharRects()
|
||||
}
|
||||
window.addEventListener("resize", measureCharRects)
|
||||
window.addEventListener("scroll", onScroll, true)
|
||||
|
||||
return function cleanup(): void {
|
||||
if (ro1) ro1.disconnect()
|
||||
if (ro2) ro2.disconnect()
|
||||
window.removeEventListener("resize", measureCharRects)
|
||||
window.removeEventListener("scroll", onScroll, true)
|
||||
}
|
||||
},
|
||||
[targetText],
|
||||
)
|
||||
|
||||
useEffect(function setupActionsOnce() {
|
||||
lastStableIndexRef.current = 0
|
||||
setCurrentEndIndex(0)
|
||||
if (!sentenceRef.current) return
|
||||
const total = targetText.length
|
||||
const firstSpan = sentenceRef.current.querySelector(
|
||||
'span[data-idx="0"]',
|
||||
) as HTMLSpanElement | null
|
||||
const lastSpan = sentenceRef.current.querySelector(
|
||||
`span[data-idx="${Math.max(0, total - 1)}"]`,
|
||||
) as HTMLSpanElement | null
|
||||
if (!firstSpan || !lastSpan) return
|
||||
const firstRef = { current: firstSpan } as React.RefObject<HTMLElement>
|
||||
const lastRef = { current: lastSpan } as React.RefObject<HTMLElement>
|
||||
setCursorActions([
|
||||
{
|
||||
type: "call",
|
||||
fn: function reset() {
|
||||
lastStableIndexRef.current = 0
|
||||
setCurrentEndIndex(0)
|
||||
setHoveredMenuIndex(null)
|
||||
},
|
||||
},
|
||||
{ type: "startAt", target: firstRef },
|
||||
{ type: "pause", duration: 200 },
|
||||
{ type: "move", target: lastRef, duration: 1800 },
|
||||
{ type: "pause", duration: 1200 },
|
||||
{ type: "click" },
|
||||
{
|
||||
type: "call",
|
||||
fn: () => {
|
||||
setMenuOpen(true)
|
||||
},
|
||||
},
|
||||
{ type: "pause", duration: 1000 },
|
||||
{ type: "move", target: menuItem6Ref, duration: 1000 },
|
||||
{ type: "pause", duration: 500 },
|
||||
{ type: "click" },
|
||||
])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={snippetRootRef}
|
||||
className="size-full select-none text-xs relative overflow-hidden"
|
||||
>
|
||||
<Cursor
|
||||
actions={cursorActions}
|
||||
onPositionChange={function onPositionChange(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) {
|
||||
// Handle text highlighting
|
||||
const textIdx = getIndexFromClientPoint(clientX, clientY)
|
||||
const next =
|
||||
textIdx < lastStableIndexRef.current
|
||||
? lastStableIndexRef.current
|
||||
: textIdx
|
||||
if (next !== lastStableIndexRef.current) {
|
||||
lastStableIndexRef.current = next
|
||||
setCurrentEndIndex(next)
|
||||
}
|
||||
|
||||
// Handle menu item hovering
|
||||
if (menuOpen) {
|
||||
const menuIdx = getMenuItemIndexFromPoint(clientX, clientY)
|
||||
if (menuIdx !== hoveredMenuIndex) {
|
||||
setHoveredMenuIndex(menuIdx)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="h-[125%] w-full bg-white text-justify p-4 text-black absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
writing is easier to read, and the easier something is to read, the more
|
||||
deeply readers will engage with it. The less energy they expend on your
|
||||
prose, the more they'll have left for your ideas. And the further
|
||||
they'll read. Most readers' energy tends to flag part way through an
|
||||
article or essay. If the friction of reading is low enough, more keep
|
||||
going till the end.{" "}
|
||||
<span ref={sentenceRef}>
|
||||
{targetText.split("").map(function renderChar(ch, idx) {
|
||||
const highlighted = idx <= currentEndIndex
|
||||
return (
|
||||
<span
|
||||
key={`char-${idx}-${ch}`}
|
||||
data-idx={idx}
|
||||
className={highlighted ? "bg-blue-200/70" : undefined}
|
||||
>
|
||||
{ch}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
<span className="size-0 relative">
|
||||
{menuOpen && (
|
||||
<div className="flex flex-col w-48 text-left absolute top-0 right-0 text-white bg-zinc-800/70 text-xs border-0.5 backdrop-blur-sm border-zinc-900/80 rounded-md px-1.5 py-1.5">
|
||||
{[
|
||||
"Back",
|
||||
"Forward",
|
||||
"Reload",
|
||||
"Save As...",
|
||||
"Print...",
|
||||
"Translate to English",
|
||||
"Save to Supermemory",
|
||||
"View Page Source",
|
||||
"Inspect",
|
||||
].map((item, idx) => (
|
||||
<React.Fragment key={item}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
menuItemRefs.current[idx] = el
|
||||
if (idx === 6) {
|
||||
menuItem6Ref.current = el
|
||||
}
|
||||
}}
|
||||
data-menu-idx={idx}
|
||||
className={cn(
|
||||
"px-2 py-0.5 flex items-center gap-1.5 rounded-sm transition-colors",
|
||||
(idx === 0 || idx === 1) && "text-white/30",
|
||||
hoveredMenuIndex === idx && "bg-blue-500",
|
||||
)}
|
||||
>
|
||||
{idx === 6 && (
|
||||
<img
|
||||
src="/images/icon-16.png"
|
||||
alt="Supermemory"
|
||||
className="size-3.5"
|
||||
/>
|
||||
)}
|
||||
{item}
|
||||
</div>
|
||||
{[2, 5, 6].includes(idx) && (
|
||||
<div className="h-px rounded-full my-1 mx-2 bg-zinc-300/20" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</span>{" "}
|
||||
My goal when writing might be called saltintesta: the ideas leap into
|
||||
your head and you barely notice the words that got them there. It's too
|
||||
much to hope that writing could ever be pure ideas. You might not even
|
||||
want it to be. But for most writers, most of the time, that's the goal
|
||||
to aim for. The gap between most writing and pure ideas is not filled
|
||||
with poetry. Plus it's more considerate to write simply. When you write
|
||||
in a fancy way to impress people, you're making them do extra work just
|
||||
so you can seem cool. It's like trailing a long train behind you that
|
||||
readers have to carry.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatGPTDemo() {
|
||||
const iconRef = useRef<HTMLImageElement>(null)
|
||||
const submitButtonRef = useRef<HTMLDivElement>(null)
|
||||
const [enhancementStatus, setEnhancementStatus] = useState<
|
||||
"notStarted" | "enhancing" | "done"
|
||||
>("notStarted")
|
||||
const [memoriesExpanded, setMemoriesExpanded] = useState(false)
|
||||
|
||||
const cursorActions: CursorAction[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "call",
|
||||
fn: function resetStates() {
|
||||
setEnhancementStatus("notStarted")
|
||||
setMemoriesExpanded(false)
|
||||
},
|
||||
},
|
||||
{ type: "startAtPercent", xPercent: 80, yPercent: 80 },
|
||||
{ type: "pause", duration: 1000 },
|
||||
{ type: "move", target: iconRef, duration: 1000 },
|
||||
{ type: "pause", duration: 1000 },
|
||||
{ type: "click" },
|
||||
{
|
||||
type: "call",
|
||||
fn: function startEnhancing() {
|
||||
setEnhancementStatus("enhancing")
|
||||
},
|
||||
},
|
||||
{ type: "pause", duration: 1000 },
|
||||
{ type: "move", xPercent: 10, yPercent: 80, duration: 1000 },
|
||||
{
|
||||
type: "call",
|
||||
fn: function finishEnhancing() {
|
||||
setEnhancementStatus("done")
|
||||
},
|
||||
},
|
||||
{ type: "pause", duration: 500 },
|
||||
{ type: "move", target: iconRef, duration: 1000 },
|
||||
{
|
||||
type: "call",
|
||||
fn: function expandMemories() {
|
||||
setMemoriesExpanded(true)
|
||||
},
|
||||
},
|
||||
{ type: "pause", duration: 1000 },
|
||||
{ type: "move", xPercent: 80, yPercent: 80, duration: 1000 },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="size-full relative overflow-hidden select-none pointer-events-none text-white flex flex-col gap-6 items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: "#212121",
|
||||
fontFamily: "ui-sans-serif, -apple-system, system-ui",
|
||||
}}
|
||||
>
|
||||
<Cursor actions={cursorActions} />
|
||||
<div className="text-xl">What's on your mind today?</div>
|
||||
<div
|
||||
className="w-[85%] text-sm rounded-3xl p-2 flex flex-col gap-2"
|
||||
style={{ backgroundColor: "#303030" }}
|
||||
>
|
||||
<div className="p-2">what are my card's benefits?</div>
|
||||
<div className="flex justify-between items-center p-1 pt-0">
|
||||
<PlusIcon className="size-5" strokeWidth={1.5} />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4.5 flex items-center">
|
||||
<motion.div
|
||||
ref={iconRef}
|
||||
layout
|
||||
initial={false}
|
||||
animate={{
|
||||
backgroundColor:
|
||||
enhancementStatus === "notStarted"
|
||||
? "transparent"
|
||||
: "#1e1b4b",
|
||||
borderColor:
|
||||
enhancementStatus === "notStarted"
|
||||
? "transparent"
|
||||
: "#4338ca",
|
||||
borderWidth: enhancementStatus === "notStarted" ? 0 : 1,
|
||||
paddingLeft:
|
||||
enhancementStatus === "notStarted"
|
||||
? 0
|
||||
: enhancementStatus === "enhancing"
|
||||
? 4
|
||||
: 8,
|
||||
paddingRight:
|
||||
enhancementStatus === "notStarted"
|
||||
? 0
|
||||
: enhancementStatus === "enhancing"
|
||||
? 6
|
||||
: 8,
|
||||
paddingTop: enhancementStatus === "notStarted" ? 0 : 4,
|
||||
paddingBottom: enhancementStatus === "notStarted" ? 0 : 4,
|
||||
marginTop: enhancementStatus === "notStarted" ? 0 : 4,
|
||||
marginBottom: enhancementStatus === "notStarted" ? 0 : 4,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
layout: { duration: 0.2, ease: "easeInOut" },
|
||||
}}
|
||||
className="rounded-full text-xs w-fit flex items-center relative"
|
||||
style={{ border: "solid" }}
|
||||
>
|
||||
{enhancementStatus === "notStarted" && (
|
||||
<img
|
||||
src="/images/icon-16.png"
|
||||
alt="Enhance with Supermemory"
|
||||
className="size-5"
|
||||
/>
|
||||
)}
|
||||
{enhancementStatus === "enhancing" && (
|
||||
<>
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
<motion.span
|
||||
initial={{ opacity: 0, width: 0 }}
|
||||
animate={{ opacity: 1, width: "auto" }}
|
||||
transition={{ delay: 0.1, duration: 0.2 }}
|
||||
className="ml-2 whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
Searching...
|
||||
</motion.span>
|
||||
</>
|
||||
)}
|
||||
{enhancementStatus === "done" && (
|
||||
<>
|
||||
<CheckIcon className="size-3 text-green-400" />
|
||||
<motion.span
|
||||
initial={{ opacity: 0, width: "auto" }}
|
||||
animate={{ opacity: 1, width: "auto" }}
|
||||
transition={{ delay: 0.1, duration: 0.2 }}
|
||||
className="ml-2"
|
||||
>
|
||||
Including 1 memory
|
||||
</motion.span>
|
||||
</>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{memoriesExpanded && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: -8,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: -8,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.15,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
className="absolute left-1/2 -translate-x-1/2 top-8 bg-[#1e1b4b] border border-[#4338ca] w-56 rounded-lg p-2"
|
||||
style={{ transformOrigin: "top center" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src="/images/icon-16.png"
|
||||
alt="Enhance with Supermemory"
|
||||
className="size-5"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
User possesses an American Express Platinum card
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<MicIcon className="size-4" strokeWidth={1.5} />
|
||||
<div
|
||||
ref={submitButtonRef}
|
||||
className="rounded-full bg-white size-6 flex items-center justify-center"
|
||||
>
|
||||
<ArrowUpIcon className="size-4 text-black" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TwitterDemo() {
|
||||
const importButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const [importStatus, setImportStatus] = useState<
|
||||
"notStarted" | "importing" | "done"
|
||||
>("notStarted")
|
||||
|
||||
const cursorActions: CursorAction[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "call",
|
||||
fn: function resetStates() {
|
||||
setImportStatus("notStarted")
|
||||
},
|
||||
},
|
||||
{ type: "startAtPercent", xPercent: 10, yPercent: 80 },
|
||||
{ type: "pause", duration: 1300 },
|
||||
{ type: "move", target: importButtonRef, duration: 1100 },
|
||||
{ type: "pause", duration: 800 },
|
||||
{ type: "click" },
|
||||
{
|
||||
type: "call",
|
||||
fn: function startImporting() {
|
||||
setImportStatus("importing")
|
||||
},
|
||||
},
|
||||
{ type: "pause", duration: 700 },
|
||||
{ type: "move", xPercent: 80, yPercent: 80, duration: 1200 },
|
||||
{
|
||||
type: "call",
|
||||
fn: function finishImporting() {
|
||||
setImportStatus("done")
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="size-full relative overflow-hidden select-none flex flex-col items-center justify-center">
|
||||
<div className="bg-white text-black px-5 py-3 text-sm rounded-2xl w-9/10">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold">𝕏</span>
|
||||
<span className="text-base font-medium">
|
||||
Import Twitter Bookmarks
|
||||
</span>
|
||||
</div>
|
||||
<XIcon className="size-4" />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-zinc-600">
|
||||
This will import all your Twitter bookmarks to Supermemory
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<motion.button
|
||||
ref={importButtonRef}
|
||||
animate={{
|
||||
backgroundColor:
|
||||
importStatus === "importing"
|
||||
? "#f59e0b"
|
||||
: importStatus === "done"
|
||||
? "#10b981"
|
||||
: "#3b82f6",
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
className="text-white px-4 py-2 rounded-lg flex items-center gap-2 min-w-[180px] justify-center"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{importStatus === "importing" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
</motion.div>
|
||||
)}
|
||||
{importStatus === "done" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.span
|
||||
key={importStatus}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
{importStatus === "importing"
|
||||
? "Importing bookmarks..."
|
||||
: importStatus === "done"
|
||||
? "Import successful"
|
||||
: "Import All Bookmarks"}
|
||||
</motion.span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
<Cursor actions={cursorActions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExtensionForm() {
|
||||
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding()
|
||||
return (
|
||||
<div className="relative flex items-start flex-col gap-6 w-full">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between w-full gap-6 relative">
|
||||
<div className="flex flex-col items-start text-left gap-4 flex-1">
|
||||
<NavMenu>
|
||||
<p className="text-base text-white/60">
|
||||
Step {getStepNumberFor("extension")} of {totalSteps}
|
||||
</p>
|
||||
</NavMenu>
|
||||
<h1 className="text-white font-medium text-2xl md:text-4xl">
|
||||
Install the Chrome extension
|
||||
</h1>
|
||||
<p className="text-white/80 text-lg md:text-2xl">
|
||||
Bring Supermemory everywhere
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end text-center gap-3 w-full md:w-auto">
|
||||
<Button
|
||||
variant="link"
|
||||
size="lg"
|
||||
className="text-white/80 hover:text-white font-medium! text-lg w-fit px-0! cursor-pointer"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Continue
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
<a
|
||||
href="https://chromewebstore.google.com/detail/afpgkkipfdpeaflnpoaffkcankadgjfc?utm_source=item-share-cb"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="bg-zinc-50/80 backdrop-blur-lg border-2 hover:bg-zinc-100/80 transition-colors duration-100 border-zinc-200/80 shadow-xs rounded-full pl-3.5 pr-4 py-2.5 text-base font-sans tracking-tight font-medium flex items-center gap-3 w-full md:w-auto justify-center"
|
||||
>
|
||||
<img
|
||||
src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Google_Chrome_icon_%28February_2022%29.svg/2048px-Google_Chrome_icon_%28February_2022%29.svg.png"
|
||||
alt="Chrome"
|
||||
className="size-5"
|
||||
/>
|
||||
Add to Chrome
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 w-full max-w-6xl gap-4 text-base font-sans font-medium tracking-normal">
|
||||
<div className="flex flex-col w-full max-w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
|
||||
<div className="p-4 bg-white">
|
||||
<h2 className="text-lg">Remember anything, anywhere</h2>
|
||||
<p className="text-zinc-600 text-sm">
|
||||
Just right-click to save instantly.
|
||||
</p>
|
||||
</div>
|
||||
<div className="aspect-square bg-blue-500">
|
||||
<SnippetDemo />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col w-full max-w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
|
||||
<div className="p-4 bg-white">
|
||||
<h2 className="text-lg">Integrate with your AI</h2>
|
||||
{/* Supercharge your AI with memory */}
|
||||
{/* Supercharge any AI with Supermemory. */}
|
||||
{/* ChatGPT is better with Supermemory. */}
|
||||
{/* Seamless integration with your workflow */}
|
||||
<p className="text-zinc-600 text-sm">
|
||||
{/* Integrates with ChatGPT and Claude. */}
|
||||
{/* Integrates with your chat apps */}
|
||||
Enhance any prompt with Supermemory.
|
||||
{/* Seamlessly */}
|
||||
</p>
|
||||
</div>
|
||||
<div className="aspect-square bg-blue-500">
|
||||
<ChatGPTDemo />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col w-full max-w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden">
|
||||
<div className="p-4 bg-white">
|
||||
<h2 className="text-lg">Import Twitter bookmarks</h2>
|
||||
<p className="text-zinc-600 text-sm">
|
||||
Search semantically and effortlessly.
|
||||
{/* Import instantly and search effortlessly. */}
|
||||
</p>
|
||||
</div>
|
||||
<div className="aspect-square bg-blue-500">
|
||||
<TwitterDemo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react"
|
||||
import { useEffect, useMemo, useState, memo } from "react"
|
||||
import { useOnboarding } from "./onboarding-context"
|
||||
|
||||
interface OrbProps {
|
||||
size: number
|
||||
initialX: number
|
||||
initialY: number
|
||||
duration: number
|
||||
delay: number
|
||||
revealDelay: number
|
||||
shouldReveal: boolean
|
||||
color: {
|
||||
primary: string
|
||||
secondary: string
|
||||
tertiary: string
|
||||
}
|
||||
}
|
||||
|
||||
function FloatingOrb({
|
||||
size,
|
||||
initialX,
|
||||
initialY,
|
||||
duration,
|
||||
delay,
|
||||
revealDelay,
|
||||
shouldReveal,
|
||||
color,
|
||||
}: OrbProps) {
|
||||
const blurPixels = Math.min(64, Math.max(24, Math.floor(size * 0.08)))
|
||||
|
||||
const gradient = useMemo(() => {
|
||||
return `radial-gradient(circle, ${color.primary} 0%, ${color.secondary} 40%, ${color.tertiary} 70%, transparent 100%)`
|
||||
}, [color.primary, color.secondary, color.tertiary])
|
||||
|
||||
const style = useMemo(() => {
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
background: gradient,
|
||||
filter: `blur(${blurPixels}px)`,
|
||||
willChange: "transform, opacity",
|
||||
mixBlendMode: "plus-lighter",
|
||||
} as any
|
||||
}, [size, gradient, blurPixels])
|
||||
|
||||
const initial = useMemo(() => {
|
||||
return {
|
||||
x: initialX,
|
||||
y: initialY,
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
}
|
||||
}, [initialX, initialY])
|
||||
|
||||
const animate = useMemo(() => {
|
||||
if (!shouldReveal) {
|
||||
return {
|
||||
x: initialX,
|
||||
y: initialY,
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: [initialX, initialX + 200, initialX - 150, initialX + 100, initialX],
|
||||
y: [initialY, initialY - 180, initialY + 120, initialY - 80, initialY],
|
||||
scale: [0.8, 1.2, 0.9, 1.1, 0.8],
|
||||
opacity: 0.7,
|
||||
}
|
||||
}, [shouldReveal, initialX, initialY])
|
||||
|
||||
const transition = useMemo(() => {
|
||||
return {
|
||||
x: {
|
||||
duration: shouldReveal ? duration : 0,
|
||||
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
|
||||
ease: [0.42, 0, 0.58, 1],
|
||||
delay: shouldReveal ? delay + revealDelay : 0,
|
||||
},
|
||||
y: {
|
||||
duration: shouldReveal ? duration : 0,
|
||||
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
|
||||
ease: [0.42, 0, 0.58, 1],
|
||||
delay: shouldReveal ? delay + revealDelay : 0,
|
||||
},
|
||||
scale: {
|
||||
duration: shouldReveal ? duration : 0.8,
|
||||
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
|
||||
ease: shouldReveal ? [0.42, 0, 0.58, 1] : [0, 0, 0.58, 1],
|
||||
delay: shouldReveal ? delay + revealDelay : revealDelay,
|
||||
},
|
||||
opacity: {
|
||||
duration: 1.2,
|
||||
ease: [0, 0, 0.58, 1],
|
||||
delay: shouldReveal ? revealDelay : 0,
|
||||
},
|
||||
} as any
|
||||
}, [shouldReveal, duration, delay, revealDelay])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute rounded-full"
|
||||
style={style}
|
||||
initial={initial}
|
||||
animate={animate}
|
||||
transition={transition}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const MemoFloatingOrb = memo(FloatingOrb)
|
||||
|
||||
export function FloatingOrbs() {
|
||||
const { orbsRevealed } = useOnboarding()
|
||||
const reduceMotion = useReducedMotion()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [orbs, setOrbs] = useState<
|
||||
Array<{
|
||||
id: number
|
||||
size: number
|
||||
initialX: number
|
||||
initialY: number
|
||||
duration: number
|
||||
delay: number
|
||||
revealDelay: number
|
||||
color: {
|
||||
primary: string
|
||||
secondary: string
|
||||
tertiary: string
|
||||
}
|
||||
}>
|
||||
>([])
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
|
||||
const screenWidth = typeof window !== "undefined" ? window.innerWidth : 1200
|
||||
const screenHeight =
|
||||
typeof window !== "undefined" ? window.innerHeight : 800
|
||||
|
||||
// Define edge zones (avoiding center)
|
||||
const edgeThickness = Math.min(screenWidth, screenHeight) * 0.25 // 25% of smaller dimension
|
||||
|
||||
// Define rainbow color palette
|
||||
const colorPalette = [
|
||||
{
|
||||
// Magenta
|
||||
primary: "rgba(255, 0, 150, 0.6)",
|
||||
secondary: "rgba(255, 100, 200, 0.4)",
|
||||
tertiary: "rgba(255, 150, 220, 0.1)",
|
||||
},
|
||||
{
|
||||
// Yellow
|
||||
primary: "rgba(255, 235, 59, 0.6)",
|
||||
secondary: "rgba(255, 245, 120, 0.4)",
|
||||
tertiary: "rgba(255, 250, 180, 0.1)",
|
||||
},
|
||||
{
|
||||
// Light Blue
|
||||
primary: "rgba(100, 181, 246, 0.6)",
|
||||
secondary: "rgba(144, 202, 249, 0.4)",
|
||||
tertiary: "rgba(187, 222, 251, 0.1)",
|
||||
},
|
||||
{
|
||||
// Orange (keeping original)
|
||||
primary: "rgba(255, 154, 0, 0.6)",
|
||||
secondary: "rgba(255, 206, 84, 0.4)",
|
||||
tertiary: "rgba(255, 154, 0, 0.1)",
|
||||
},
|
||||
{
|
||||
// Very Light Red/Pink
|
||||
primary: "rgba(255, 138, 128, 0.6)",
|
||||
secondary: "rgba(255, 171, 145, 0.4)",
|
||||
tertiary: "rgba(255, 205, 210, 0.1)",
|
||||
},
|
||||
]
|
||||
|
||||
// Generate orb configurations positioned along edges
|
||||
const newOrbs = Array.from({ length: 8 }, (_, i) => {
|
||||
let x: number
|
||||
let y: number
|
||||
const zone = i % 4 // Rotate through 4 zones: top, right, bottom, left
|
||||
|
||||
switch (zone) {
|
||||
case 0: // Top edge
|
||||
x = Math.random() * screenWidth
|
||||
y = Math.random() * edgeThickness
|
||||
break
|
||||
case 1: // Right edge
|
||||
x = screenWidth - edgeThickness + Math.random() * edgeThickness
|
||||
y = Math.random() * screenHeight
|
||||
break
|
||||
case 2: // Bottom edge
|
||||
x = Math.random() * screenWidth
|
||||
y = screenHeight - edgeThickness + Math.random() * edgeThickness
|
||||
break
|
||||
case 3: // Left edge
|
||||
x = Math.random() * edgeThickness
|
||||
y = Math.random() * screenHeight
|
||||
break
|
||||
default:
|
||||
x = Math.random() * screenWidth
|
||||
y = Math.random() * screenHeight
|
||||
}
|
||||
|
||||
return {
|
||||
id: i,
|
||||
size: Math.random() * 300 + 200, // 200px to 500px
|
||||
initialX: x,
|
||||
initialY: y,
|
||||
duration: Math.random() * 20 + 15, // 15-35 seconds (longer for more gentle movement)
|
||||
delay: i * 0.4, // Staggered start for floating animation
|
||||
revealDelay: i * 0.2, // Faster staggered reveal
|
||||
color: colorPalette[i % colorPalette.length]!, // Cycle through rainbow colors
|
||||
}
|
||||
})
|
||||
|
||||
setOrbs(newOrbs)
|
||||
}, [])
|
||||
|
||||
if (!mounted || orbs.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none overflow-hidden"
|
||||
style={{ isolation: "isolate", contain: "paint" }}
|
||||
>
|
||||
{orbs.map((orb) => (
|
||||
<MemoFloatingOrb
|
||||
key={orb.id}
|
||||
size={orb.size}
|
||||
initialX={orb.initialX}
|
||||
initialY={orb.initialY}
|
||||
duration={reduceMotion ? 0 : orb.duration}
|
||||
delay={orb.delay}
|
||||
revealDelay={orb.revealDelay}
|
||||
shouldReveal={reduceMotion ? false : orbsRevealed}
|
||||
color={orb.color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { AnimatedText } from "./animated-text"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useOnboarding } from "./onboarding-context"
|
||||
|
||||
export function Intro() {
|
||||
const { nextStep, introTriggers: triggers } = useOnboarding()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col gap-4 relative text-2xl md:text-4xl w-full text-white text-center"
|
||||
layout
|
||||
transition={{
|
||||
layout: { duration: 0.8, ease: "anticipate" },
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
<p className="font-medium text-base">Hey there!</p>
|
||||
{triggers.first && (
|
||||
<motion.div
|
||||
key="first"
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
opacity: { duration: 0.3 },
|
||||
layout: { duration: 0.8, ease: "easeInOut" },
|
||||
}}
|
||||
>
|
||||
<AnimatedText trigger={triggers.first} delay={0}>
|
||||
Intelligence without memory
|
||||
</AnimatedText>
|
||||
</motion.div>
|
||||
)}
|
||||
{triggers.second && (
|
||||
<motion.div
|
||||
key="second"
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
opacity: { duration: 0.3 },
|
||||
layout: { duration: 0.8, ease: "easeInOut" },
|
||||
}}
|
||||
>
|
||||
<AnimatedText trigger={triggers.second} delay={0.4}>
|
||||
is just sophisticated randomness.
|
||||
</AnimatedText>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
key="fourth"
|
||||
className="justify-center flex mt-6"
|
||||
initial={{ opacity: 0, filter: "blur(5px)" }}
|
||||
animate={{
|
||||
opacity: triggers.fourth ? 1 : 0,
|
||||
filter: triggers.fourth ? "blur(0px)" : "blur(5px)",
|
||||
}}
|
||||
transition={{
|
||||
opacity: { duration: 0.6, ease: "easeOut" },
|
||||
filter: { duration: 0.4, ease: "easeOut" },
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant={"default"}
|
||||
size={"sm"}
|
||||
className={cn(
|
||||
"bg-[#1e3a5f] hover:bg-[#2a4a75] border-2 border-[#4a7ba7]/50 text-white font-medium px-6 py-3 rounded-lg shadow-lg hover:shadow-xl transition-all duration-300",
|
||||
!triggers.fourth && "pointer-events-none opacity-50",
|
||||
)}
|
||||
style={{
|
||||
transform: triggers.fourth ? "scale(1)" : "scale(0.95)",
|
||||
}}
|
||||
onClick={nextStep}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@ui/components/select"
|
||||
import { useOnboarding } from "./onboarding-context"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { CheckIcon, CircleCheckIcon, CopyIcon, LoaderIcon } from "lucide-react"
|
||||
import { TextMorph } from "@/components/text-morph"
|
||||
import { NavMenu } from "./nav-menu"
|
||||
import { cn } from "@lib/utils"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
const clients = {
|
||||
cursor: "Cursor",
|
||||
claude: "Claude Desktop",
|
||||
vscode: "VSCode",
|
||||
cline: "Cline",
|
||||
"roo-cline": "Roo Cline",
|
||||
witsy: "Witsy",
|
||||
enconvo: "Enconvo",
|
||||
"gemini-cli": "Gemini CLI",
|
||||
"claude-code": "Claude Code",
|
||||
} as const
|
||||
|
||||
export function MCPForm() {
|
||||
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding()
|
||||
const [client, setClient] = useState<keyof typeof clients>("cursor")
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const [isInstalling, setIsInstalling] = useState(true)
|
||||
|
||||
const hasLoginQuery = useQuery({
|
||||
queryKey: ["mcp", "has-login"],
|
||||
queryFn: async (): Promise<{ previousLogin: boolean }> => {
|
||||
const response = await $fetch("@get/mcp/has-login")
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to check MCP login")
|
||||
}
|
||||
return response.data as { previousLogin: boolean }
|
||||
},
|
||||
enabled: isInstalling,
|
||||
refetchInterval: isInstalling ? 1000 : false,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoginQuery.data?.previousLogin) {
|
||||
setIsInstalling(false)
|
||||
}
|
||||
}, [hasLoginQuery.data?.previousLogin])
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-6">
|
||||
<div className="space-y-4 relative">
|
||||
<div className="absolute top-0 right-0">
|
||||
<AnimatePresence mode="sync">
|
||||
{!isInstalling ? (
|
||||
<motion.div
|
||||
key="save"
|
||||
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
|
||||
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
size="lg"
|
||||
className="text-white/80 not-odd:font-medium! text-lg w-fit px-0! cursor-pointer"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="skip"
|
||||
initial={{ opacity: 0, filter: "blur(5px)" }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, filter: "blur(5px)" }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
size="lg"
|
||||
className="text-white/80 font-medium! text-lg w-fit px-0! cursor-pointer"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Skip For Now
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<NavMenu>
|
||||
<p className="text-base text-white/60">
|
||||
Step {getStepNumberFor("mcp")} of {totalSteps}
|
||||
</p>
|
||||
</NavMenu>
|
||||
<h1 className="max-sm:text-4xl text-white font-medium">
|
||||
Install the MCP server
|
||||
</h1>
|
||||
<p className="text-2xl max-sm:text-lg text-white/80">
|
||||
Bring Supermemory to all your favourite tools
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 font-sans text-base tracking-normal font-normal">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div
|
||||
style={{
|
||||
height: "calc(100% - 0.5rem)",
|
||||
}}
|
||||
className="absolute -z-10 left-1/2 top-8 w-[1px] -translate-x-1/2 transform bg-white/10"
|
||||
/>
|
||||
<div className="size-10 rounded-lg bg-white/10 text-white font-medium flex items-center justify-center">
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2 w-full">
|
||||
<p className="text-white/80">
|
||||
Select the app you want to install Supermemory MCP to
|
||||
</p>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
setClient(value as keyof typeof clients)
|
||||
}
|
||||
value={client}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="client-select"
|
||||
className="w-full bg-white/10! text-white"
|
||||
>
|
||||
<SelectValue placeholder="Select client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(clients).map(([key, value]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="size-10 rounded-lg bg-white/10 text-white font-medium flex items-center justify-center">
|
||||
2
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: "calc(100% - 0.5rem)",
|
||||
}}
|
||||
className="absolute left-1/2 -z-10 top-8 w-[1px] -translate-x-1/2 transform bg-white/10"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-white/80">Copy the installation command</p>
|
||||
<div className="bg-white/10 relative shadow-xs rounded-lg max-w-md text-balance py-4 px-5 align-middle justify-center">
|
||||
<p className="text-white font-mono text-xs w-4/5 text-nowrap overflow-x-hidden text-ellipsis">
|
||||
npx -y install-mcp@latest https://mcp.supermemory.ai/mcp
|
||||
--client {client} --oauth=yes
|
||||
</p>
|
||||
<Button
|
||||
className="absolute right-2 top-[6px]"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`npx -y install-mcp@latest https://mcp.supermemory.ai/mcp --client ${client} --oauth=yes`,
|
||||
)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, 2000)
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
<TextMorph>{isCopied ? "Copied!" : "Copy"}</TextMorph>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="size-10 rounded-lg bg-white/10 text-white font-medium flex items-center justify-center">
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2 w-full">
|
||||
<p className="text-white/80">
|
||||
Run the command in your terminal of choice
|
||||
</p>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"px-5 py-4 bg-black/10 text-white shadow-xs rounded-lg flex items-center gap-3 font-mono text-sm",
|
||||
)}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{isInstalling ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<CircleCheckIcon className="size-4 text-white" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.span
|
||||
key={isInstalling ? "installing" : "complete"}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
{isInstalling
|
||||
? "Waiting for installation..."
|
||||
: "Installation complete!"}
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useOnboarding } from "./onboarding-context"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import Link from "next/link"
|
||||
import { useEffect, useState } from "react"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { NavMenu } from "./nav-menu"
|
||||
import { authClient } from "@lib/auth"
|
||||
|
||||
export function NameForm() {
|
||||
const { nextStep, totalSteps, getStepNumberFor } = useOnboarding()
|
||||
const { user } = useAuth()
|
||||
const [name, setName] = useState(user?.name ?? "")
|
||||
|
||||
useEffect(() => {
|
||||
if (!name && user?.name) {
|
||||
setName(user.name)
|
||||
}
|
||||
}, [name, user?.name])
|
||||
|
||||
function handleNext(): void {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) {
|
||||
nextStep()
|
||||
return
|
||||
}
|
||||
|
||||
nextStep()
|
||||
void authClient.updateUser({ name: trimmed }).catch((error: unknown) => {
|
||||
console.error("Failed to update user name during onboarding:", error)
|
||||
})
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent): void {
|
||||
e.preventDefault()
|
||||
handleNext()
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-4xl">You need to sign in to continue</h1>
|
||||
<Link href="/login">Login</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<NavMenu>
|
||||
<p className="text-base text-white/60">
|
||||
Step {getStepNumberFor("name")} of {totalSteps}
|
||||
</p>
|
||||
</NavMenu>
|
||||
<p className="text-2xl md:text-4xl text-white font-medium">
|
||||
What should we call you?
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col group text-white">
|
||||
<div className="relative flex flex-col">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck="false"
|
||||
className="outline-0 text-2xl h-12 font-normal p-0"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{name && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="next"
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute pointer-events-none inset-0 flex items-center justify-end"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="cursor-pointer transition-colors duration-150 gap-2 pointer-events-auto flex items-center p-2 hover:bg-zinc-100 hover:text-black/90 rounded-lg"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
{/* <span className="text-sm">Next</span> */}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="w-full rounded-full group-focus-within:bg-zinc-400 transition-colors h-px bg-zinc-200" />
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
"use client"
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@ui/components/hover-card"
|
||||
import { useOnboarding, type OnboardingStep } from "./onboarding-context"
|
||||
import { useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
export function NavMenu({ children }: { children: React.ReactNode }) {
|
||||
const { setStep, currentStep, visibleSteps, getStepNumberFor } =
|
||||
useOnboarding()
|
||||
const [open, setOpen] = useState(false)
|
||||
const LABELS: Record<OnboardingStep, string> = {
|
||||
intro: "Intro",
|
||||
name: "Name",
|
||||
bio: "About you",
|
||||
// connections: "Connections",
|
||||
mcp: "MCP",
|
||||
extension: "Extension",
|
||||
welcome: "Welcome",
|
||||
}
|
||||
const navigableSteps = visibleSteps.filter(
|
||||
(step) => step !== "intro" && step !== "welcome",
|
||||
)
|
||||
return (
|
||||
<HoverCard openDelay={100} open={open} onOpenChange={setOpen}>
|
||||
<HoverCardTrigger className="w-fit" asChild>
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
side="left"
|
||||
sideOffset={24}
|
||||
className="origin-top-right bg-white border border-zinc-200 text-zinc-900"
|
||||
>
|
||||
<h2 className="text-zinc-900 text-sm font-medium">Go to step</h2>
|
||||
<ul className="text-sm mt-2">
|
||||
{navigableSteps.map((step) => (
|
||||
<li key={step}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"py-1.5 px-2 rounded-md hover:bg-zinc-100 w-full text-left",
|
||||
currentStep === step && "bg-zinc-100",
|
||||
)}
|
||||
onClick={() => {
|
||||
setStep(step)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{getStepNumberFor(step)}. {LABELS[step]}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useOnboarding } from "./onboarding-context"
|
||||
|
||||
interface OnboardingBackgroundProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function OnboardingBackground({ children }: OnboardingBackgroundProps) {
|
||||
const { currentStep, visibleSteps } = useOnboarding()
|
||||
|
||||
const backgroundImage = "url(/onboarding.png)"
|
||||
|
||||
const currentZoomStepIndex = visibleSteps.indexOf(currentStep)
|
||||
|
||||
const zoomScale =
|
||||
currentZoomStepIndex >= 0 ? 1.0 + currentZoomStepIndex * 0.1 : 1.0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full overflow-x-hidden text-zinc-900 flex items-center justify-center relative px-4 md:px-0">
|
||||
<div
|
||||
className="absolute inset-0 transition-transform duration-700 ease-in-out"
|
||||
style={{
|
||||
backgroundImage,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "bottom",
|
||||
backgroundRepeat: "no-repeat",
|
||||
transform: `scale(${zoomScale})`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-4xl mx-auto">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
} from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
// Define the context interface
|
||||
interface OnboardingContextType {
|
||||
currentStep: OnboardingStep
|
||||
setStep: (step: OnboardingStep) => void
|
||||
nextStep: () => void
|
||||
previousStep: () => void
|
||||
totalSteps: number
|
||||
currentStepIndex: number
|
||||
// Visible-step aware helpers
|
||||
visibleSteps: OnboardingStep[]
|
||||
currentVisibleStepIndex: number
|
||||
currentVisibleStepNumber: number
|
||||
getStepNumberFor: (step: OnboardingStep) => number
|
||||
introTriggers: {
|
||||
first: boolean
|
||||
second: boolean
|
||||
third: boolean
|
||||
fourth: boolean
|
||||
}
|
||||
orbsRevealed: boolean
|
||||
resetIntroTriggers: () => void
|
||||
}
|
||||
|
||||
// Create the context
|
||||
const OnboardingContext = createContext<OnboardingContextType | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
// Define the base step order
|
||||
const BASE_STEP_ORDER = [
|
||||
"intro",
|
||||
"name",
|
||||
"bio",
|
||||
"mcp",
|
||||
"extension",
|
||||
"welcome",
|
||||
] as const
|
||||
|
||||
export type OnboardingStep = (typeof BASE_STEP_ORDER)[number]
|
||||
|
||||
interface OnboardingProviderProps {
|
||||
children: ReactNode
|
||||
initialStep?: OnboardingStep
|
||||
}
|
||||
|
||||
export function OnboardingProvider({
|
||||
children,
|
||||
initialStep = "intro",
|
||||
}: OnboardingProviderProps) {
|
||||
// Helper function to validate if a step is valid
|
||||
const isValidStep = (step: string): step is OnboardingStep => {
|
||||
return BASE_STEP_ORDER.includes(step as OnboardingStep)
|
||||
}
|
||||
|
||||
const [currentStep, setCurrentStep] = useQueryState("step", {
|
||||
defaultValue: initialStep,
|
||||
parse: (value: string) => {
|
||||
// Validate the step from URL - if invalid, use the initial step
|
||||
return isValidStep(value) ? value : initialStep
|
||||
},
|
||||
serialize: (value: OnboardingStep) => value,
|
||||
})
|
||||
const [orbsRevealed, setOrbsRevealed] = useState(false)
|
||||
const [introTriggers, setIntroTriggers] = useState({
|
||||
first: false,
|
||||
second: false,
|
||||
third: false,
|
||||
fourth: false,
|
||||
})
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
// Compute visible steps based on device
|
||||
const visibleSteps = useMemo(() => {
|
||||
if (isMobile) {
|
||||
// On mobile, hide MCP and Extension steps
|
||||
return BASE_STEP_ORDER.filter((s) => s !== "mcp" && s !== "extension")
|
||||
}
|
||||
return [...BASE_STEP_ORDER]
|
||||
}, [isMobile])
|
||||
|
||||
// Setup intro trigger timings when on intro step
|
||||
useEffect(() => {
|
||||
if (currentStep !== "intro") return
|
||||
|
||||
const cleanups = [
|
||||
setTimeout(() => {
|
||||
setIntroTriggers((prev) => ({ ...prev, first: true }))
|
||||
}, 300),
|
||||
setTimeout(() => {
|
||||
setIntroTriggers((prev) => ({ ...prev, second: true }))
|
||||
}, 300),
|
||||
setTimeout(() => {
|
||||
setIntroTriggers((prev) => ({ ...prev, third: true }))
|
||||
}, 300),
|
||||
setTimeout(() => {
|
||||
setIntroTriggers((prev) => ({ ...prev, fourth: true }))
|
||||
}, 400),
|
||||
]
|
||||
|
||||
return () => cleanups.forEach(clearTimeout)
|
||||
}, [currentStep])
|
||||
|
||||
// Set orbs as revealed once the fourth trigger is activated OR if we're on any non-intro step
|
||||
useEffect(() => {
|
||||
if (currentStep !== "intro") {
|
||||
// If we're not on the intro step, orbs should always be visible
|
||||
// (user has either completed intro or navigated directly to another step)
|
||||
if (!orbsRevealed) {
|
||||
setOrbsRevealed(true)
|
||||
}
|
||||
} else if (introTriggers.fourth && !orbsRevealed) {
|
||||
// On intro step, reveal orbs only after the fourth trigger
|
||||
setOrbsRevealed(true)
|
||||
}
|
||||
}, [introTriggers.fourth, orbsRevealed, currentStep])
|
||||
|
||||
// Ensure current step is always part of visible steps; if not, advance to the next visible step
|
||||
useEffect(() => {
|
||||
if (!visibleSteps.includes(currentStep)) {
|
||||
if (visibleSteps.length === 0) return
|
||||
const baseIndex = BASE_STEP_ORDER.indexOf(currentStep)
|
||||
// Find the next visible step after the current base index
|
||||
const nextAfterBase = visibleSteps.find(
|
||||
(step) => BASE_STEP_ORDER.indexOf(step) > baseIndex,
|
||||
)
|
||||
const targetStep = nextAfterBase ?? visibleSteps[visibleSteps.length - 1]!
|
||||
setCurrentStep(targetStep)
|
||||
}
|
||||
}, [visibleSteps, currentStep])
|
||||
|
||||
function setStep(step: OnboardingStep) {
|
||||
setCurrentStep(step)
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
const currentIndex = visibleSteps.indexOf(currentStep)
|
||||
const nextIndex = currentIndex + 1
|
||||
|
||||
if (nextIndex < visibleSteps.length) {
|
||||
setStep(visibleSteps[nextIndex]!)
|
||||
}
|
||||
}
|
||||
|
||||
function previousStep() {
|
||||
const currentIndex = visibleSteps.indexOf(currentStep)
|
||||
const previousIndex = currentIndex - 1
|
||||
|
||||
if (previousIndex >= 0) {
|
||||
setStep(visibleSteps[previousIndex]!)
|
||||
}
|
||||
}
|
||||
|
||||
function resetIntroTriggers() {
|
||||
setIntroTriggers({
|
||||
first: false,
|
||||
second: false,
|
||||
third: false,
|
||||
fourth: false,
|
||||
})
|
||||
}
|
||||
|
||||
const currentStepIndex = BASE_STEP_ORDER.indexOf(currentStep)
|
||||
|
||||
// Visible-step aware helpers
|
||||
const stepsForNumbering = useMemo(
|
||||
() => visibleSteps.filter((s) => s !== "intro" && s !== "welcome"),
|
||||
[visibleSteps],
|
||||
)
|
||||
|
||||
function getStepNumberFor(step: OnboardingStep): number {
|
||||
if (step === "intro" || step === "welcome") {
|
||||
return 0
|
||||
}
|
||||
const idx = stepsForNumbering.indexOf(step)
|
||||
return idx === -1 ? 0 : idx + 1
|
||||
}
|
||||
|
||||
const currentVisibleStepIndex = useMemo(
|
||||
() => visibleSteps.indexOf(currentStep),
|
||||
[visibleSteps, currentStep],
|
||||
)
|
||||
const currentVisibleStepNumber = useMemo(
|
||||
() => getStepNumberFor(currentStep),
|
||||
[currentStep, stepsForNumbering],
|
||||
)
|
||||
const totalSteps = stepsForNumbering.length
|
||||
|
||||
const contextValue: OnboardingContextType = {
|
||||
currentStep,
|
||||
setStep,
|
||||
nextStep,
|
||||
previousStep,
|
||||
totalSteps,
|
||||
currentStepIndex,
|
||||
visibleSteps,
|
||||
currentVisibleStepIndex,
|
||||
currentVisibleStepNumber,
|
||||
getStepNumberFor,
|
||||
introTriggers,
|
||||
orbsRevealed,
|
||||
resetIntroTriggers,
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</OnboardingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useOnboarding() {
|
||||
const context = useContext(OnboardingContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useOnboarding must be used within an OnboardingProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { NameForm } from "./name-form"
|
||||
import { Intro } from "./intro"
|
||||
import { useOnboarding } from "./onboarding-context"
|
||||
import { BioForm } from "./bio-form"
|
||||
import { ExtensionForm } from "./extension-form"
|
||||
import { MCPForm } from "./mcp-form"
|
||||
import { Welcome } from "./welcome"
|
||||
import { Space_Grotesk } from "next/font/google"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
const sans = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
})
|
||||
|
||||
export function OnboardingForm() {
|
||||
const { currentStep, resetIntroTriggers } = useOnboarding()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-2xl md:text-4xl px-4 md:px-6 py-6 md:py-8 flex flex-col justify-center w-full max-w-4xl mx-auto",
|
||||
sans.variable,
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode="wait" onExitComplete={resetIntroTriggers}>
|
||||
{currentStep === "intro" && (
|
||||
<motion.div
|
||||
key="intro"
|
||||
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.98 }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
|
||||
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.98 }}
|
||||
transition={{ duration: 0.28, ease: "easeInOut" }}
|
||||
>
|
||||
<Intro />
|
||||
</motion.div>
|
||||
)}
|
||||
{currentStep === "name" && (
|
||||
<motion.div
|
||||
key="name"
|
||||
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
|
||||
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
<NameForm />
|
||||
</motion.div>
|
||||
)}
|
||||
{currentStep === "bio" && (
|
||||
<motion.div
|
||||
key="bio"
|
||||
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
|
||||
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
<BioForm />
|
||||
</motion.div>
|
||||
)}
|
||||
{/*{currentStep === "connections" && (
|
||||
<motion.div
|
||||
key="connections"
|
||||
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
|
||||
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
<ConnectionsForm />
|
||||
</motion.div>
|
||||
)}*/}
|
||||
{currentStep === "mcp" && (
|
||||
<motion.div
|
||||
key="mcp"
|
||||
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
|
||||
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
<MCPForm />
|
||||
</motion.div>
|
||||
)}
|
||||
{currentStep === "extension" && (
|
||||
<motion.div
|
||||
key="extension"
|
||||
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
|
||||
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
<ExtensionForm />
|
||||
</motion.div>
|
||||
)}
|
||||
{currentStep === "welcome" && (
|
||||
<motion.div
|
||||
key="welcome"
|
||||
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
|
||||
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
<Welcome />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { getSession } from "@lib/auth"
|
||||
import { OnboardingForm } from "./onboarding-form"
|
||||
import { OnboardingProvider } from "./onboarding-context"
|
||||
import { OnboardingProgressBar } from "./progress-bar"
|
||||
import { redirect } from "next/navigation"
|
||||
import { OnboardingBackground } from "./onboarding-background"
|
||||
import { OnboardingWrapper } from "@/components/onboarding/onboarding-wrapper"
|
||||
import type { Metadata } from "next"
|
||||
export const metadata: Metadata = {
|
||||
title: "Welcome to Supermemory",
|
||||
description: "We're excited to have you on board.",
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const session = getSession()
|
||||
|
||||
if (!session) redirect("/login")
|
||||
|
||||
return (
|
||||
<OnboardingWrapper>
|
||||
<OnboardingProvider>
|
||||
<OnboardingProgressBar />
|
||||
<OnboardingBackground>
|
||||
<OnboardingForm />
|
||||
</OnboardingBackground>
|
||||
</OnboardingProvider>
|
||||
</OnboardingWrapper>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { motion } from "motion/react"
|
||||
import { useOnboarding } from "./onboarding-context"
|
||||
|
||||
export function OnboardingProgressBar() {
|
||||
const { currentVisibleStepNumber, totalSteps } = useOnboarding()
|
||||
|
||||
const progress =
|
||||
totalSteps === 0 ? 0 : (currentVisibleStepNumber / totalSteps) * 100
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-50 h-[2px] bg-zinc-200">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-[#06245B] via-[#1A5EA7] to-[#DDF5FF]"
|
||||
initial={{ width: "0%" }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { ArrowRightIcon } from "lucide-react"
|
||||
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export function Welcome() {
|
||||
const { markOnboardingCompleted } = useOnboardingStorage()
|
||||
const router = useRouter()
|
||||
|
||||
const handleGetStarted = () => {
|
||||
markOnboardingCompleted()
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center text-center w-full">
|
||||
<h1 className="text-white font-medium text-2xl md:text-4xl">
|
||||
Welcome to Supermemory
|
||||
</h1>
|
||||
<p className="text-white/80 text-lg md:text-2xl">
|
||||
We're excited to have you on board.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGetStarted}
|
||||
className="tracking-normal w-fit flex items-center justify-center text-lg md:text-2xl underline cursor-pointer font-medium text-white/80 hover:text-white transition-colors"
|
||||
>
|
||||
Get started
|
||||
<ArrowRightIcon className="size-4 ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
import { usePersistentChat } from "@/stores/chat"
|
||||
import { ArrowUp } from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { ProjectSelector } from "./project-selector"
|
||||
import { ModelSelector } from "./model-selector"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
export function ChatInput() {
|
||||
const [message, setMessage] = useState("")
|
||||
const [selectedModel, setSelectedModel] = useState<
|
||||
"gpt-5" | "claude-sonnet-4.5" | "gemini-2.5-pro"
|
||||
>("gemini-2.5-pro")
|
||||
const router = useRouter()
|
||||
const { setCurrentChatId } = usePersistentChat()
|
||||
const { user } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
const savedModel = localStorage.getItem("selectedModel") as
|
||||
| "gpt-5"
|
||||
| "claude-sonnet-4.5"
|
||||
| "gemini-2.5-pro"
|
||||
if (
|
||||
savedModel &&
|
||||
["gpt-5", "claude-sonnet-4.5", "gemini-2.5-pro"].includes(savedModel)
|
||||
) {
|
||||
setSelectedModel(savedModel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleModelChange = (
|
||||
modelId: "gpt-5" | "claude-sonnet-4.5" | "gemini-2.5-pro",
|
||||
) => {
|
||||
setSelectedModel(modelId)
|
||||
localStorage.setItem("selectedModel", modelId)
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
if (!message.trim()) return
|
||||
|
||||
const newChatId = generateId()
|
||||
|
||||
setCurrentChatId(newChatId)
|
||||
|
||||
sessionStorage.setItem(`chat-initial-${newChatId}`, message.trim())
|
||||
sessionStorage.setItem(`chat-model-${newChatId}`, selectedModel)
|
||||
|
||||
router.push(`/chat/${newChatId}`)
|
||||
|
||||
setMessage("")
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-4xl">
|
||||
<div className="text-start mb-4">
|
||||
<h2 className="text-3xl font-bold text-foreground">
|
||||
Welcome, <span className="text-primary">{user?.name}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<form
|
||||
className="flex flex-col items-end bg-card border border-border rounded-[14px] shadow-lg"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (!message.trim()) return
|
||||
handleSend()
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask your supermemory..."
|
||||
className="w-full text-foreground placeholder-muted-foreground rounded-md outline-none resize-none text-base leading-relaxed px-6 py-4 bg-transparent"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex items-center gap-2 w-full justify-between py-2 px-3 rounded-b-[14px]">
|
||||
<ProjectSelector />
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim()}
|
||||
className="text-primary-foreground border-0 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed !bg-primary h-8 w-8"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
motion,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
animate,
|
||||
useReducedMotion,
|
||||
} from "motion/react"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
|
||||
import * as flubber from "flubber"
|
||||
|
||||
type ChatLoaderProps = {
|
||||
size?: number
|
||||
colorClassName?: string
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LEFT_PATHS = [
|
||||
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H19.0324V9.02914L12.6984 9.02793Z", // 0
|
||||
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H14.149L12.699 9.02914L12.6984 9.02793Z", // 1
|
||||
"M12.6985 9.02793V3.52344H10.6524V9.49591C10.6524 10.1302 10.6516 10.7381 10.6532 11.0926L10.6524 16.4075H12.6985L12.6991 11.0926V9.02914L12.6985 9.02793Z", // 2
|
||||
"M14.5653 7.14453V7.1485H10.6528V8.0394C10.6528 9.25237 10.6512 10.4147 10.6542 11.0925L10.6528 11.0887H14.5653L14.5664 11.0925V7.14684L14.5653 7.14453Z", // 3
|
||||
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 4
|
||||
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 5
|
||||
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 6
|
||||
"M19.0304 6.51562V6.51963H15.0776V7.41971C15.0776 8.64517 15.076 9.81944 15.0791 10.5043L15.0776 10.5004H19.0304L19.0315 10.5043V6.51796L19.0304 6.51562Z", // 7
|
||||
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 8
|
||||
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 9
|
||||
"M19.0304 6.51562V6.51963H15.0776V7.41971C15.0776 8.64517 15.076 9.81944 15.0791 10.5043L15.0776 10.5004H19.0304L19.0315 10.5043V6.51796L19.0304 6.51562Z", // 10
|
||||
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 11
|
||||
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 12
|
||||
"M19.0304 6.51562V6.51963H15.0776V7.41971C15.0776 8.64517 15.076 9.81944 15.0791 10.5043L15.0776 10.5004H19.0304L19.0315 10.5043V6.51796L19.0304 6.51562Z", // 13
|
||||
"M19.0304 8.51562V8.51963H15.0776V9.41971C15.0776 10.6452 15.076 11.8194 15.0791 12.5043L15.0776 12.5004H19.0304L19.0315 12.5043V8.51796L19.0304 8.51562Z", // 14
|
||||
"M14.5653 7.14453V7.1485H10.6528V8.0394C10.6528 9.25237 10.6512 10.4147 10.6542 11.0925L10.6528 11.0887H14.5653L14.5664 11.0925V7.14684L14.5653 7.14453Z", // 15
|
||||
"M12.6985 9.02793V3.52344H10.6524V9.49591C10.6524 10.1302 10.6516 10.7381 10.6532 11.0926L10.6524 16.4075H12.6985L12.6991 11.0926V9.02914L12.6985 9.02793Z", // 16
|
||||
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H14.149L12.699 9.02914L12.6984 9.02793Z", // 17
|
||||
"M12.6984 9.02793V3.52344H10.6523V9.49591C10.6523 10.1302 10.9028 10.7395 11.3479 11.1883L16.5188 16.4032L17.9655 14.9441L14.1463 11.0926H19.0324V9.02914L12.6984 9.02793Z", // 18
|
||||
]
|
||||
|
||||
const MIDDLE_PATHS = [
|
||||
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 0
|
||||
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 1
|
||||
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 2
|
||||
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 3
|
||||
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 4
|
||||
"M1.96875 6.50391L1.96904 10.4909L1.9702 10.4833H5.92302V9.58685C5.92302 8.3613 5.92302 7.73358 5.92302 6.50459L1.96875 6.50391Z", // 5
|
||||
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 6
|
||||
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 7
|
||||
"M1.96875 6.50391L1.96904 10.4909L1.9702 10.4833H5.92302V9.58685C5.92302 8.3613 5.92302 7.73358 5.92302 6.50459L1.96875 6.50391Z", // 8
|
||||
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 9
|
||||
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 10
|
||||
"M1.96875 6.50391L1.96904 10.4909L1.9702 10.4833H5.92302V9.58685C5.92302 8.3613 5.92302 7.73358 5.92302 6.50459L1.96875 6.50391Z", // 11
|
||||
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 12
|
||||
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 13
|
||||
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 14
|
||||
"M1.96875 8.50391L1.96904 12.4909L1.9702 12.4833H5.92302V11.5868C5.92302 10.3613 5.92302 9.73358 5.92302 8.50459L1.96875 8.50391Z", // 15
|
||||
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 16
|
||||
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 17
|
||||
"M6.60156 9.46875L6.60171 11.5326L6.60231 11.5286H8.64841V11.0646C8.64841 10.4302 8.64841 10.1053 8.64841 9.46911L6.60156 9.46875Z", // 18
|
||||
]
|
||||
|
||||
const RIGHT_PATHS = [
|
||||
"M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z", // 0
|
||||
"M3.03516 6.05861L6.85434 9.91021H6.85044L8.30044 11.9737L8.30104 17.4781H10.3471V11.5057C10.3471 10.8713 10.0967 10.2621 9.65162 9.81327L4.48188 4.59961L3.03516 6.05861Z", // 1
|
||||
"M8.30024 4.58789L8.2998 9.91036L8.30039 11.9738L8.30099 17.4783H10.3471V11.5058C10.3471 10.8714 10.3471 10.5465 10.3471 9.91036V4.58789H8.30024Z", // 2
|
||||
"M6.42383 9.9082L6.42412 13.8633L6.42527 13.8557H10.3464V12.9664C10.3464 11.7507 10.3464 11.128 10.3464 9.90888L6.42383 9.9082Z", // 3
|
||||
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 4
|
||||
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 5
|
||||
"M8.52051 6.50391L8.5208 10.4909L8.52196 10.4833H12.4748V9.58685C12.4748 8.3613 12.4748 7.73358 12.4748 6.50459L8.52051 6.50391Z", // 6
|
||||
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 7
|
||||
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 8
|
||||
"M8.52051 6.50391L8.5208 10.4909L8.52196 10.4833H12.4748V9.58685C12.4748 8.3613 12.4748 7.73358 12.4748 6.50459L8.52051 6.50391Z", // 9
|
||||
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 10
|
||||
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 11
|
||||
"M8.52051 6.50391L8.5208 10.4909L8.52196 10.4833H12.4748V9.58685C12.4748 8.3613 12.4748 7.73358 12.4748 6.50459L8.52051 6.50391Z", // 12
|
||||
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 13
|
||||
"M8.52051 8.50391L8.5208 12.4909L8.52196 12.4833H12.4748V11.5868C12.4748 10.3613 12.4748 9.73358 12.4748 8.50459L8.52051 8.50391Z", // 14
|
||||
"M6.42383 9.9082L6.42412 13.8633L6.42527 13.8557H10.3464V12.9664C10.3464 11.7507 10.3464 11.128 10.3464 9.90888L6.42383 9.9082Z", // 15
|
||||
"M8.30024 4.58789L8.2998 9.91036L8.30039 11.9738L8.30099 17.4783H10.3471V11.5058C10.3471 10.8714 10.3471 10.5465 10.3471 9.91036V4.58789H8.30024Z", // 16
|
||||
"M3.03516 6.05861L6.85434 9.91021H6.85044L8.30044 11.9737L8.30104 17.4781H10.3471V11.5057C10.3471 10.8713 10.0967 10.2621 9.65162 9.81327L4.48188 4.59961L3.03516 6.05861Z", // 17
|
||||
"M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z", // 18
|
||||
]
|
||||
|
||||
export function ChatLoader({
|
||||
size = 80,
|
||||
colorClassName = "text-white",
|
||||
label = "",
|
||||
className = "",
|
||||
}: ChatLoaderProps) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
const t = useMotionValue(0)
|
||||
|
||||
const loopDuration = 3.6 // full cycle
|
||||
|
||||
const makeMultiInterp = (paths: string[]) => {
|
||||
if (!paths || paths.length === 0) {
|
||||
return (_t: number) => ""
|
||||
}
|
||||
|
||||
if (paths.length === 1) {
|
||||
const only = paths[0]
|
||||
return (_t: number) => only
|
||||
}
|
||||
|
||||
const options: { maxSegmentLength?: number } = { maxSegmentLength: 0.5 }
|
||||
|
||||
const interpolateFn = (flubber as any).interpolate as (
|
||||
from: string,
|
||||
to: string,
|
||||
options?: { maxSegmentLength?: number },
|
||||
) => (t: number) => string
|
||||
|
||||
const segmentInterpolators: Array<(t: number) => string> = []
|
||||
|
||||
for (let i = 0; i < paths.length - 1; i++) {
|
||||
segmentInterpolators.push(
|
||||
interpolateFn(paths[i]!, paths[i + 1]!, options),
|
||||
)
|
||||
}
|
||||
|
||||
const segmentCount = segmentInterpolators.length
|
||||
|
||||
return (t: number) => {
|
||||
if (t <= 0) return paths[0] || ""
|
||||
if (t >= 1) return paths[paths.length - 1] || ""
|
||||
|
||||
const scaled = t * segmentCount
|
||||
const segIndex = Math.min(Math.floor(scaled), segmentCount - 1)
|
||||
const localT = scaled - segIndex
|
||||
|
||||
return segmentInterpolators[segIndex]!(localT)
|
||||
}
|
||||
}
|
||||
|
||||
const leftInterp = useMemo(
|
||||
() => (LEFT_PATHS.length ? makeMultiInterp(LEFT_PATHS) : null),
|
||||
[],
|
||||
)
|
||||
|
||||
const middleInterp = useMemo(
|
||||
() => (MIDDLE_PATHS.length ? makeMultiInterp(MIDDLE_PATHS) : null),
|
||||
[],
|
||||
)
|
||||
|
||||
const rightInterp = useMemo(
|
||||
() => (RIGHT_PATHS.length ? makeMultiInterp(RIGHT_PATHS) : null),
|
||||
[],
|
||||
)
|
||||
|
||||
// Turn scalar t into d strings
|
||||
const leftD = useTransform(t, (v) =>
|
||||
leftInterp ? leftInterp(v) : LEFT_PATHS[0] || "",
|
||||
)
|
||||
|
||||
const middleD = useTransform(t, (v) =>
|
||||
middleInterp ? middleInterp(v) : MIDDLE_PATHS[0] || "",
|
||||
)
|
||||
|
||||
const rightD = useTransform(t, (v) =>
|
||||
rightInterp ? rightInterp(v) : RIGHT_PATHS[0] || "",
|
||||
)
|
||||
|
||||
const middleOpacity = useTransform(t, (v) => {
|
||||
if (v < 0.2) return 0
|
||||
if (v < 0.3) return (v - 0.2) / 0.1 // fade in
|
||||
if (v < 0.8) return 1
|
||||
if (v < 0.9) return 1 - (v - 0.8) / 0.1 // fade out
|
||||
return 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion) {
|
||||
t.set(0)
|
||||
return
|
||||
}
|
||||
|
||||
const controls = animate(t, [0, 1], {
|
||||
duration: loopDuration,
|
||||
ease: "linear",
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
repeatType: "loop",
|
||||
repeatDelay: 0.4, // ⬅️ wait 2 seconds at the end before restarting
|
||||
})
|
||||
|
||||
return () => controls.stop()
|
||||
}, [t, prefersReducedMotion, loopDuration])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={`inline-flex flex-col items-center gap-2 ${className}`}
|
||||
style={{ width: size }}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 36 20"
|
||||
width={size}
|
||||
height={(size * 20) / 36}
|
||||
className={colorClassName}
|
||||
>
|
||||
{leftInterp && <motion.path d={leftD as any} fill="currentColor" />}
|
||||
{rightInterp && <motion.path d={rightD as any} fill="currentColor" />}
|
||||
{middleInterp && (
|
||||
<motion.path
|
||||
d={middleD as any}
|
||||
fill="currentColor"
|
||||
style={{ opacity: middleOpacity as any }}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{label && (
|
||||
<span
|
||||
className="text-xs font-medium text-slate-400"
|
||||
style={{ fontSize: size * 0.18 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
Bookmark,
|
||||
Zap,
|
||||
CircleX,
|
||||
Users,
|
||||
Lock,
|
||||
ChromeIcon,
|
||||
TwitterIcon,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import Image from "next/image"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
export function ChromeExtensionButton() {
|
||||
const [isExtensionInstalled, setIsExtensionInstalled] = useState(false)
|
||||
const [isChecking, setIsChecking] = useState(true)
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed =
|
||||
localStorage.getItem("chrome-extension-dismissed") === "true"
|
||||
const minimized =
|
||||
localStorage.getItem("chrome-extension-minimized") === "true"
|
||||
|
||||
setIsDismissed(dismissed)
|
||||
setIsMinimized(minimized)
|
||||
|
||||
const checkExtension = () => {
|
||||
const message = { action: "check-extension" }
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setIsExtensionInstalled(false)
|
||||
setIsChecking(false)
|
||||
// Auto-minimize after 3 seconds if extension is not installed and not dismissed
|
||||
if (!dismissed && !minimized) {
|
||||
setTimeout(() => {
|
||||
setIsMinimized(true)
|
||||
localStorage.setItem("chrome-extension-minimized", "true")
|
||||
}, 3000)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.action === "extension-detected") {
|
||||
clearTimeout(timeout)
|
||||
setIsExtensionInstalled(true)
|
||||
setIsChecking(false)
|
||||
window.removeEventListener("message", handleMessage)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
|
||||
window.postMessage(message, "*")
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
window.removeEventListener("message", handleMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if (!dismissed) {
|
||||
checkExtension()
|
||||
} else {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInstall = () => {
|
||||
analytics.extensionInstallClicked()
|
||||
window.open(
|
||||
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem("chrome-extension-dismissed", "true")
|
||||
localStorage.removeItem("chrome-extension-minimized")
|
||||
setIsDismissed(true)
|
||||
}
|
||||
|
||||
// Don't show if extension is installed, checking, dismissed, or on mobile
|
||||
if (isExtensionInstalled || isChecking || isDismissed || isMobile) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed bottom-4 right-4 z-50"
|
||||
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
<div
|
||||
className={`bg-background/95 backdrop-blur-md shadow-xl ${
|
||||
isMinimized
|
||||
? "flex items-center gap-1 rounded-full"
|
||||
: "max-w-md w-90 rounded-2xl"
|
||||
}`}
|
||||
>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-4 text-white bg-cover bg-center">
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{
|
||||
backgroundImage: "url('/images/extension-bg.png')",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<h1 className="text-2xl font-bold mb-1">
|
||||
supermemory extension
|
||||
</h1>
|
||||
<p className="text-sm opacity-90">
|
||||
your second brain for the web.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-2 pb-4 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<TwitterIcon className="fill-blue-500 dark:fill-blue-400 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-foreground">
|
||||
Twitter Imports
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Import your twitter timeline & save tweets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-orange-50 dark:bg-orange-950 border border-orange-200 dark:border-orange-800 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Bookmark className="w-5 h-5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-foreground">
|
||||
Save All Bookmarks
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Instantly save any webpage to your memory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-foreground">
|
||||
Charge Empty Memory
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically capture & organize your browsing history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-4">
|
||||
<Button
|
||||
onClick={handleInstall}
|
||||
className="w-full bg-background border border-primary text-foreground hover:bg-accent font-semibold rounded-lg h-10 flex items-center justify-center gap-3"
|
||||
>
|
||||
<div className="w-6 h-6 bg-[#686CFD] rounded-full flex items-center justify-center">
|
||||
<Image
|
||||
src="/images/extension-logo.png"
|
||||
alt="Extension Logo"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</div>
|
||||
Add to Chrome - It's Free
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-4 flex items-center justify-center gap-6 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
<span>4K+ users</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span>Privacy first</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{isMinimized && (
|
||||
<div className="relative flex items-center w-full group">
|
||||
<Button
|
||||
size={"lg"}
|
||||
onClick={handleInstall}
|
||||
className="text-xs rounded-full"
|
||||
style={{
|
||||
backgroundImage: "url('/images/extension-bg.png')",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
>
|
||||
<ChromeIcon className="h-3 w-3 mr-1" />
|
||||
Get Extension
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-[-16px] right-[-12px] h-6 w-6 p-0 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-75 transition-opacity duration-200"
|
||||
>
|
||||
<CircleX className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent } from "@repo/ui/components/card"
|
||||
import { Badge } from "@repo/ui/components/badge"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui/components/alert-dialog"
|
||||
import { ExternalLink, FileText, Brain, Trash2 } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { colors } from "@repo/ui/memory-graph/constants"
|
||||
import { getPastelBackgroundColor } from "../memories-utils"
|
||||
|
||||
interface GoogleDocsCardProps {
|
||||
title: string
|
||||
url: string | null | undefined
|
||||
description?: string | null
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
onDelete?: () => void
|
||||
showExternalLink?: boolean
|
||||
activeMemories?: Array<{ id: string; isForgotten?: boolean }>
|
||||
lastModified?: string | Date
|
||||
}
|
||||
|
||||
export const GoogleDocsCard = ({
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
className,
|
||||
onClick,
|
||||
onDelete,
|
||||
showExternalLink = true,
|
||||
activeMemories,
|
||||
lastModified,
|
||||
}: GoogleDocsCardProps) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (!isDialogOpen) {
|
||||
if (onClick) {
|
||||
onClick()
|
||||
} else if (url) {
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (url) {
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"cursor-pointer transition-all hover:shadow-md group overflow-hidden relative py-4",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
style={{
|
||||
backgroundColor: getPastelBackgroundColor(url || title || "googledocs"),
|
||||
}}
|
||||
>
|
||||
{onDelete && (
|
||||
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-red-500/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
color: colors.text.muted,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Document</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this document and all its
|
||||
related memories? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 87.3 78"
|
||||
aria-label="Google Docs"
|
||||
>
|
||||
<title>Google Docs</title>
|
||||
<path
|
||||
fill="#0066da"
|
||||
d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3L27.5 53H0c0 1.55.4 3.1 1.2 4.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#00ac47"
|
||||
d="M43.65 25 29.9 1.2c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44A9.06 9.06 0 0 0 0 53h27.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#ea4335"
|
||||
d="M73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75L86.1 57.5c.8-1.4 1.2-2.95 1.2-4.5H59.798l5.852 11.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#00832d"
|
||||
d="M43.65 25 57.4 1.2C56.05.4 54.5 0 52.9 0H34.4c-1.6 0-3.15.45-4.5 1.2z"
|
||||
/>
|
||||
<path
|
||||
fill="#2684fc"
|
||||
d="M59.8 53H27.5L13.75 76.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
|
||||
/>
|
||||
<path
|
||||
fill="#ffba00"
|
||||
d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3L43.65 25 59.8 53h27.45c0-1.55-.4-3.1-1.2-4.5z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Google Docs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{showExternalLink && (
|
||||
<button
|
||||
onClick={handleExternalLinkClick}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-white/10 flex-shrink-0"
|
||||
type="button"
|
||||
aria-label="Open in Google Docs"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-sm line-clamp-2 leading-tight flex-1">
|
||||
{title || "Untitled Document"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="w-3 h-3" />
|
||||
<span>Google Workspace</span>
|
||||
</div>
|
||||
{lastModified && (
|
||||
<span className="truncate">
|
||||
Modified{" "}
|
||||
{lastModified instanceof Date
|
||||
? lastModified.toLocaleDateString()
|
||||
: new Date(lastModified).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeMemories && activeMemories.length > 0 && (
|
||||
<div>
|
||||
<Badge
|
||||
className="text-xs text-accent-foreground"
|
||||
style={{
|
||||
backgroundColor: colors.memory.secondary,
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<Brain className="w-3 h-3 mr-1" />
|
||||
{activeMemories.length}{" "}
|
||||
{activeMemories.length === 1 ? "memory" : "memories"}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
GoogleDocsCard.displayName = "GoogleDocsCard"
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
import { Badge } from "@repo/ui/components/badge"
|
||||
import { Card, CardContent, CardHeader } from "@repo/ui/components/card"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui/components/alert-dialog"
|
||||
|
||||
import { colors } from "@repo/ui/memory-graph/constants"
|
||||
import { Brain, ExternalLink, Trash2 } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
formatDate,
|
||||
getPastelBackgroundColor,
|
||||
getSourceUrl,
|
||||
} from "../memories-utils"
|
||||
import { MCPIcon } from "../menu"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
interface NoteCardProps {
|
||||
document: DocumentWithMemories
|
||||
width: number
|
||||
activeMemories: Array<{ id: string; isForgotten?: boolean }>
|
||||
forgottenMemories: Array<{ id: string; isForgotten?: boolean }>
|
||||
onOpenDetails: (document: DocumentWithMemories) => void
|
||||
onDelete: (document: DocumentWithMemories) => void
|
||||
}
|
||||
|
||||
export const NoteCard = ({
|
||||
document,
|
||||
width,
|
||||
activeMemories,
|
||||
forgottenMemories,
|
||||
onOpenDetails,
|
||||
onDelete,
|
||||
}: NoteCardProps) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="w-full p-4 transition-all cursor-pointer group relative overflow-hidden gap-2 shadow-xs"
|
||||
onClick={() => {
|
||||
if (!isDialogOpen) {
|
||||
analytics.documentCardClicked()
|
||||
onOpenDetails(document)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: getPastelBackgroundColor(
|
||||
document.id || document.title || "note",
|
||||
),
|
||||
width: width,
|
||||
}}
|
||||
>
|
||||
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 group-hover:cursor-pointer transition-opacity p-1.5 rounded-md hover:bg-red-500/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
color: colors.text.muted,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Document</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this document and all its related
|
||||
memories? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(document)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<CardHeader className="relative z-10 px-0 pb-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium line-clamp-1",
|
||||
document.url ? "max-w-[190px]" : "max-w-[200px]",
|
||||
)}
|
||||
>
|
||||
{document.title || "Untitled Document"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{document.url && (
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const sourceUrl = getSourceUrl(document)
|
||||
window.open(sourceUrl ?? undefined, "_blank")
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
color: colors.text.secondary,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span>{formatDate(document.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10 px-0">
|
||||
{document.content && (
|
||||
<p
|
||||
className="text-xs line-clamp-6"
|
||||
style={{ color: colors.text.muted }}
|
||||
>
|
||||
{document.content}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{activeMemories.length > 0 && (
|
||||
<Badge
|
||||
className="text-xs text-accent-foreground mt-2"
|
||||
style={{
|
||||
backgroundColor: colors.memory.secondary,
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<Brain className="w-3 h-3 mr-1" />
|
||||
{activeMemories.length}{" "}
|
||||
{activeMemories.length === 1 ? "memory" : "memories"}
|
||||
</Badge>
|
||||
)}
|
||||
{forgottenMemories.length > 0 && (
|
||||
<Badge
|
||||
className="text-xs mt-2"
|
||||
style={{
|
||||
borderColor: "rgba(255, 255, 255, 0.2)",
|
||||
color: colors.text.muted,
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{forgottenMemories.length} forgotten
|
||||
</Badge>
|
||||
)}
|
||||
{document.source === "mcp" && (
|
||||
<Badge variant="outline" className="mt-2">
|
||||
<MCPIcon className="w-3 h-3 mr-1" />
|
||||
MCP
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
NoteCard.displayName = "NoteCard"
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
import { Suspense, useState } from "react"
|
||||
import type { Tweet } from "react-tweet/api"
|
||||
import {
|
||||
type TwitterComponents,
|
||||
TweetContainer,
|
||||
TweetHeader,
|
||||
TweetInReplyTo,
|
||||
TweetBody,
|
||||
TweetMedia,
|
||||
TweetInfo,
|
||||
QuotedTweet,
|
||||
TweetNotFound,
|
||||
TweetSkeleton,
|
||||
enrichTweet,
|
||||
} from "react-tweet"
|
||||
import { Badge } from "@repo/ui/components/badge"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui/components/alert-dialog"
|
||||
import { Brain, Trash2 } from "lucide-react"
|
||||
import { colors } from "@repo/ui/memory-graph/constants"
|
||||
import { getPastelBackgroundColor } from "../memories-utils"
|
||||
|
||||
type MyTweetProps = {
|
||||
tweet: Tweet
|
||||
components?: TwitterComponents
|
||||
}
|
||||
|
||||
const MyTweet = ({ tweet: t, components }: MyTweetProps) => {
|
||||
const parsedTweet = typeof t === "string" ? JSON.parse(t) : t
|
||||
const tweet = enrichTweet(parsedTweet)
|
||||
return (
|
||||
<TweetContainer className="pb-5">
|
||||
<TweetHeader tweet={tweet} components={components} />
|
||||
{tweet.in_reply_to_status_id_str && <TweetInReplyTo tweet={tweet} />}
|
||||
<TweetBody tweet={tweet} />
|
||||
{tweet.mediaDetails?.length ? (
|
||||
<TweetMedia tweet={tweet} components={components} />
|
||||
) : null}
|
||||
{tweet.quoted_tweet && <QuotedTweet tweet={tweet.quoted_tweet} />}
|
||||
<TweetInfo tweet={tweet} />
|
||||
</TweetContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TweetContent = ({
|
||||
components,
|
||||
tweet,
|
||||
}: {
|
||||
components: TwitterComponents
|
||||
tweet: Tweet
|
||||
}) => {
|
||||
if (!tweet) {
|
||||
const NotFound = components?.TweetNotFound || TweetNotFound
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return <MyTweet tweet={tweet} components={components} />
|
||||
}
|
||||
|
||||
const CustomTweet = ({
|
||||
fallback = <TweetSkeleton />,
|
||||
...props
|
||||
}: {
|
||||
components: TwitterComponents
|
||||
tweet: Tweet
|
||||
fallback?: React.ReactNode
|
||||
}) => (
|
||||
<Suspense fallback={fallback}>
|
||||
<TweetContent {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
export const TweetCard = ({
|
||||
data,
|
||||
activeMemories,
|
||||
onDelete,
|
||||
}: {
|
||||
data: Tweet
|
||||
activeMemories?: Array<{ id: string; isForgotten?: boolean }>
|
||||
onDelete?: () => void
|
||||
}) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative transition-all group"
|
||||
style={{
|
||||
backgroundColor: getPastelBackgroundColor(data.id_str || "tweet"),
|
||||
}}
|
||||
>
|
||||
<CustomTweet components={{}} tweet={data} />
|
||||
|
||||
{onDelete && (
|
||||
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-red-500/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
color: colors.text.muted,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Document</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this document and all its
|
||||
related memories? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{activeMemories && activeMemories.length > 0 && (
|
||||
<div className="absolute bottom-2 left-4 z-10">
|
||||
<Badge
|
||||
className="text-xs text-accent-foreground"
|
||||
style={{
|
||||
backgroundColor: colors.memory.secondary,
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<Brain className="w-3 h-3 mr-1" />
|
||||
{activeMemories.length}{" "}
|
||||
{activeMemories.length === 1 ? "memory" : "memories"}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
TweetCard.displayName = "TweetCard"
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { Card, CardContent } from "@repo/ui/components/card"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui/components/alert-dialog"
|
||||
import { ExternalLink, Trash2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { getPastelBackgroundColor } from "../memories-utils"
|
||||
import { colors } from "@repo/ui/memory-graph/constants"
|
||||
|
||||
interface WebsiteCardProps {
|
||||
title: string
|
||||
url: string
|
||||
image?: string
|
||||
description?: string
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
onOpenDetails?: () => void
|
||||
onDelete?: () => void
|
||||
showExternalLink?: boolean
|
||||
}
|
||||
|
||||
export const WebsiteCard = ({
|
||||
title,
|
||||
url,
|
||||
image,
|
||||
description,
|
||||
className,
|
||||
onClick,
|
||||
onOpenDetails,
|
||||
onDelete,
|
||||
showExternalLink = true,
|
||||
}: WebsiteCardProps) => {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (!isDialogOpen) {
|
||||
if (onClick) {
|
||||
onClick()
|
||||
} else if (onOpenDetails) {
|
||||
onOpenDetails()
|
||||
} else {
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
const hostname = (() => {
|
||||
try {
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
})()
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"cursor-pointer transition-all hover:shadow-md group overflow-hidden py-0 relative",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
style={{
|
||||
backgroundColor: getPastelBackgroundColor(url || title || "website"),
|
||||
}}
|
||||
>
|
||||
{onDelete && (
|
||||
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-red-500/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
color: colors.text.muted,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Document</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this document and all its
|
||||
related memories? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
<CardContent className="p-0">
|
||||
{image && !imageError && (
|
||||
<div className="relative h-38 bg-gray-100 overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={title || "Website preview"}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
onError={() => setImageError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 py-2 space-y-2">
|
||||
<div className="font-semibold text-sm line-clamp-2 leading-tight flex items-center justify-between">
|
||||
{title}
|
||||
<div className="flex items-center gap-1">
|
||||
{showExternalLink && (
|
||||
<button
|
||||
onClick={handleExternalLinkClick}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-100 flex-shrink-0"
|
||||
type="button"
|
||||
aria-label="Open in new tab"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground truncate">{hostname}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
WebsiteCard.displayName = "WebsiteCard"
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@repo/ui/components/dialog"
|
||||
import { Input } from "@repo/ui/components/input"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useState } from "react"
|
||||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
|
||||
interface CreateProjectDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CreateProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CreateProjectDialogProps) {
|
||||
const [projectName, setProjectName] = useState("")
|
||||
const { createProjectMutation } = useProjectMutations()
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false)
|
||||
setProjectName("")
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (projectName.trim()) {
|
||||
createProjectMutation.mutate(
|
||||
{ name: projectName },
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleClose()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="sm:max-w-2xl backdrop-blur-xl">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Give your project a unique name
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col gap-2"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Input
|
||||
id="projectName"
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && projectName.trim()) {
|
||||
handleCreate()
|
||||
}
|
||||
}}
|
||||
placeholder="My Awesome Project"
|
||||
value={projectName}
|
||||
/>
|
||||
<p className="text-xs">
|
||||
This will help you organize your memories
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
createProjectMutation.isPending || !projectName.trim()
|
||||
}
|
||||
onClick={handleCreate}
|
||||
type="button"
|
||||
>
|
||||
{createProjectMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Project"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { motion } from "motion/react"
|
||||
|
||||
interface GlassMenuEffectProps {
|
||||
rounded?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GlassMenuEffect({
|
||||
rounded = "rounded-[28px]",
|
||||
className = "",
|
||||
}: GlassMenuEffectProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={`absolute inset-0 ${className}`}
|
||||
layout
|
||||
style={{
|
||||
transform: "translateZ(0)",
|
||||
willChange: "auto",
|
||||
}}
|
||||
transition={{
|
||||
layout: {
|
||||
type: "spring",
|
||||
damping: 35,
|
||||
stiffness: 180,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 backdrop-blur-md bg-white/5 border border-white/10 ${rounded}`}
|
||||
style={{
|
||||
transform: "translateZ(0)",
|
||||
willChange: "transform",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useState } from "react"
|
||||
import { MemoryGraph } from "@/components/new/memory-graph/memory-graph"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { ConnectAIModal } from "@/components/connect-ai-modal"
|
||||
import { AddMemoryView } from "@/components/views/add-memory"
|
||||
import { useChatOpen, useProject, useGraphModal } from "@/stores"
|
||||
import { useGraphHighlights } from "@/stores/highlights"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
/**
|
||||
* Graph Dialog component
|
||||
*/
|
||||
export function GraphDialog() {
|
||||
const { user } = useAuth()
|
||||
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
|
||||
const { selectedProject } = useProject()
|
||||
const { isOpen: isChatOpen } = useChatOpen()
|
||||
const { isOpen: showGraphModal, setIsOpen: setShowGraphModal } =
|
||||
useGraphModal()
|
||||
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
|
||||
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
if (!user) return null
|
||||
|
||||
// Convert selectedProject to containerTags array
|
||||
const containerTags = selectedProject ? [selectedProject] : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={showGraphModal} onOpenChange={setShowGraphModal}>
|
||||
<DialogContent
|
||||
className="w-[95vw] h-[95vh] p-0 max-w-6xl sm:max-w-6xl"
|
||||
showCloseButton={true}
|
||||
>
|
||||
<DialogTitle className="sr-only">Memory Graph</DialogTitle>
|
||||
<div className="w-full h-full">
|
||||
<MemoryGraph
|
||||
containerTags={containerTags}
|
||||
variant="console"
|
||||
highlightDocumentIds={allHighlightDocumentIds}
|
||||
highlightsVisible={isChatOpen}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{!isMobile ? (
|
||||
<ConnectAIModal
|
||||
onOpenChange={setShowConnectAIModal}
|
||||
open={showConnectAIModal}
|
||||
>
|
||||
<div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6">
|
||||
<div className="relative z-10 text-slate-200 text-center">
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowAddMemoryView(true)
|
||||
setShowConnectAIModal(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Add your first memory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ConnectAIModal>
|
||||
) : (
|
||||
<div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6">
|
||||
<div className="relative z-10 text-slate-200 text-center">
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowAddMemoryView(true)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Add your first memory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MemoryGraph>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{showAddMemoryView && (
|
||||
<AddMemoryView
|
||||
initialTab="note"
|
||||
onClose={() => setShowAddMemoryView(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,446 +0,0 @@
|
|||
import { Button } from "@ui/components/button"
|
||||
import { Logo, LogoFull } from "@ui/assets/Logo"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
MoonIcon,
|
||||
Plus,
|
||||
SunIcon,
|
||||
MonitorIcon,
|
||||
User,
|
||||
CreditCard,
|
||||
Chrome,
|
||||
LogOut,
|
||||
WaypointsIcon,
|
||||
Gauge,
|
||||
HistoryIcon,
|
||||
Trash2,
|
||||
X,
|
||||
Check,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { DropdownMenuItem } from "@ui/components/dropdown-menu"
|
||||
import { DropdownMenu } from "@ui/components/dropdown-menu"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { ConnectAIModal } from "./connect-ai-modal"
|
||||
import { useTheme } from "next-themes"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { MCPIcon } from "./menu"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useGraphModal, usePersistentChat, useProject } from "@/stores"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@ui/components/dialog"
|
||||
import { ScrollArea } from "@ui/components/scroll-area"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
|
||||
export function Header({ onAddMemory }: { onAddMemory?: () => void }) {
|
||||
const { user } = useAuth()
|
||||
const searchParams = useSearchParams()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const router = useRouter()
|
||||
const { setIsOpen: setGraphModalOpen } = useGraphModal()
|
||||
const {
|
||||
getCurrentChat,
|
||||
conversations,
|
||||
currentChatId,
|
||||
setCurrentChatId,
|
||||
deleteConversation,
|
||||
} = usePersistentChat()
|
||||
const { selectedProject } = useProject()
|
||||
const pathname = usePathname()
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
const [mcpInitialClient, setMcpInitialClient] = useState<"mcp-url" | null>(
|
||||
null,
|
||||
)
|
||||
const [mcpInitialTab, setMcpInitialTab] = useState<
|
||||
"oneClick" | "manual" | null
|
||||
>(null)
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...conversations].sort((a, b) =>
|
||||
a.lastUpdated < b.lastUpdated ? 1 : -1,
|
||||
)
|
||||
}, [conversations])
|
||||
|
||||
useEffect(() => {
|
||||
const mcpParam = searchParams.get("mcp")
|
||||
if (mcpParam === "manual") {
|
||||
setMcpInitialClient("mcp-url")
|
||||
setMcpInitialTab("manual")
|
||||
setMcpModalOpen(true)
|
||||
const newSearchParams = new URLSearchParams(searchParams.toString())
|
||||
newSearchParams.delete("mcp")
|
||||
const newUrl = `${
|
||||
window.location.pathname
|
||||
}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`
|
||||
window.history.replaceState({}, "", newUrl)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
function handleNewChat() {
|
||||
analytics.newChatStarted()
|
||||
const newId = generateId()
|
||||
setCurrentChatId(newId)
|
||||
router.push(`/chat/${newId}`)
|
||||
setIsDialogOpen(false)
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoString: string): string {
|
||||
return formatDistanceToNow(new Date(isoString), { addSuffix: true })
|
||||
}
|
||||
|
||||
const handleSignOut = () => {
|
||||
analytics.userSignedOut()
|
||||
authClient.signOut()
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full p-3 md:p-4">
|
||||
<div className="flex items-center gap-2 md:gap-3 justify-between w-full">
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<Link
|
||||
className="pointer-events-auto"
|
||||
href={
|
||||
process.env.NODE_ENV === "development"
|
||||
? "http://localhost:3000"
|
||||
: "https://app.supermemory.ai"
|
||||
}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{getCurrentChat()?.title && pathname.includes("/chat") ? (
|
||||
<div className="flex items-center gap-2 md:gap-4 min-w-0 max-w-[200px] md:max-w-md">
|
||||
<Logo className="h-6 block text-foreground shrink-0" />
|
||||
<span className="truncate text-sm md:text-base">
|
||||
{getCurrentChat()?.title}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<LogoFull className="h-8 hidden md:block" />
|
||||
<Logo className="h-8 md:hidden text-foreground" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onAddMemory}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Add Memory</span>
|
||||
<span className="hidden md:inline bg-secondary-foreground/10 rounded-md px-2 py-[2px] text-xs">
|
||||
c
|
||||
</span>
|
||||
</Button>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDialogOpen(open)
|
||||
if (open) {
|
||||
analytics.chatHistoryViewed()
|
||||
}
|
||||
if (!open) {
|
||||
setConfirmingDeleteId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<HistoryIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Chat History</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader className="pb-4 border-b rounded-t-lg">
|
||||
<DialogTitle className="">Conversations</DialogTitle>
|
||||
<DialogDescription>
|
||||
Project{" "}
|
||||
<span className="font-mono font-medium">
|
||||
{selectedProject}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-96">
|
||||
<div className="flex flex-col gap-1">
|
||||
{sorted.map((c) => {
|
||||
const isActive = c.id === currentChatId
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCurrentChatId(c.id)
|
||||
router.push(`/chat/${c.id}`)
|
||||
setIsDialogOpen(false)
|
||||
setConfirmingDeleteId(null)
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-md px-3 py-2 outline-none w-full text-left",
|
||||
"transition-colors",
|
||||
isActive ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
isActive ? "text-foreground" : undefined,
|
||||
)}
|
||||
>
|
||||
{c.title || "Untitled Chat"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Last updated {formatRelativeTime(c.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
{confirmingDeleteId === c.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
analytics.chatDeleted()
|
||||
deleteConversation(c.id)
|
||||
setConfirmingDeleteId(null)
|
||||
}}
|
||||
className="bg-red-500 text-white hover:bg-red-600 hover:text-white"
|
||||
aria-label="Confirm delete"
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmingDeleteId(null)
|
||||
}}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmingDeleteId(c.id)
|
||||
}}
|
||||
aria-label="Delete conversation"
|
||||
>
|
||||
<Trash2 className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{sorted.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground px-3 py-2">
|
||||
No conversations yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-dashed"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus className="size-4 mr-1" /> New Conversation
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setGraphModalOpen(true)}
|
||||
>
|
||||
<WaypointsIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Graph View</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<ConnectAIModal
|
||||
open={mcpModalOpen}
|
||||
onOpenChange={setMcpModalOpen}
|
||||
openInitialClient={mcpInitialClient}
|
||||
openInitialTab={mcpInitialTab}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 hidden md:block"
|
||||
onClick={() => setMcpModalOpen(true)}
|
||||
>
|
||||
<MCPIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</ConnectAIModal>
|
||||
<TooltipContent>
|
||||
<p>Connect to AI (MCP)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Avatar className="border border-border h-8 w-8 md:h-10 md:w-10">
|
||||
<AvatarImage src={user?.image ?? ""} />
|
||||
<AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="mr-2 md:mr-4 px-2 w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{user?.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push("/settings/billing")}
|
||||
>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push("/settings/integrations")}
|
||||
>
|
||||
<Gauge className="h-4 w-4" />
|
||||
Integrations
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
window.open(
|
||||
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Chrome className="h-4 w-4" />
|
||||
Chrome Extension
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center justify-between p-2 cursor-default hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="text-sm font-medium">Theme</span>
|
||||
<div className="flex items-center gap-1 bg-accent rounded-full">
|
||||
<Button
|
||||
variant={theme === "system" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-full group hover:cursor-pointer",
|
||||
)}
|
||||
onClick={() => setTheme("system")}
|
||||
title="System"
|
||||
>
|
||||
<MonitorIcon
|
||||
className={cn(
|
||||
theme === "system"
|
||||
? "text-primary-foreground"
|
||||
: "text-muted-foreground",
|
||||
"h-3 w-3 group-hover:text-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "light" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-full group hover:cursor-pointer",
|
||||
)}
|
||||
onClick={() => setTheme("light")}
|
||||
title="Light"
|
||||
>
|
||||
<SunIcon
|
||||
className={cn(
|
||||
theme === "light"
|
||||
? "text-primary-foreground"
|
||||
: "text-muted-foreground",
|
||||
"h-3 w-3 group-hover:text-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === "dark" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-full group hover:cursor-pointer",
|
||||
)}
|
||||
onClick={() => setTheme("dark")}
|
||||
title="Dark"
|
||||
>
|
||||
<MoonIcon
|
||||
className={cn(
|
||||
theme === "dark"
|
||||
? "text-primary-foreground"
|
||||
: "text-muted-foreground",
|
||||
"h-3 w-3 group-hover:text-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleSignOut()}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import { Button } from "@repo/ui/components/button"
|
||||
import { Download, Share, X } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function InstallPrompt() {
|
||||
const [isIOS, setIsIOS] = useState(false)
|
||||
const [showPrompt, setShowPrompt] = useState(false)
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const isIOSDevice =
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
|
||||
const isInStandaloneMode = window.matchMedia(
|
||||
"(display-mode: standalone)",
|
||||
).matches
|
||||
const hasSeenPrompt =
|
||||
localStorage.getItem("install-prompt-dismissed") === "true"
|
||||
|
||||
setIsIOS(isIOSDevice)
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === "development"
|
||||
setShowPrompt(
|
||||
!hasSeenPrompt &&
|
||||
(isDevelopment ||
|
||||
(!isInStandaloneMode &&
|
||||
(isIOSDevice || "serviceWorker" in navigator))),
|
||||
)
|
||||
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault()
|
||||
setDeferredPrompt(e)
|
||||
if (!hasSeenPrompt) {
|
||||
setShowPrompt(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"beforeinstallprompt",
|
||||
handleBeforeInstallPrompt,
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt()
|
||||
const { outcome } = await deferredPrompt.userChoice
|
||||
if (outcome === "accepted") {
|
||||
localStorage.setItem("install-prompt-dismissed", "true")
|
||||
setShowPrompt(false)
|
||||
}
|
||||
setDeferredPrompt(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem("install-prompt-dismissed", "true")
|
||||
setShowPrompt(false)
|
||||
}
|
||||
|
||||
if (!showPrompt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-sm md:hidden"
|
||||
>
|
||||
<div className="bg-black/90 backdrop-blur-md text-white rounded-2xl p-4 shadow-2xl border border-white/10">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-[#0f1419] rounded-lg flex items-center justify-center">
|
||||
<Download className="w-4 h-4" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-sm">Install Supermemory</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
className="text-white/60 hover:text-white h-6 w-6 p-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-white/80 text-xs mb-4 leading-relaxed">
|
||||
Add Supermemory to your home screen for quick access and a better
|
||||
experience.
|
||||
</p>
|
||||
|
||||
{isIOS ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-white/70 text-xs flex items-center gap-1">
|
||||
1. Tap the <Share className="w-3 h-3 inline" /> Share button in
|
||||
Safari
|
||||
</p>
|
||||
<p className="text-white/70 text-xs">
|
||||
2. Select "Add to Home Screen" ➕
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleInstall}
|
||||
size="sm"
|
||||
className="w-full bg-[#0f1419] hover:bg-[#1a1f2a] text-white text-xs"
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
Add to Home Screen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { colors } from "@repo/ui/memory-graph/constants"
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { Masonry, useInfiniteLoader } from "masonic"
|
||||
import { memo, useCallback, useMemo, useState } from "react"
|
||||
import type { z } from "zod"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useDeleteDocument } from "@lib/queries"
|
||||
import { useProject } from "@/stores"
|
||||
|
||||
import { MemoryDetail } from "./memories-utils/memory-detail"
|
||||
import { TweetCard } from "./content-cards/tweet"
|
||||
import { WebsiteCard } from "./content-cards/website"
|
||||
import { NoteCard } from "./content-cards/note"
|
||||
import { GoogleDocsCard } from "./content-cards/google-docs"
|
||||
import type { Tweet } from "react-tweet/api"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
interface MasonryMemoryListProps {
|
||||
children?: React.ReactNode
|
||||
documents: DocumentWithMemories[]
|
||||
isLoading: boolean
|
||||
isLoadingMore: boolean
|
||||
error: Error | null
|
||||
totalLoaded: number
|
||||
hasMore: boolean
|
||||
loadMoreDocuments: () => Promise<void>
|
||||
}
|
||||
|
||||
const DocumentCard = memo(
|
||||
({
|
||||
index: _index,
|
||||
data: document,
|
||||
width,
|
||||
onOpenDetails,
|
||||
onDelete,
|
||||
}: {
|
||||
index: number
|
||||
data: DocumentWithMemories & { ogImage?: string }
|
||||
width: number
|
||||
onOpenDetails: (document: DocumentWithMemories) => void
|
||||
onDelete: (document: DocumentWithMemories) => void
|
||||
}) => {
|
||||
const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten)
|
||||
const forgottenMemories = document.memoryEntries.filter(
|
||||
(m) => m.isForgotten,
|
||||
)
|
||||
|
||||
if (
|
||||
document.url?.includes("https://docs.googleapis.com/v1/documents") ||
|
||||
document.url?.includes("docs.google.com/document") ||
|
||||
document.type === "google_doc"
|
||||
) {
|
||||
return (
|
||||
<GoogleDocsCard
|
||||
url={document.url}
|
||||
title={document.title || "Untitled Document"}
|
||||
description={document.content}
|
||||
activeMemories={activeMemories}
|
||||
lastModified={document.updatedAt || document.createdAt}
|
||||
onDelete={() => onDelete(document)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
document.url?.includes("x.com/") &&
|
||||
document.metadata?.sm_internal_twitter_metadata
|
||||
) {
|
||||
return (
|
||||
<TweetCard
|
||||
data={
|
||||
document.metadata?.sm_internal_twitter_metadata as unknown as Tweet
|
||||
}
|
||||
activeMemories={activeMemories}
|
||||
onDelete={() => onDelete(document)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this is a website document saved from the Chrome extension
|
||||
const websiteUrl =
|
||||
(document.metadata?.website_url as string | undefined) ||
|
||||
(document.url?.includes("https://") ? document.url : undefined)
|
||||
|
||||
if (websiteUrl) {
|
||||
return (
|
||||
<WebsiteCard
|
||||
url={websiteUrl}
|
||||
title={
|
||||
(document.metadata?.website_title as string | undefined) ||
|
||||
document.title ||
|
||||
"Untitled Document"
|
||||
}
|
||||
image={
|
||||
(document.metadata?.website_og_image as string | undefined) ||
|
||||
document.ogImage
|
||||
}
|
||||
description={
|
||||
document.content && typeof document.content === "string"
|
||||
? document.content
|
||||
: undefined
|
||||
}
|
||||
onOpenDetails={() => onOpenDetails(document)}
|
||||
onDelete={() => onDelete(document)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NoteCard
|
||||
document={document}
|
||||
width={width}
|
||||
activeMemories={activeMemories}
|
||||
forgottenMemories={forgottenMemories}
|
||||
onOpenDetails={onOpenDetails}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
DocumentCard.displayName = "DocumentCard"
|
||||
|
||||
export const MasonryMemoryList = ({
|
||||
children,
|
||||
documents,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
error,
|
||||
hasMore,
|
||||
loadMoreDocuments,
|
||||
}: MasonryMemoryListProps) => {
|
||||
const [selectedSpace, _] = useState<string>("all")
|
||||
const [selectedDocument, setSelectedDocument] =
|
||||
useState<DocumentWithMemories | null>(null)
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
const { selectedProject } = useProject()
|
||||
const deleteDocumentMutation = useDeleteDocument(selectedProject)
|
||||
|
||||
const handleDeleteDocument = useCallback(
|
||||
(document: DocumentWithMemories) => {
|
||||
deleteDocumentMutation.mutate(document.id)
|
||||
},
|
||||
[deleteDocumentMutation],
|
||||
)
|
||||
|
||||
// Filter documents based on selected space
|
||||
const filteredDocuments = useMemo(() => {
|
||||
if (!documents) return []
|
||||
|
||||
if (selectedSpace === "all") {
|
||||
return documents
|
||||
}
|
||||
|
||||
return documents.map((doc) => ({
|
||||
...doc,
|
||||
memoryEntries: doc.memoryEntries.filter(
|
||||
(memory) =>
|
||||
(memory.spaceContainerTag ?? memory.spaceId) === selectedSpace,
|
||||
),
|
||||
}))
|
||||
}, [documents, selectedSpace])
|
||||
|
||||
const handleOpenDetails = useCallback((document: DocumentWithMemories) => {
|
||||
analytics.memoryDetailOpened()
|
||||
setSelectedDocument(document)
|
||||
setIsDetailOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseDetails = useCallback(() => {
|
||||
setIsDetailOpen(false)
|
||||
setTimeout(() => setSelectedDocument(null), 300)
|
||||
}, [])
|
||||
|
||||
// Infinite loading with Masonic
|
||||
const maybeLoadMore = useInfiniteLoader(
|
||||
async (_startIndex, _stopIndex, _currentItems) => {
|
||||
if (hasMore && !isLoadingMore) {
|
||||
await loadMoreDocuments()
|
||||
}
|
||||
},
|
||||
{
|
||||
isItemLoaded: (index, items) => !!items[index],
|
||||
minimumBatchSize: 10,
|
||||
threshold: 5,
|
||||
},
|
||||
)
|
||||
|
||||
const renderDocumentCard = useCallback(
|
||||
({
|
||||
index,
|
||||
data,
|
||||
width,
|
||||
}: {
|
||||
index: number
|
||||
data: DocumentWithMemories
|
||||
width: number
|
||||
}) => (
|
||||
<DocumentCard
|
||||
index={index}
|
||||
data={data}
|
||||
width={width}
|
||||
onOpenDetails={handleOpenDetails}
|
||||
onDelete={handleDeleteDocument}
|
||||
/>
|
||||
),
|
||||
[handleOpenDetails, handleDeleteDocument],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full relative pt-10">
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="rounded-xl overflow-hidden">
|
||||
<div
|
||||
className="relative z-10 px-6 py-4"
|
||||
style={{ color: colors.text.primary }}
|
||||
>
|
||||
Error loading documents: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="h-full overflow-auto px-4 pt-4">
|
||||
<div
|
||||
className={`grid gap-4 ${isMobile ? "grid-cols-1" : "grid-cols-2 md:grid-cols-3 lg:grid-cols-4"}`}
|
||||
>
|
||||
{Array.from({ length: 8 }, (_, i) => ({
|
||||
id: `skeleton-${Math.random()}-${i}`,
|
||||
height: 100 + (i % 3),
|
||||
})).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-800 p-4 animate-pulse"
|
||||
style={{ height: `${item.height}px` }}
|
||||
>
|
||||
<div className="flex flex-col gap-3 h-full">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded w-3/4" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-800 rounded w-full" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-800 rounded w-5/6" />
|
||||
<div className="mt-auto flex gap-2">
|
||||
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-800 rounded-full" />
|
||||
<div className="h-6 w-16 bg-gray-200 dark:bg-gray-800 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : filteredDocuments.length === 0 && !isLoading ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="h-full overflow-auto custom-scrollbar sm-tweet-theme"
|
||||
data-theme="light"
|
||||
>
|
||||
<Masonry
|
||||
key={`masonry-${filteredDocuments.length}-${filteredDocuments.map((d) => d.id).join(",")}`}
|
||||
items={filteredDocuments}
|
||||
render={renderDocumentCard}
|
||||
columnGutter={16}
|
||||
rowGutter={16}
|
||||
columnWidth={280}
|
||||
maxColumnCount={isMobile ? 1 : undefined}
|
||||
itemHeightEstimate={200}
|
||||
overscanBy={3}
|
||||
onRender={maybeLoadMore}
|
||||
className="px-4"
|
||||
/>
|
||||
|
||||
{isLoadingMore && (
|
||||
<div className="py-8 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 animate-spin text-blue-400" />
|
||||
<span style={{ color: colors.text.primary }}>
|
||||
Loading more memories...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MemoryDetail
|
||||
document={selectedDocument}
|
||||
isOpen={isDetailOpen}
|
||||
onClose={handleCloseDetails}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import { memo, useMemo } from "react"
|
||||
import DOMPurify from "dompurify"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import type { Components } from "react-markdown"
|
||||
|
||||
interface HTMLContentRendererProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if content is likely HTML based on common HTML patterns
|
||||
*/
|
||||
const isHTMLContent = (content: string): boolean => {
|
||||
// Check for HTML tags, entities, and DOCTYPE
|
||||
const htmlPatterns = [
|
||||
/<[a-z][\s\S]*>/i, // HTML tags
|
||||
/&[a-z]+;/i, // HTML entities
|
||||
/<!doctype\s+html/i, // DOCTYPE declaration
|
||||
/<\/[a-z]+>/i, // Closing tags
|
||||
]
|
||||
|
||||
return htmlPatterns.some((pattern) => pattern.test(content))
|
||||
}
|
||||
|
||||
export const HTMLContentRenderer = memo(
|
||||
({ content, className = "" }: HTMLContentRendererProps) => {
|
||||
const { isHTML, isMarkdown, processedContent } = useMemo(() => {
|
||||
const contentIsHTML = isHTMLContent(content)
|
||||
|
||||
if (contentIsHTML) {
|
||||
return {
|
||||
isHTML: true,
|
||||
isMarkdown: false,
|
||||
processedContent: DOMPurify.sanitize(content),
|
||||
}
|
||||
}
|
||||
|
||||
let processed = content
|
||||
|
||||
if (content.includes("\n$ ")) {
|
||||
processed = content.replace(/^\$ (.*$)/gm, "```bash\n$ $1\n```")
|
||||
}
|
||||
|
||||
if (
|
||||
content.trim().startsWith("{") &&
|
||||
content.includes('"') &&
|
||||
content.includes(":")
|
||||
) {
|
||||
const lines = content.split("\n")
|
||||
let inJsonBlock = false
|
||||
const jsonLines: string[] = []
|
||||
const otherLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === "{" || line.trim() === "[") {
|
||||
inJsonBlock = true
|
||||
}
|
||||
|
||||
if (inJsonBlock) {
|
||||
jsonLines.push(line)
|
||||
if (line.trim() === "}" || line.trim() === "]") {
|
||||
inJsonBlock = false
|
||||
}
|
||||
} else {
|
||||
otherLines.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonLines.length > 0 && jsonLines.join("\n").trim()) {
|
||||
const jsonBlock = jsonLines.join("\n")
|
||||
const otherContent = otherLines.join("\n")
|
||||
processed =
|
||||
otherContent +
|
||||
(otherContent ? "\n\n" : "") +
|
||||
"```json\n" +
|
||||
jsonBlock +
|
||||
"\n```"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isHTML: false,
|
||||
isMarkdown: true,
|
||||
processedContent: processed,
|
||||
}
|
||||
}, [content])
|
||||
|
||||
if (isHTML) {
|
||||
return (
|
||||
<div
|
||||
className={`${className} bg-background`}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Content is sanitized with DOMPurify
|
||||
dangerouslySetInnerHTML={{ __html: processedContent }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMarkdown) {
|
||||
try {
|
||||
const components: Components = {
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-foreground text-lg font-semibold mb-1.5">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-foreground text-base font-semibold mb-1.5">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-foreground text-sm font-semibold mb-1">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-foreground text-sm font-medium mb-1">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ children }) => (
|
||||
<h5 className="text-foreground text-sm font-medium mb-1">
|
||||
{children}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children }) => (
|
||||
<h6 className="text-foreground text-sm font-medium mb-1">
|
||||
{children}
|
||||
</h6>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-foreground text-sm leading-relaxed mb-1.5">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="text-foreground font-semibold">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="text-foreground italic">{children}</em>
|
||||
),
|
||||
code: ({ children, className }) => (
|
||||
<code
|
||||
className={`text-foreground bg-muted px-1.5 py-0.5 rounded text-xs font-mono ${className || ""}`}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="text-foreground bg-muted border border-border p-2 rounded text-xs overflow-x-auto mb-2 whitespace-pre font-mono leading-tight">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="text-muted-foreground border-l-4 border-muted-foreground pl-3 italic mb-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary hover:text-primary/80 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="text-foreground text-sm mb-2 ml-4 list-disc">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="text-foreground text-sm mb-2 ml-4 list-decimal">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="mb-1">{children}</li>,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${className} bg-background`}>
|
||||
<ReactMarkdown components={components}>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
} catch {
|
||||
return (
|
||||
<p
|
||||
className={`text-sm leading-relaxed whitespace-pre-wrap text-foreground ${className}`}
|
||||
>
|
||||
{processedContent}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
HTMLContentRenderer.displayName = "HTMLContentRenderer"
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import type { DocumentWithMemories } from "@ui/memory-graph/types"
|
||||
|
||||
export const formatDate = (date: string | Date) => {
|
||||
const dateObj = new Date(date)
|
||||
const now = new Date()
|
||||
const currentYear = now.getFullYear()
|
||||
const dateYear = dateObj.getFullYear()
|
||||
|
||||
const monthNames = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
]
|
||||
const month = monthNames[dateObj.getMonth()]
|
||||
const day = dateObj.getDate()
|
||||
|
||||
const getOrdinalSuffix = (n: number) => {
|
||||
const s = ["th", "st", "nd", "rd"]
|
||||
const v = n % 100
|
||||
return n + (s[(v - 20) % 10] || s[v] || s[0] || "th")
|
||||
}
|
||||
|
||||
const formattedDay = getOrdinalSuffix(day)
|
||||
|
||||
if (dateYear !== currentYear) {
|
||||
return `${month} ${formattedDay}, ${dateYear}`
|
||||
}
|
||||
|
||||
return `${month} ${formattedDay}`
|
||||
}
|
||||
|
||||
export const getSourceUrl = (document: DocumentWithMemories) => {
|
||||
if (document.type === "google_doc" && document.customId) {
|
||||
return `https://docs.google.com/document/d/${document.customId}`
|
||||
}
|
||||
if (document.type === "google_sheet" && document.customId) {
|
||||
return `https://docs.google.com/spreadsheets/d/${document.customId}`
|
||||
}
|
||||
if (document.type === "google_slide" && document.customId) {
|
||||
return `https://docs.google.com/presentation/d/${document.customId}`
|
||||
}
|
||||
if (document.metadata?.website_url) {
|
||||
return document.metadata?.website_url as string
|
||||
}
|
||||
// Fallback to existing URL for all other document types
|
||||
return document.url
|
||||
}
|
||||
|
||||
// Simple hash function for consistent color generation
|
||||
const hashString = (str: string): number => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash)
|
||||
}
|
||||
|
||||
// Generate consistent pastel background color based on document ID
|
||||
export const getPastelBackgroundColor = (
|
||||
documentId: string | undefined | null,
|
||||
): string => {
|
||||
// Handle null/undefined cases
|
||||
if (!documentId) {
|
||||
return "rgba(255, 255, 255, 0.06)" // Default fallback color
|
||||
}
|
||||
|
||||
const hash = hashString(documentId)
|
||||
|
||||
// Define pastel color palette with good contrast against dark backgrounds
|
||||
const pastelColors = [
|
||||
// Soft pinks and roses
|
||||
"rgba(255, 182, 193, 0.08)", // Light pink
|
||||
"rgba(255, 218, 221, 0.08)", // Misty rose
|
||||
"rgba(255, 192, 203, 0.08)", // Pink
|
||||
|
||||
// Soft blues and purples
|
||||
"rgba(173, 216, 230, 0.08)", // Light blue
|
||||
"rgba(221, 160, 221, 0.08)", // Plum
|
||||
"rgba(218, 112, 214, 0.08)", // Orchid
|
||||
"rgba(147, 197, 253, 0.08)", // Sky blue
|
||||
|
||||
// Soft greens
|
||||
"rgba(152, 251, 152, 0.08)", // Pale green
|
||||
"rgba(175, 238, 238, 0.08)", // Pale turquoise
|
||||
"rgba(144, 238, 144, 0.08)", // Light green
|
||||
|
||||
// Soft oranges and yellows
|
||||
"rgba(255, 218, 185, 0.08)", // Peach puff
|
||||
"rgba(255, 239, 213, 0.08)", // Papaya whip
|
||||
"rgba(255, 228, 196, 0.08)", // Bisque
|
||||
|
||||
// Soft corals and salmons
|
||||
"rgba(250, 128, 114, 0.08)", // Salmon
|
||||
"rgba(255, 127, 80, 0.08)", // Coral
|
||||
"rgba(255, 160, 122, 0.08)", // Light salmon
|
||||
]
|
||||
|
||||
// Use hash to consistently pick a color
|
||||
const colorIndex = hash % pastelColors.length
|
||||
return pastelColors[colorIndex] || "rgba(255, 255, 255, 0.06)"
|
||||
}
|
||||
|
|
@ -1,387 +0,0 @@
|
|||
import { getDocumentIcon } from "@/components/new/document-modal/document-icon"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@repo/ui/components/drawer"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@repo/ui/components/dialog"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { Badge } from "@ui/components/badge"
|
||||
import {
|
||||
Brain,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CircleUserRound,
|
||||
ExternalLink,
|
||||
List,
|
||||
Sparkles,
|
||||
} from "lucide-react"
|
||||
import { memo, useState } from "react"
|
||||
import type { z } from "zod"
|
||||
import { formatDate, getSourceUrl } from "."
|
||||
import { Label1Regular } from "@ui/text/label/label-1-regular"
|
||||
import { HTMLContentRenderer } from "./html-content-renderer"
|
||||
import { Button } from "@ui/components/button"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
type MemoryEntry = DocumentWithMemories["memoryEntries"][0]
|
||||
|
||||
const formatDocumentType = (type: string) => {
|
||||
if (type.toLowerCase() === "pdf") return "PDF"
|
||||
|
||||
return type
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => {
|
||||
return (
|
||||
<div
|
||||
className={`p-2.5 md:p-4 rounded-lg md:rounded-xl border transition-all relative overflow-hidden group ${
|
||||
memory.isLatest
|
||||
? "bg-card shadow-sm hover:shadow-md border-primary/30"
|
||||
: "bg-card/50 shadow-xs hover:shadow-sm border-border/60 hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2 md:gap-3 relative z-10">
|
||||
<div className="flex-1 space-y-1.5 md:space-y-3">
|
||||
<Label1Regular className="text-xs md:text-sm leading-relaxed text-left text-card-foreground">
|
||||
{memory.memory}
|
||||
</Label1Regular>
|
||||
<div className="flex gap-1 md:gap-2 justify-between items-center flex-wrap">
|
||||
<div className="flex items-center gap-2 md:gap-3 text-[10px] md:text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 md:w-3.5 md:h-3.5" />
|
||||
{formatDate(memory.createdAt)}
|
||||
</span>
|
||||
<span className="font-mono bg-muted/30 px-1 md:px-1.5 py-0.5 rounded text-[9px] md:text-[10px]">
|
||||
v{memory.version}
|
||||
</span>
|
||||
{memory.sourceRelevanceScore && (
|
||||
<span
|
||||
className={`flex items-center gap-1 font-medium ${
|
||||
memory.sourceRelevanceScore > 70
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
{memory.sourceRelevanceScore}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap">
|
||||
{memory.isForgotten && (
|
||||
<Badge
|
||||
className="text-[9px] md:text-[10px] h-4 md:h-5"
|
||||
variant="destructive"
|
||||
>
|
||||
Forgotten
|
||||
</Badge>
|
||||
)}
|
||||
{memory.isLatest && (
|
||||
<Badge
|
||||
className="text-[9px] md:text-[10px] h-4 md:h-5 bg-primary/15 text-primary border-primary/30"
|
||||
variant="outline"
|
||||
>
|
||||
Latest
|
||||
</Badge>
|
||||
)}
|
||||
{memory.forgetAfter && (
|
||||
<Badge
|
||||
className="text-[9px] md:text-[10px] h-4 md:h-5 text-amber-600 dark:text-amber-500 bg-amber-500/10 border-amber-500/30"
|
||||
variant="outline"
|
||||
>
|
||||
<span className="hidden sm:inline">
|
||||
Expires {formatDate(memory.forgetAfter)}
|
||||
</span>
|
||||
<span className="sm:hidden">Expires</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const MemoryDetail = memo(
|
||||
({
|
||||
document,
|
||||
isOpen,
|
||||
onClose,
|
||||
isMobile,
|
||||
}: {
|
||||
document: DocumentWithMemories | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
isMobile: boolean
|
||||
}) => {
|
||||
if (!document) return null
|
||||
|
||||
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
|
||||
|
||||
const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten)
|
||||
const forgottenMemories = document.memoryEntries.filter(
|
||||
(m) => m.isForgotten,
|
||||
)
|
||||
|
||||
const HeaderContent = ({
|
||||
TitleComponent,
|
||||
}: {
|
||||
TitleComponent: typeof DialogTitle | typeof DrawerTitle
|
||||
}) => (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-end gap-2 md:gap-3 flex-1 min-w-0">
|
||||
<div className="p-1.5 md:p-2 rounded-lg bg-muted/10 flex-shrink-0">
|
||||
{getDocumentIcon(
|
||||
document.type,
|
||||
"w-4 h-4 md:w-5 md:h-5 text-foreground",
|
||||
document.source ?? undefined,
|
||||
document.url ?? undefined,
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<TitleComponent className="text-foreground text-sm md:text-base truncate text-left">
|
||||
{document.title || "Untitled Document"}
|
||||
</TitleComponent>
|
||||
<div className="flex items-center gap-1.5 md:gap-2 mt-1 text-[10px] md:text-xs text-muted-foreground flex-wrap">
|
||||
<span>{formatDocumentType(document.type)}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(document.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{(document.url || document.metadata?.website_url) && (
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const sourceUrl = getSourceUrl(document)
|
||||
window.open(sourceUrl ?? undefined, "_blank")
|
||||
}}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<span className="hidden sm:inline">visit source</span>
|
||||
<span className="sm:hidden">Source</span>
|
||||
<ExternalLink className="w-2.5 h-2.5 md:w-3 md:h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ContentDisplaySection = () => {
|
||||
const hasContent = document.content && document.content.trim().length > 0
|
||||
|
||||
if (!hasContent) {
|
||||
return (
|
||||
<div className="text-center py-12 rounded-lg bg-muted/5">
|
||||
<CircleUserRound className="w-12 h-12 mx-auto mb-4 opacity-30 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
No content available for this document
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 md:p-4 rounded-lg bg-muted/5 border border-border h-full overflow-y-auto max-w-3xl">
|
||||
<HTMLContentRenderer content={document.content || ""} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SummaryDisplaySection = () => {
|
||||
const hasSummary = document.summary && document.summary.trim().length > 0
|
||||
|
||||
if (!hasSummary) {
|
||||
return (
|
||||
<div className="text-center py-6 rounded-lg bg-muted/5">
|
||||
<List className="w-6 h-6 mx-auto mb-2 opacity-30 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
No summary available
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-2.5 md:p-3 px-3 md:px-4 rounded-lg bg-primary/5 border border-primary/15">
|
||||
<p className="text-xs md:text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||
{document.summary}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MemoryContent = () => (
|
||||
<div className="space-y-6">
|
||||
{activeMemories.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium flex items-start gap-2 pb-2 text-muted-foreground">
|
||||
Active Memories ({activeMemories.length})
|
||||
</div>
|
||||
<div className="space-y-3 max-h-[80vh] overflow-y-auto custom-scrollbar">
|
||||
{activeMemories.map((memory) => (
|
||||
<div key={memory.id}>
|
||||
<MemoryDetailItem memory={memory} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{forgottenMemories.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-4 px-3 py-2 rounded-lg opacity-60 text-muted-foreground bg-muted/5">
|
||||
Forgotten Memories ({forgottenMemories.length})
|
||||
</div>
|
||||
<div className="space-y-3 opacity-40">
|
||||
{forgottenMemories.map((memory) => (
|
||||
<MemoryDetailItem key={memory.id} memory={memory} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeMemories.length === 0 && forgottenMemories.length === 0 && (
|
||||
<div className="text-center py-12 rounded-lg bg-muted/5">
|
||||
<Brain className="w-12 h-12 mx-auto mb-4 opacity-30 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
No memories found for this document
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer onOpenChange={onClose} open={isOpen}>
|
||||
<DrawerContent className="border-0 p-0 overflow-hidden max-h-[95vh] bg-background border-t border-border backdrop-blur-xl flex flex-col">
|
||||
<div className="p-3 md:p-4 relative bg-muted/5 flex-shrink-0">
|
||||
<DrawerHeader className="p-0 text-left">
|
||||
<HeaderContent TitleComponent={DrawerTitle} />
|
||||
</DrawerHeader>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="border-b border-border">
|
||||
<div className="p-2.5 md:p-3 bg-muted/5">
|
||||
<h4 className="text-sm md:text-base font-medium text-foreground">
|
||||
Content
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-3 md:p-4 m-4">
|
||||
<ContentDisplaySection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border">
|
||||
<div className="p-2.5 md:p-3 bg-muted/5">
|
||||
<h4 className="text-sm md:text-base font-medium text-foreground">
|
||||
Summary
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-3 md:p-4">
|
||||
<SummaryDisplaySection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="px-2.5 pt-2.5 md:p-3 bg-muted/5">
|
||||
<h4 className="text-sm md:text-base font-medium text-foreground">
|
||||
Memories
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-3 md:p-4">
|
||||
<MemoryContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onClose} open={isOpen}>
|
||||
<DialogContent className="w-[95vw] md:w-[90vw] lg:w-[85vw] h-[90vh] border-0 p-0 overflow-hidden flex flex-col bg-background !max-w-7xl gap-0">
|
||||
<div className="p-4 md:p-6 relative flex-shrink-0 bg-muted/5">
|
||||
<DialogHeader className="pb-0">
|
||||
<HeaderContent TitleComponent={DialogTitle} />
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
|
||||
<div className="flex-1 flex flex-col h-full justify-between min-w-0">
|
||||
<div className="p-2 px-3 md:pl-4 overflow-y-auto custom-scrollbar transition-all duration-300">
|
||||
<h3 className="font-medium text-[10px] md:text-sm text-muted-foreground pb-2 px-1">
|
||||
Content
|
||||
</h3>
|
||||
<ContentDisplaySection />
|
||||
</div>
|
||||
|
||||
<div className="transition-all duration-300 mx-2 mb-3 md:mb-4 flex-shrink-0">
|
||||
<div className="bg-card border border-border rounded-xl shadow-lg backdrop-blur-sm h-full flex flex-col">
|
||||
<button
|
||||
onClick={() => setIsSummaryOpen(!isSummaryOpen)}
|
||||
className="flex-shrink-0 w-full flex items-center justify-between p-3 md:p-4 hover:bg-muted/5 transition-colors rounded-t-xl"
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<h3 className="font-semibold text-xs md:text-sm text-foreground">
|
||||
Summary
|
||||
</h3>
|
||||
{document.summary &&
|
||||
document.summary.trim().length > 0 && (
|
||||
<Badge
|
||||
className="text-[10px] h-5"
|
||||
variant="secondary"
|
||||
>
|
||||
Available
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isSummaryOpen ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isSummaryOpen && (
|
||||
<div className="flex-1 px-3 md:px-4 pb-3 md:pb-4 overflow-hidden min-h-0">
|
||||
<div className="h-full overflow-y-auto custom-scrollbar">
|
||||
<SummaryDisplaySection />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-96 flex flex-col border-t lg:border-t-0 border-border">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 memory-dialog-scroll overflow-y-auto p-2 md:p-3">
|
||||
<MemoryContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
// apps/web/components/memories.tsx
|
||||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { $fetch } from "@repo/lib/api"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import type { z } from "zod"
|
||||
import { ConnectAIModal } from "@/components/connect-ai-modal"
|
||||
import { MasonryMemoryList } from "@/components/masonry-memory-list"
|
||||
import { AddMemoryView } from "@/components/views/add-memory"
|
||||
import { useChatOpen, useProject } from "@/stores"
|
||||
import { useGraphHighlights } from "@/stores/highlights"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
export function Memories() {
|
||||
const { user } = useAuth()
|
||||
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
|
||||
const { selectedProject } = useProject()
|
||||
const { isOpen } = useChatOpen()
|
||||
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([])
|
||||
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
|
||||
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === "development"
|
||||
const PAGE_SIZE = IS_DEV ? 100 : 100
|
||||
const MAX_TOTAL = 1000
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isPending,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery<DocumentsResponse, Error>({
|
||||
queryKey: ["documents-with-memories", selectedProject],
|
||||
initialPageParam: 1,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = await $fetch("@post/documents/documents", {
|
||||
body: {
|
||||
page: pageParam as number,
|
||||
limit: (pageParam as number) === 1 ? (IS_DEV ? 500 : 500) : PAGE_SIZE,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags: selectedProject ? [selectedProject] : undefined,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to fetch documents")
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (!lastPage || !lastPage.pagination) return undefined
|
||||
if (!Array.isArray(allPages)) return undefined
|
||||
|
||||
const loaded = allPages.reduce(
|
||||
(acc, p) => acc + (p.documents?.length ?? 0),
|
||||
0,
|
||||
)
|
||||
if (loaded >= MAX_TOTAL) return undefined
|
||||
|
||||
const { currentPage, totalPages } = lastPage.pagination
|
||||
if (currentPage < totalPages) {
|
||||
return currentPage + 1
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!user, // Only run query if user is authenticated
|
||||
})
|
||||
|
||||
const baseDocuments = useMemo(() => {
|
||||
return (
|
||||
data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
|
||||
)
|
||||
}, [data])
|
||||
|
||||
const allDocuments = useMemo(() => {
|
||||
if (injectedDocs.length === 0) return baseDocuments
|
||||
const byId = new Map<string, DocumentWithMemories>()
|
||||
for (const d of injectedDocs) byId.set(d.id, d)
|
||||
for (const d of baseDocuments) if (!byId.has(d.id)) byId.set(d.id, d)
|
||||
return Array.from(byId.values())
|
||||
}, [baseDocuments, injectedDocs])
|
||||
|
||||
const totalLoaded = allDocuments.length
|
||||
const hasMore = hasNextPage
|
||||
const isLoadingMore = isFetchingNextPage
|
||||
|
||||
const loadMoreDocuments = useCallback(async (): Promise<void> => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
await fetchNextPage()
|
||||
return
|
||||
}
|
||||
return
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||
|
||||
// Handle highlighted documents injection for chat
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0) return
|
||||
const present = new Set<string>()
|
||||
for (const d of [...baseDocuments, ...injectedDocs]) {
|
||||
if (d.id) present.add(d.id)
|
||||
if (d.customId) present.add(d.customId as string)
|
||||
}
|
||||
const missing = allHighlightDocumentIds.filter(
|
||||
(id: string) => !present.has(id),
|
||||
)
|
||||
if (missing.length === 0) return
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
try {
|
||||
const resp = await $fetch("@post/documents/documents/by-ids", {
|
||||
body: {
|
||||
ids: missing,
|
||||
by: "customId",
|
||||
containerTags: selectedProject ? [selectedProject] : undefined,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
if (cancelled || resp?.error) return
|
||||
const extraDocs = resp?.data?.documents as
|
||||
| DocumentWithMemories[]
|
||||
| undefined
|
||||
if (!extraDocs || extraDocs.length === 0) return
|
||||
setInjectedDocs((prev) => {
|
||||
const seen = new Set<string>([
|
||||
...prev.map((d) => d.id),
|
||||
...baseDocuments.map((d) => d.id),
|
||||
])
|
||||
const merged = [...prev]
|
||||
for (const doc of extraDocs) {
|
||||
if (!seen.has(doc.id)) {
|
||||
merged.push(doc)
|
||||
seen.add(doc.id)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
allHighlightDocumentIds,
|
||||
baseDocuments,
|
||||
injectedDocs,
|
||||
selectedProject,
|
||||
])
|
||||
|
||||
// Show connect AI modal if no documents
|
||||
useEffect(() => {
|
||||
if (allDocuments.length === 0 && !isMobile) {
|
||||
setShowConnectAIModal(true)
|
||||
}
|
||||
}, [allDocuments.length, isMobile])
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>Please log in to view your memories</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full mx-4 md:mx-24">
|
||||
<MasonryMemoryList
|
||||
documents={allDocuments}
|
||||
error={error}
|
||||
hasMore={hasMore}
|
||||
isLoading={isPending}
|
||||
isLoadingMore={isLoadingMore}
|
||||
loadMoreDocuments={loadMoreDocuments}
|
||||
totalLoaded={totalLoaded}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{!isMobile ? (
|
||||
<ConnectAIModal
|
||||
onOpenChange={setShowConnectAIModal}
|
||||
open={showConnectAIModal}
|
||||
>
|
||||
<div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6">
|
||||
<div className="relative z-10 text-slate-200 text-center">
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowAddMemoryView(true)
|
||||
setShowConnectAIModal(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Add your first memory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ConnectAIModal>
|
||||
) : (
|
||||
<div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6">
|
||||
<div className="relative z-10 text-slate-200 text-center">
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowAddMemoryView(true)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Add your first memory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MasonryMemoryList>
|
||||
|
||||
{showAddMemoryView && (
|
||||
<AddMemoryView
|
||||
initialTab="note"
|
||||
onClose={() => setShowAddMemoryView(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,396 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Badge } from "@repo/ui/components/badge"
|
||||
import { Card, CardContent, CardHeader } from "@repo/ui/components/card"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui/components/alert-dialog"
|
||||
import { colors } from "@repo/ui/memory-graph/constants"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import { Brain, ExternalLink, Sparkles, Trash2 } from "lucide-react"
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { z } from "zod"
|
||||
import useResizeObserver from "@/hooks/use-resize-observer"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useDeleteDocument } from "@lib/queries"
|
||||
import { useProject } from "@/stores"
|
||||
|
||||
import { MemoryDetail } from "./memories-utils/memory-detail"
|
||||
import { getDocumentIcon } from "@/components/new/document-modal/document-icon"
|
||||
import { formatDate, getSourceUrl } from "./memories-utils"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
interface MemoryListViewProps {
|
||||
children?: React.ReactNode
|
||||
documents: DocumentWithMemories[]
|
||||
isLoading: boolean
|
||||
isLoadingMore: boolean
|
||||
error: Error | null
|
||||
totalLoaded: number
|
||||
hasMore: boolean
|
||||
loadMoreDocuments: () => Promise<void>
|
||||
}
|
||||
|
||||
const DocumentCard = memo(
|
||||
({
|
||||
document,
|
||||
onOpenDetails,
|
||||
onDelete,
|
||||
}: {
|
||||
document: DocumentWithMemories
|
||||
onOpenDetails: (document: DocumentWithMemories) => void
|
||||
onDelete: (document: DocumentWithMemories) => void
|
||||
}) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten)
|
||||
const forgottenMemories = document.memoryEntries.filter(
|
||||
(m) => m.isForgotten,
|
||||
)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden gap-2 md:w-full shadow-xs"
|
||||
onClick={() => {
|
||||
if (!isDialogOpen) {
|
||||
analytics.documentCardClicked()
|
||||
onOpenDetails(document)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: colors.document.primary,
|
||||
}}
|
||||
>
|
||||
<CardHeader className="relative z-10 px-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{getDocumentIcon(document.type, "w-4 h-4 flex-shrink-0")}
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium line-clamp-1",
|
||||
document.url ? "max-w-[190px]" : "max-w-[200px]",
|
||||
)}
|
||||
>
|
||||
{document.title || "Untitled Document"}
|
||||
</p>
|
||||
</div>
|
||||
{document.url && (
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const sourceUrl = getSourceUrl(document)
|
||||
window.open(sourceUrl ?? undefined, "_blank")
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
color: colors.text.secondary,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span>{formatDate(document.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10 px-0">
|
||||
{document.content && (
|
||||
<p
|
||||
className="text-xs line-clamp-2 mb-3"
|
||||
style={{ color: colors.text.muted }}
|
||||
>
|
||||
{document.content}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{activeMemories.length > 0 && (
|
||||
<Badge
|
||||
className="text-xs text-accent-foreground"
|
||||
style={{
|
||||
backgroundColor: colors.memory.secondary,
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<Brain className="w-3 h-3 mr-1" />
|
||||
{activeMemories.length}{" "}
|
||||
{activeMemories.length === 1 ? "memory" : "memories"}
|
||||
</Badge>
|
||||
)}
|
||||
{forgottenMemories.length > 0 && (
|
||||
<Badge
|
||||
className="text-xs"
|
||||
style={{
|
||||
borderColor: "rgba(255, 255, 255, 0.2)",
|
||||
color: colors.text.muted,
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{forgottenMemories.length} forgotten
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-red-500/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
style={{
|
||||
color: colors.text.muted,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Document</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this document and all its
|
||||
related memories? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(document)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const MemoryListView = ({
|
||||
children,
|
||||
documents,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
error,
|
||||
hasMore,
|
||||
loadMoreDocuments,
|
||||
}: MemoryListViewProps) => {
|
||||
const [selectedSpace, _] = useState<string>("all")
|
||||
const [selectedDocument, setSelectedDocument] =
|
||||
useState<DocumentWithMemories | null>(null)
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false)
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isMobile = useIsMobile()
|
||||
const { selectedProject } = useProject()
|
||||
const deleteDocumentMutation = useDeleteDocument(selectedProject)
|
||||
|
||||
const gap = 14
|
||||
|
||||
const handleDeleteDocument = useCallback(
|
||||
(document: DocumentWithMemories) => {
|
||||
deleteDocumentMutation.mutate(document.id)
|
||||
},
|
||||
[deleteDocumentMutation],
|
||||
)
|
||||
|
||||
const { width: containerWidth } = useResizeObserver(containerRef)
|
||||
const columnWidth = isMobile ? containerWidth : 320
|
||||
const columns = Math.max(
|
||||
1,
|
||||
Math.floor((containerWidth + gap) / (columnWidth + gap)),
|
||||
)
|
||||
|
||||
// Filter documents based on selected space
|
||||
const filteredDocuments = useMemo(() => {
|
||||
if (!documents) return []
|
||||
|
||||
if (selectedSpace === "all") {
|
||||
return documents
|
||||
}
|
||||
|
||||
return documents.map((doc) => ({
|
||||
...doc,
|
||||
memoryEntries: doc.memoryEntries.filter(
|
||||
(memory) =>
|
||||
(memory.spaceContainerTag ?? memory.spaceId) === selectedSpace,
|
||||
),
|
||||
}))
|
||||
}, [documents, selectedSpace])
|
||||
|
||||
const handleOpenDetails = useCallback((document: DocumentWithMemories) => {
|
||||
analytics.memoryDetailOpened()
|
||||
setSelectedDocument(document)
|
||||
setIsDetailOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseDetails = useCallback(() => {
|
||||
setIsDetailOpen(false)
|
||||
setTimeout(() => setSelectedDocument(null), 300)
|
||||
}, [])
|
||||
|
||||
const virtualItems = useMemo(() => {
|
||||
const items = []
|
||||
for (let i = 0; i < filteredDocuments.length; i += columns) {
|
||||
items.push(filteredDocuments.slice(i, i + columns))
|
||||
}
|
||||
return items
|
||||
}, [filteredDocuments, columns])
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: virtualItems.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
overscan: 5,
|
||||
estimateSize: () => 200,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const [lastItem] = [...virtualizer.getVirtualItems()].reverse()
|
||||
|
||||
if (!lastItem || !hasMore || isLoadingMore) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lastItem.index >= virtualItems.length - 1) {
|
||||
loadMoreDocuments()
|
||||
}
|
||||
}, [
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMoreDocuments,
|
||||
virtualizer.getVirtualItems,
|
||||
virtualItems.length,
|
||||
])
|
||||
|
||||
// Always render with consistent structure
|
||||
return (
|
||||
<>
|
||||
<div className="h-full overflow-hidden relative pb-20" ref={containerRef}>
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="rounded-xl overflow-hidden">
|
||||
<div
|
||||
className="relative z-10 px-6 py-4"
|
||||
style={{ color: colors.text.primary }}
|
||||
>
|
||||
Error loading documents: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="rounded-xl overflow-hidden">
|
||||
<div
|
||||
className="relative z-10 px-6 py-4"
|
||||
style={{ color: colors.text.primary }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 animate-spin text-blue-400" />
|
||||
<span>Loading memory list...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredDocuments.length === 0 && !isLoading ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="h-full overflow-auto mt-20 custom-scrollbar"
|
||||
>
|
||||
<div
|
||||
className="w-full relative"
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize() + virtualItems.length * gap}px`,
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const rowItems = virtualItems[virtualRow.index]
|
||||
if (!rowItems) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute top-0 left-0 w-full sm-tweet-theme"
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start + virtualRow.index * gap}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="grid justify-center"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, ${columnWidth}px)`,
|
||||
gap: `${gap}px`,
|
||||
}}
|
||||
>
|
||||
{rowItems.map((document, columnIndex) => (
|
||||
<DocumentCard
|
||||
key={`${document.id}-${virtualRow.index}-${columnIndex}`}
|
||||
document={document}
|
||||
onOpenDetails={handleOpenDetails}
|
||||
onDelete={handleDeleteDocument}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isLoadingMore && (
|
||||
<div className="py-8 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 animate-spin text-blue-400" />
|
||||
<span style={{ color: colors.text.primary }}>
|
||||
Loading more memories...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MemoryDetail
|
||||
document={selectedDocument}
|
||||
isOpen={isDetailOpen}
|
||||
onClose={handleCloseDetails}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,681 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { fetchApiProProduct, fetchMemoriesFeature } from "@repo/lib/queries"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import { ConnectAIModal } from "./connect-ai-modal"
|
||||
import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold"
|
||||
import { GlassMenuEffect } from "@ui/other/glass-effect"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { Plus, Puzzle, User, X } from "lucide-react"
|
||||
import { AnimatePresence, LayoutGroup, motion } from "motion/react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Drawer } from "vaul"
|
||||
import { useMobilePanel } from "@/lib/mobile-panel-context"
|
||||
import { useChatOpen } from "@/stores"
|
||||
import { ProjectSelector } from "./project-selector"
|
||||
import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory"
|
||||
import { IntegrationsView } from "./views/integrations"
|
||||
import { ProfileView } from "./views/profile"
|
||||
|
||||
export const MCPIcon = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>ModelContextProtocol</title>
|
||||
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
|
||||
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Menu({ id }: { id?: string }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const openParam = searchParams.get("open")
|
||||
|
||||
// Valid view names that can be opened via URL parameter
|
||||
const validViews = [
|
||||
"addUrl",
|
||||
"mcp",
|
||||
"projects",
|
||||
"profile",
|
||||
"integrations",
|
||||
] as const
|
||||
type ValidView = (typeof validViews)[number]
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [expandedView, setExpandedView] = useState<
|
||||
"addUrl" | "mcp" | "projects" | "profile" | "integrations" | null
|
||||
>(null)
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const [isCollapsing, setIsCollapsing] = useState(false)
|
||||
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
|
||||
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
const { activePanel, setActivePanel } = useMobilePanel()
|
||||
const autumn = useCustomer()
|
||||
const { setIsOpen } = useChatOpen()
|
||||
|
||||
const { data: memoriesCheck } = fetchMemoriesFeature(
|
||||
autumn,
|
||||
!autumn.isLoading,
|
||||
)
|
||||
|
||||
const memoriesUsed = memoriesCheck?.usage ?? 0
|
||||
const memoriesLimit = memoriesCheck?.included_usage ?? 0
|
||||
|
||||
const { data: proCheck } = fetchApiProProduct(autumn)
|
||||
|
||||
useEffect(() => {
|
||||
if (memoriesCheck) {
|
||||
console.log({ memoriesCheck })
|
||||
}
|
||||
|
||||
if (proCheck) {
|
||||
console.log({ proCheck })
|
||||
}
|
||||
}, [memoriesCheck, proCheck])
|
||||
|
||||
// Function to clear the 'open' parameter from URL
|
||||
const clearOpenParam = useCallback(() => {
|
||||
const newSearchParams = new URLSearchParams(searchParams.toString())
|
||||
newSearchParams.delete("open")
|
||||
const newUrl = `${window.location.pathname}${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`
|
||||
router.replace(newUrl)
|
||||
}, [searchParams, router])
|
||||
|
||||
const isProUser = proCheck?.allowed ?? false
|
||||
|
||||
const shouldShowLimitWarning =
|
||||
!isProUser && memoriesUsed >= memoriesLimit * 0.8
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
icon: Plus,
|
||||
text: "Add Memory",
|
||||
key: "addUrl" as const,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
icon: Puzzle,
|
||||
text: "Integrations",
|
||||
key: "integrations" as const,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
icon: MCPIcon,
|
||||
text: "MCP",
|
||||
key: "mcp" as const,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
icon: User,
|
||||
text: "Profile",
|
||||
key: "profile" as const,
|
||||
disabled: false,
|
||||
},
|
||||
]
|
||||
|
||||
const handleMenuItemClick = (
|
||||
key: "chat" | "addUrl" | "mcp" | "projects" | "profile" | "integrations",
|
||||
) => {
|
||||
if (key === "chat") {
|
||||
setIsOpen(true)
|
||||
setIsMobileMenuOpen(false)
|
||||
if (isMobile) {
|
||||
setActivePanel("chat")
|
||||
}
|
||||
} else if (key === "mcp") {
|
||||
// Open ConnectAIModal directly for MCP
|
||||
setIsMobileMenuOpen(false)
|
||||
setExpandedView(null)
|
||||
setShowConnectAIModal(true)
|
||||
} else {
|
||||
if (expandedView === key) {
|
||||
setIsCollapsing(true)
|
||||
setExpandedView(null)
|
||||
} else if (key === "addUrl") {
|
||||
setShowAddMemoryView(true)
|
||||
setExpandedView(null)
|
||||
} else {
|
||||
setExpandedView(key)
|
||||
}
|
||||
if (isMobile) {
|
||||
setActivePanel("menu")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle initial view opening based on URL parameter
|
||||
useEffect(() => {
|
||||
if (openParam) {
|
||||
if (openParam === "chat") {
|
||||
setIsOpen(true)
|
||||
setIsMobileMenuOpen(false)
|
||||
if (isMobile) {
|
||||
setActivePanel("chat")
|
||||
}
|
||||
} else if (openParam === "mcp") {
|
||||
// Open ConnectAIModal directly for MCP
|
||||
setIsMobileMenuOpen(false)
|
||||
setExpandedView(null)
|
||||
setShowConnectAIModal(true)
|
||||
} else if (openParam === "addUrl") {
|
||||
setShowAddMemoryView(true)
|
||||
setExpandedView(null)
|
||||
if (isMobile) {
|
||||
setIsMobileMenuOpen(true)
|
||||
setActivePanel("menu")
|
||||
}
|
||||
} else if (validViews.includes(openParam as ValidView)) {
|
||||
// For other valid views like "profile", "integrations"
|
||||
setExpandedView(openParam as ValidView)
|
||||
if (isMobile) {
|
||||
setIsMobileMenuOpen(true)
|
||||
setActivePanel("menu")
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the parameter from URL after performing any action
|
||||
clearOpenParam()
|
||||
}
|
||||
}, [
|
||||
openParam,
|
||||
isMobile,
|
||||
setIsOpen,
|
||||
setActivePanel,
|
||||
validViews,
|
||||
clearOpenParam,
|
||||
])
|
||||
|
||||
// Watch for active panel changes on mobile
|
||||
useEffect(() => {
|
||||
if (isMobile && activePanel !== "menu" && activePanel !== null) {
|
||||
// Another panel became active, close the menu
|
||||
setIsMobileMenuOpen(false)
|
||||
setExpandedView(null)
|
||||
}
|
||||
}, [isMobile, activePanel])
|
||||
|
||||
// Calculate width based on state
|
||||
const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56
|
||||
|
||||
// Dynamic z-index for mobile based on active panel
|
||||
const mobileZIndex = isMobile && activePanel === "menu" ? "z-[70]" : "z-[100]"
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Menu */}
|
||||
{!isMobile && (
|
||||
<LayoutGroup>
|
||||
<div className="fixed h-screen w-full p-4 items-center top-0 left-0 pointer-events-none z-[60] flex">
|
||||
<motion.nav
|
||||
animate={{
|
||||
width: menuWidth,
|
||||
scale: 1,
|
||||
}}
|
||||
className="pointer-events-auto group relative flex text-sm font-medium flex-col items-start overflow-hidden rounded-3xl shadow-2xl"
|
||||
id={id}
|
||||
initial={{ width: 56, scale: 0.95 }}
|
||||
layout
|
||||
onMouseEnter={() => !expandedView && setIsHovered(true)}
|
||||
onMouseLeave={() => !expandedView && setIsHovered(false)}
|
||||
transition={{
|
||||
width: {
|
||||
duration: 0.2,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
},
|
||||
scale: {
|
||||
duration: 0.5,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
},
|
||||
layout: {
|
||||
duration: 0.2,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Menu content */}
|
||||
<motion.div
|
||||
className="relative z-20 flex flex-col gap-6 w-full bg-white"
|
||||
layout
|
||||
>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="wait"
|
||||
onExitComplete={() => setIsCollapsing(false)}
|
||||
>
|
||||
{!expandedView ? (
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
className="w-full flex flex-col gap-6 p-4"
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeOut",
|
||||
},
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
key="menu-items"
|
||||
layout
|
||||
style={{
|
||||
transform: "translateZ(0)",
|
||||
willChange: "opacity",
|
||||
}}
|
||||
transition={{
|
||||
opacity: {
|
||||
duration: 0.15,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
{menuItems.map((item, index) => (
|
||||
<div key={item.key}>
|
||||
<motion.button
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
},
|
||||
}}
|
||||
className={`flex w-full items-center transition-colors duration-100 cursor-pointer relative ${isHovered || expandedView ? "px-1" : ""}`}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
layout
|
||||
onClick={() => handleMenuItemClick(item.key)}
|
||||
type="button"
|
||||
whileHover={{
|
||||
scale: 1.02,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: 1,
|
||||
transition: {
|
||||
delay: expandedView === null ? 0.15 : 0,
|
||||
duration: 0.1,
|
||||
},
|
||||
}}
|
||||
initial={{ scale: 0.8 }}
|
||||
layout="position"
|
||||
>
|
||||
<item.icon className="duration-200 h-6 w-6 flex-shrink-0" />
|
||||
</motion.div>
|
||||
<motion.p
|
||||
animate={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
x: isHovered ? 0 : -10,
|
||||
}}
|
||||
className="pl-3 whitespace-nowrap"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
style={{
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
transition={{
|
||||
duration: isHovered ? 0.2 : 0.1,
|
||||
delay: isHovered ? index * 0.03 : 0,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</motion.p>
|
||||
</motion.button>
|
||||
{index === 0 && (
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scaleX: 1,
|
||||
}}
|
||||
className="w-full h-px bg-black/20 mt-3 origin-left"
|
||||
initial={{ opacity: 0, scaleX: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: 0.1,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
className="w-full p-4"
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeOut",
|
||||
},
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
key="expanded-view"
|
||||
layout
|
||||
style={{
|
||||
transform: "translateZ(0)",
|
||||
willChange: "opacity, transform",
|
||||
}}
|
||||
transition={{
|
||||
opacity: {
|
||||
duration: 0.15,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center justify-between mb-4"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
layout
|
||||
transition={{
|
||||
delay: 0.05,
|
||||
duration: 0.2,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
>
|
||||
<HeadingH2Bold className="text-white">
|
||||
{expandedView === "mcp" && "Model Context Protocol"}
|
||||
{expandedView === "profile" && "Profile"}
|
||||
{expandedView === "integrations" && "Integrations"}
|
||||
</HeadingH2Bold>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{
|
||||
delay: 0.08,
|
||||
duration: 0.2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="text-white/70 hover:text-white transition-colors duration-200"
|
||||
onClick={() => {
|
||||
setIsCollapsing(true)
|
||||
setExpandedView(null)
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="max-h-[70vh] overflow-y-auto pr-2 custom-scrollbar"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{
|
||||
delay: 0.1,
|
||||
duration: 0.25,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
>
|
||||
{expandedView === "profile" && <ProfileView />}
|
||||
{expandedView === "integrations" && (
|
||||
<IntegrationsView />
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.nav>
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu with Vaul Drawer */}
|
||||
{isMobile && (
|
||||
<Drawer.Root
|
||||
open={isMobileMenuOpen || !!expandedView}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setIsMobileMenuOpen(false)
|
||||
setExpandedView(null)
|
||||
setActivePanel(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Menu Trigger Button */}
|
||||
{!isMobileMenuOpen && !expandedView && (
|
||||
<Drawer.Trigger asChild>
|
||||
<div className={`fixed bottom-8 right-6 z-100 ${mobileZIndex}`}>
|
||||
<motion.button
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="w-14 h-14 flex items-center justify-center text-white rounded-full shadow-2xl"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(true)
|
||||
setActivePanel("menu")
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
type="button"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{/* Glass effect background */}
|
||||
<div className="absolute inset-0 rounded-full">
|
||||
<GlassMenuEffect rounded="rounded-full" />
|
||||
</div>
|
||||
<svg
|
||||
className="h-6 w-6 relative z-10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title>Open menu</title>
|
||||
<path
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</motion.button>
|
||||
</div>
|
||||
</Drawer.Trigger>
|
||||
)}
|
||||
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/40 z-[60]" />
|
||||
<Drawer.Content className="bg-transparent fixed bottom-0 left-0 right-0 z-[70] outline-none">
|
||||
<Drawer.Title className="sr-only">
|
||||
{expandedView === "addUrl" && "Add Memory"}
|
||||
{expandedView === "mcp" && "Model Context Protocol"}
|
||||
{expandedView === "profile" && "Profile"}
|
||||
{!expandedView && "Menu"}
|
||||
</Drawer.Title>
|
||||
<div className="w-full flex flex-col text-sm font-medium shadow-2xl relative overflow-hidden rounded-t-3xl max-h-[80vh]">
|
||||
{/* Glass effect background */}
|
||||
<div className="absolute inset-0 rounded-t-3xl">
|
||||
<GlassMenuEffect rounded="rounded-t-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Drag Handle */}
|
||||
<div className="relative z-20 flex justify-center py-3">
|
||||
<div className="w-12 h-1 bg-white/30 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Menu content */}
|
||||
<div className="relative z-20 flex flex-col w-full px-2 pb-8">
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
mode="wait"
|
||||
onExitComplete={() => setIsCollapsing(false)}
|
||||
>
|
||||
{!expandedView ? (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="w-full flex flex-col gap-6"
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="menu-items-mobile"
|
||||
layout
|
||||
>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
transition={{ delay: 0.08 }}
|
||||
>
|
||||
<ProjectSelector />
|
||||
</motion.div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{menuItems.map((item, index) => (
|
||||
<div key={item.key}>
|
||||
<motion.button
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: 0.1 + index * 0.05,
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
},
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-2 py-2 text-white/90 hover:text-white hover:bg-white/10 rounded-lg cursor-pointer relative"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
layout
|
||||
onClick={() => {
|
||||
handleMenuItemClick(item.key)
|
||||
if (
|
||||
item.key !== "mcp" &&
|
||||
item.key !== "profile" &&
|
||||
item.key !== "integrations"
|
||||
) {
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<item.icon className="h-5 w-5 drop-shadow-lg flex-shrink-0" />
|
||||
<span className="drop-shadow-lg text-sm font-medium flex-1 text-left">
|
||||
{item.text}
|
||||
</span>
|
||||
{/* Show warning indicator for Add Memory when limits approached */}
|
||||
{shouldShowLimitWarning &&
|
||||
item.key === "addUrl" && (
|
||||
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">
|
||||
{memoriesLimit - memoriesUsed} left
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
{/* Add horizontal line after first item */}
|
||||
{index === 0 && (
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scaleX: 1,
|
||||
}}
|
||||
className="w-full h-px bg-white/20 mt-2 origin-left"
|
||||
initial={{ opacity: 0, scaleX: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: 0.15 + index * 0.05,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="w-full px-2 flex flex-col"
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="expanded-view-mobile"
|
||||
layout
|
||||
>
|
||||
<div className="flex-1">
|
||||
<motion.div
|
||||
className="flex items-center justify-between"
|
||||
layout
|
||||
>
|
||||
<HeadingH2Bold className="text-white">
|
||||
{expandedView === "addUrl" && "Add Memory"}
|
||||
{expandedView === "mcp" &&
|
||||
"Model Context Protocol"}
|
||||
{expandedView === "profile" && "Profile"}
|
||||
{expandedView === "integrations" &&
|
||||
"Integrations"}
|
||||
</HeadingH2Bold>
|
||||
<Button
|
||||
className="text-white/70 hover:text-white transition-colors duration-200"
|
||||
onClick={() => {
|
||||
setIsCollapsing(true)
|
||||
setExpandedView(null)
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
<div className="max-h-[60vh] overflow-y-auto pr-1">
|
||||
{expandedView === "addUrl" && (
|
||||
<AddMemoryExpandedView />
|
||||
)}
|
||||
{expandedView === "profile" && <ProfileView />}
|
||||
{expandedView === "integrations" && (
|
||||
<IntegrationsView />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
)}
|
||||
|
||||
{showAddMemoryView && (
|
||||
<AddMemoryView
|
||||
initialTab="note"
|
||||
onClose={() => setShowAddMemoryView(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConnectAIModal
|
||||
onOpenChange={setShowConnectAIModal}
|
||||
open={showConnectAIModal}
|
||||
>
|
||||
<Button className="hidden">Connect AI Assistant</Button>
|
||||
</ConnectAIModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Menu
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { models, type ModelId, ModelIcon } from "@/lib/models"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel?: ModelId
|
||||
onModelChange?: (modelId: ModelId) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
selectedModel = "gemini-2.5-pro",
|
||||
onModelChange,
|
||||
disabled = false,
|
||||
}: ModelSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const currentModel = models.find((m) => m.id === selectedModel) || models[0]
|
||||
|
||||
const handleModelSelect = (modelId: ModelId) => {
|
||||
onModelChange?.(modelId)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md transition-colors"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ModelIcon width={24} height={24} />
|
||||
<span className="text-xs font-medium max-w-32 truncate">
|
||||
{currentModel.name}
|
||||
</span>
|
||||
<motion.div
|
||||
animate={{ rotate: isOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
onKeyDown={(e) => e.key === "Escape" && setIsOpen(false)}
|
||||
aria-label="Close model selector"
|
||||
/>
|
||||
|
||||
<div className="absolute top-full left-0 mt-1 w-64 bg-background/95 backdrop-blur-xl border border-border rounded-md shadow-xl z-50 overflow-hidden space-y-1">
|
||||
<div className="p-1.5 space-y-1">
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
className={`flex items-center p-1 px-2 rounded-md transition-colors cursor-pointer w-full text-left ${
|
||||
selectedModel === model.id
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && handleModelSelect(model.id)
|
||||
}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{model.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
|
@ -17,6 +18,7 @@ import { useCustomer } from "autumn-js/react"
|
|||
import { useMemoriesUsage } from "@/hooks/use-memories-usage"
|
||||
import { SpaceSelector } from "../space-selector"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { addDocumentParam } from "@/lib/search-params"
|
||||
|
||||
type TabType = "note" | "link" | "file" | "connect"
|
||||
|
||||
|
|
@ -91,7 +93,6 @@ const tabs = [
|
|||
]
|
||||
|
||||
export function AddDocument({
|
||||
defaultTab,
|
||||
onClose,
|
||||
isOpen,
|
||||
}: {
|
||||
|
|
@ -100,7 +101,12 @@ export function AddDocument({
|
|||
isOpen?: boolean
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(defaultTab ?? "note")
|
||||
const [addParam, setAddParam] = useQueryState("add", addDocumentParam)
|
||||
const activeTab: TabType = addParam ?? "note"
|
||||
const setActiveTab = useCallback(
|
||||
(tab: TabType) => { setAddParam(tab) },
|
||||
[setAddParam],
|
||||
)
|
||||
const { selectedProject: globalSelectedProject } = useProject()
|
||||
const [localSelectedProject, setLocalSelectedProject] = useState<string>(
|
||||
globalSelectedProject,
|
||||
|
|
@ -136,12 +142,6 @@ export function AddDocument({
|
|||
setLocalSelectedProject(globalSelectedProject)
|
||||
}, [globalSelectedProject])
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultTab) {
|
||||
setActiveTab(defaultTab)
|
||||
}
|
||||
}, [defaultTab])
|
||||
|
||||
// Submit handlers
|
||||
const handleNoteSubmit = useCallback(
|
||||
(content: string) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
|
|
@ -40,9 +41,11 @@ import { UserMessage } from "./message/user-message"
|
|||
import { AgentMessage } from "./message/agent-message"
|
||||
import { ChainOfThought } from "./input/chain-of-thought"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { threadParam } from "@/lib/search-params"
|
||||
|
||||
const DEFAULT_SUGGESTIONS = [
|
||||
"Show me all content related to Supermemory.",
|
||||
|
|
@ -137,7 +140,14 @@ export function ChatSidebar({
|
|||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { selectedProject } = useProject()
|
||||
const { viewMode } = useViewMode()
|
||||
const [currentChatId, setCurrentChatId] = useState<string>(() => generateId())
|
||||
const { user } = useAuth()
|
||||
const [threadId, setThreadId] = useQueryState("thread", threadParam)
|
||||
const fallbackChatId = useMemo(() => generateId(), [])
|
||||
const currentChatId = threadId ?? fallbackChatId
|
||||
const setCurrentChatId = useCallback(
|
||||
(id: string) => setThreadId(id),
|
||||
[setThreadId],
|
||||
)
|
||||
const [pendingThreadLoad, setPendingThreadLoad] = useState<{
|
||||
id: string
|
||||
messages: UIMessage[]
|
||||
|
|
@ -167,13 +177,6 @@ export function ChatSidebar({
|
|||
transport: new DefaultChatTransport({
|
||||
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`,
|
||||
credentials: "include",
|
||||
body: {
|
||||
metadata: {
|
||||
projectId: selectedProject,
|
||||
model: selectedModel,
|
||||
chatId: currentChatId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
onFinish: async (result) => {
|
||||
if (result.message.role !== "assistant") return
|
||||
|
|
@ -373,11 +376,9 @@ export function ChatSidebar({
|
|||
|
||||
const handleNewChat = useCallback(() => {
|
||||
analytics.newChatCreated()
|
||||
const newId = generateId()
|
||||
setCurrentChatId(newId)
|
||||
setMessages([])
|
||||
setThreadId(null)
|
||||
setInput("")
|
||||
}, [setMessages])
|
||||
}, [setThreadId])
|
||||
|
||||
const fetchThreads = useCallback(async () => {
|
||||
setIsLoadingThreads(true)
|
||||
|
|
@ -397,37 +398,40 @@ export function ChatSidebar({
|
|||
}
|
||||
}, [selectedProject])
|
||||
|
||||
const loadThread = useCallback(async (threadId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const uiMessages = data.messages.map(
|
||||
(m: {
|
||||
id: string
|
||||
role: string
|
||||
parts: unknown
|
||||
createdAt: string
|
||||
}) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
parts: m.parts || [],
|
||||
createdAt: new Date(m.createdAt),
|
||||
}),
|
||||
const loadThread = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${id}`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
setCurrentChatId(threadId)
|
||||
setPendingThreadLoad({ id: threadId, messages: uiMessages })
|
||||
analytics.chatThreadLoaded({ thread_id: threadId })
|
||||
setIsHistoryOpen(false)
|
||||
setConfirmingDeleteId(null)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const uiMessages = data.messages.map(
|
||||
(m: {
|
||||
id: string
|
||||
role: string
|
||||
parts: unknown
|
||||
createdAt: string
|
||||
}) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
parts: m.parts || [],
|
||||
createdAt: new Date(m.createdAt),
|
||||
}),
|
||||
)
|
||||
setThreadId(id)
|
||||
setPendingThreadLoad({ id, messages: uiMessages })
|
||||
analytics.chatThreadLoaded({ thread_id: id })
|
||||
setIsHistoryOpen(false)
|
||||
setConfirmingDeleteId(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load thread:", error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load thread:", error)
|
||||
}
|
||||
}, [])
|
||||
},
|
||||
[setThreadId],
|
||||
)
|
||||
|
||||
const deleteThread = useCallback(
|
||||
async (threadId: string) => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,97 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { Streamdown } from "streamdown"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
Loader2,
|
||||
SearchIcon,
|
||||
PlusIcon,
|
||||
BookOpenIcon,
|
||||
ClockIcon,
|
||||
ListIcon,
|
||||
XCircleIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { RelatedMemories } from "./related-memories"
|
||||
import { MessageActions } from "./message-actions"
|
||||
import { FollowUpQuestions } from "./follow-up-questions"
|
||||
|
||||
const TOOL_META: Record<string, { label: string; icon: typeof SearchIcon }> = {
|
||||
searchMemories: { label: "Search Memories", icon: SearchIcon },
|
||||
addMemory: { label: "Add Memory", icon: PlusIcon },
|
||||
fetchMemory: { label: "Fetch Memory", icon: BookOpenIcon },
|
||||
scheduleTask: { label: "Schedule Task", icon: ClockIcon },
|
||||
listSchedules: { label: "List Schedules", icon: ListIcon },
|
||||
cancelSchedule: { label: "Cancel Schedule", icon: XCircleIcon },
|
||||
}
|
||||
|
||||
function ToolCallDisplay({ part }: { part: { type: string; state: string; input?: unknown; output?: unknown; toolCallId?: string } }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const toolName = part.type.replace("tool-", "")
|
||||
const meta = TOOL_META[toolName]
|
||||
const Icon = meta?.icon ?? WrenchIcon
|
||||
const label = meta?.label ?? toolName
|
||||
|
||||
const isLoading = part.state === "input-streaming" || part.state === "input-available"
|
||||
const isDone = part.state === "output-available"
|
||||
const isError = part.state === "error"
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
|
||||
expanded && "border-b border-[#1E2128]",
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-3 animate-spin text-blue-400 shrink-0" />
|
||||
) : (
|
||||
<Icon className={cn("size-3 shrink-0", isDone ? "text-emerald-400" : isError ? "text-red-400" : "text-white/50")} />
|
||||
)}
|
||||
<span className={cn("font-medium", isDone ? "text-emerald-400" : isError ? "text-red-400" : "text-blue-400")}>
|
||||
{label}
|
||||
</span>
|
||||
{isLoading && <span className="text-white/40 ml-auto">running...</span>}
|
||||
{isDone && <span className="text-white/40 ml-auto">done</span>}
|
||||
{isError && <span className="text-red-400/60 ml-auto">error</span>}
|
||||
{expanded ? (
|
||||
<ChevronDownIcon className="size-3 text-white/30 shrink-0" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3 text-white/30 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
{part.input !== undefined && (
|
||||
<div>
|
||||
<div className="text-white/40 mb-1">Input</div>
|
||||
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-40 overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{typeof part.input === "string" ? part.input : JSON.stringify(part.input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{isDone && part.output !== undefined && (
|
||||
<div>
|
||||
<div className="text-white/40 mb-1">Output</div>
|
||||
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-40 overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{typeof part.output === "string" ? part.output : JSON.stringify(part.output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AgentMessageProps {
|
||||
message: UIMessage
|
||||
index: number
|
||||
|
|
@ -68,20 +154,13 @@ export function AgentMessage({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
if (part.type === "tool-searchMemories") {
|
||||
if (
|
||||
part.state === "input-available" ||
|
||||
part.state === "input-streaming"
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-${partIndex}`}
|
||||
className="text-xs text-white italic"
|
||||
>
|
||||
Searching memories...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (part.type.startsWith("tool-")) {
|
||||
return (
|
||||
<ToolCallDisplay
|
||||
key={`${message.id}-${partIndex}`}
|
||||
part={part as any}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import type React from "react"
|
||||
import { useState } from "react"
|
||||
import { MCPIcon } from "@/components/menu"
|
||||
import {
|
||||
GoogleDocs,
|
||||
GoogleSheets,
|
||||
|
|
@ -19,6 +18,22 @@ import {
|
|||
import { Globe, FileText, Image } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
function MCPIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>ModelContextProtocol</title>
|
||||
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
|
||||
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const BRAND_COLORS: Record<string, string> = {
|
||||
google_doc: "#4285F4",
|
||||
google_sheet: "#0F9D58",
|
||||
|
|
|
|||
|
|
@ -47,10 +47,10 @@ export function Summary({
|
|||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
memoryEntries.length > 0 ? "justify-between" : "justify-end",
|
||||
memoryEntries?.length > 0 ? "justify-between" : "justify-end",
|
||||
)}
|
||||
>
|
||||
{memoryEntries.length > 0 && (
|
||||
{memoryEntries?.length > 0 && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#369BFD] line-clamp-1 flex items-center gap-1.5",
|
||||
|
|
|
|||
|
|
@ -1,24 +1,44 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type {
|
||||
DocumentsWithMemoriesResponseSchema,
|
||||
SearchResponseSchema,
|
||||
} from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import {
|
||||
SearchIcon,
|
||||
Settings,
|
||||
Home,
|
||||
Plus,
|
||||
Code2,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { DocumentIcon } from "@/components/new/document-icon"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
type SearchResult = z.infer<typeof SearchResponseSchema>["results"][number]
|
||||
|
||||
type PaletteItem =
|
||||
| { kind: "action"; id: string; label: string; icon: React.ReactNode; action: () => void }
|
||||
| { kind: "document"; doc: DocumentWithMemories }
|
||||
| { kind: "search-result"; result: SearchResult }
|
||||
|
||||
interface DocumentsCommandPaletteProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
projectId: string
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
onAddMemory?: () => void
|
||||
onOpenMCP?: () => void
|
||||
initialSearch?: string
|
||||
}
|
||||
|
||||
|
|
@ -27,17 +47,66 @@ export function DocumentsCommandPalette({
|
|||
onOpenChange,
|
||||
projectId,
|
||||
onOpenDocument,
|
||||
onAddMemory,
|
||||
onOpenMCP,
|
||||
initialSearch = "",
|
||||
}: DocumentsCommandPaletteProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const [search, setSearch] = useState("")
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [documents, setDocuments] = useState<DocumentWithMemories[]>([])
|
||||
const [cachedDocs, setCachedDocs] = useState<DocumentWithMemories[]>([])
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Get documents from the existing query cache when dialog opens
|
||||
const close = useCallback((then?: () => void) => {
|
||||
onOpenChange(false)
|
||||
setSearch("")
|
||||
setSearchResults([])
|
||||
if (then) setTimeout(then, 0)
|
||||
}, [onOpenChange])
|
||||
|
||||
const actions: PaletteItem[] = [
|
||||
{
|
||||
kind: "action",
|
||||
id: "home",
|
||||
label: "Go to Home",
|
||||
icon: <Home className="size-4 text-[#737373]" />,
|
||||
action: () => close(() => router.push("/")),
|
||||
},
|
||||
{
|
||||
kind: "action",
|
||||
id: "settings",
|
||||
label: "Go to Settings",
|
||||
icon: <Settings className="size-4 text-[#737373]" />,
|
||||
action: () => close(() => router.push("/settings")),
|
||||
},
|
||||
...(onAddMemory
|
||||
? [{
|
||||
kind: "action" as const,
|
||||
id: "add-memory",
|
||||
label: "Add Memory",
|
||||
icon: <Plus className="size-4 text-[#737373]" />,
|
||||
action: () => { close(); onAddMemory() },
|
||||
}]
|
||||
: []),
|
||||
...(onOpenMCP
|
||||
? [{
|
||||
kind: "action" as const,
|
||||
id: "mcp",
|
||||
label: "Open MCP",
|
||||
icon: <Code2 className="size-4 text-[#737373]" />,
|
||||
action: () => { close(); onOpenMCP() },
|
||||
}]
|
||||
: []),
|
||||
]
|
||||
|
||||
// Load cached docs when opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const queryData = queryClient.getQueryData<{
|
||||
|
|
@ -46,63 +115,215 @@ export function DocumentsCommandPalette({
|
|||
}>(["documents-with-memories", projectId])
|
||||
|
||||
if (queryData?.pages) {
|
||||
setDocuments(queryData.pages.flatMap((page) => page.documents ?? []))
|
||||
setCachedDocs(queryData.pages.flatMap((page) => page.documents ?? []))
|
||||
}
|
||||
setTimeout(() => inputRef.current?.focus(), 0)
|
||||
setSearch(initialSearch)
|
||||
setSelectedIndex(0)
|
||||
setSearchResults([])
|
||||
}
|
||||
}, [open, queryClient, projectId, initialSearch])
|
||||
|
||||
const filteredDocuments = useMemo(() => {
|
||||
if (!search.trim()) return documents
|
||||
const searchLower = search.toLowerCase()
|
||||
return documents.filter((doc) =>
|
||||
doc.title?.toLowerCase().includes(searchLower),
|
||||
)
|
||||
}, [documents, search])
|
||||
|
||||
// Reset selection when filtered results change
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value)
|
||||
setSelectedIndex(0)
|
||||
}, [])
|
||||
|
||||
// Scroll selected item into view
|
||||
// Debounced semantic search
|
||||
useEffect(() => {
|
||||
const selectedElement = listRef.current?.querySelector(
|
||||
`[data-index="${selectedIndex}"]`,
|
||||
)
|
||||
selectedElement?.scrollIntoView({ block: "nearest" })
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
|
||||
if (!search.trim()) {
|
||||
setSearchResults([])
|
||||
setIsSearching(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
try {
|
||||
const res = await $fetch("@post/search", {
|
||||
body: {
|
||||
q: search.trim(),
|
||||
limit: 10,
|
||||
containerTags: projectId ? [projectId] : undefined,
|
||||
includeSummary: true,
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!controller.signal.aborted && res.data) {
|
||||
setSearchResults(res.data.results)
|
||||
}
|
||||
} catch {
|
||||
// aborted or failed - ignore
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setIsSearching(false)
|
||||
}
|
||||
}, 250)
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [search, projectId])
|
||||
|
||||
// Build the item list
|
||||
const hasQuery = search.trim().length > 0
|
||||
const items: PaletteItem[] = []
|
||||
|
||||
if (hasQuery) {
|
||||
for (const r of searchResults) {
|
||||
items.push({ kind: "search-result", result: r })
|
||||
}
|
||||
const q = search.toLowerCase()
|
||||
for (const a of actions) {
|
||||
if (a.kind === "action" && a.label.toLowerCase().includes(q)) items.push(a)
|
||||
}
|
||||
} else {
|
||||
for (const doc of cachedDocs.slice(0, 10)) items.push({ kind: "document", doc })
|
||||
for (const a of actions) items.push(a)
|
||||
}
|
||||
|
||||
// Reset selection on items change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [search, searchResults.length])
|
||||
|
||||
// Scroll selected into view
|
||||
useEffect(() => {
|
||||
listRef.current
|
||||
?.querySelector(`[data-index="${selectedIndex}"]`)
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
}, [selectedIndex])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(document: DocumentWithMemories) => {
|
||||
if (!document.id) return
|
||||
onOpenDocument(document)
|
||||
onOpenChange(false)
|
||||
setSearch("")
|
||||
(item: PaletteItem) => {
|
||||
if (item.kind === "action") {
|
||||
item.action()
|
||||
} else if (item.kind === "document") {
|
||||
if (!item.doc.id) return
|
||||
onOpenDocument(item.doc)
|
||||
close()
|
||||
} else {
|
||||
// search result -> convert to DocumentWithMemories shape for the modal
|
||||
onOpenDocument({
|
||||
id: item.result.documentId,
|
||||
title: item.result.title,
|
||||
type: item.result.type,
|
||||
createdAt: item.result.createdAt as unknown as string,
|
||||
updatedAt: item.result.updatedAt as unknown as string,
|
||||
url: (item.result.metadata?.url as string) ?? null,
|
||||
content: item.result.content ?? item.result.chunks?.[0]?.content ?? null,
|
||||
summary: item.result.summary ?? null,
|
||||
} as unknown as DocumentWithMemories)
|
||||
close()
|
||||
}
|
||||
},
|
||||
[onOpenDocument, onOpenChange],
|
||||
[onOpenDocument, close],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => (i < filteredDocuments.length - 1 ? i + 1 : i))
|
||||
setSelectedIndex((i) => Math.min(i + 1, items.length - 1))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : i))
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const document = filteredDocuments[selectedIndex]
|
||||
if (document) handleSelect(document)
|
||||
const item = items[selectedIndex]
|
||||
if (item) handleSelect(item)
|
||||
}
|
||||
},
|
||||
[filteredDocuments, selectedIndex, handleSelect],
|
||||
[items, selectedIndex, handleSelect],
|
||||
)
|
||||
|
||||
function renderItem(item: PaletteItem, index: number) {
|
||||
const isSelected = index === selectedIndex
|
||||
const baseClass = cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-[#293952]/40"
|
||||
: "opacity-70 hover:opacity-100 hover:bg-[#293952]/40",
|
||||
)
|
||||
|
||||
if (item.kind === "action") {
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-index={index}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={baseClass}
|
||||
>
|
||||
<div className="flex items-center justify-center size-5 shrink-0">
|
||||
{item.icon}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-white">{item.label}</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const title =
|
||||
item.kind === "document" ? item.doc.title : item.result.title
|
||||
const type =
|
||||
item.kind === "document" ? item.doc.type : item.result.type
|
||||
const url =
|
||||
item.kind === "document"
|
||||
? item.doc.url
|
||||
: (item.result.metadata?.url as string) ?? null
|
||||
const date =
|
||||
item.kind === "document"
|
||||
? item.doc.createdAt
|
||||
: item.result.createdAt
|
||||
const key =
|
||||
item.kind === "document" ? item.doc.id : item.result.documentId
|
||||
const snippet =
|
||||
item.kind === "search-result"
|
||||
? item.result.chunks?.find((c) => c.isRelevant)?.content
|
||||
: null
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
data-index={index}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={baseClass}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center size-5 rounded-md shrink-0"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #14161A 0%, #0D0F12 100%)",
|
||||
boxShadow:
|
||||
"inset 0px 1px 1px rgba(255,255,255,0.03), inset 0px -1px 1px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
<DocumentIcon type={type} url={url} className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex gap-1 justify-between items-center">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{title || "Untitled"}
|
||||
</p>
|
||||
<p className="text-xs text-[#737373] text-nowrap">
|
||||
{new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{snippet && (
|
||||
<p className="text-xs text-[#737373] truncate mt-0.5">
|
||||
{snippet.slice(0, 120)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
|
|
@ -120,19 +341,20 @@ export function DocumentsCommandPalette({
|
|||
showCloseButton={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<DialogTitle className="sr-only">Search Documents</DialogTitle>
|
||||
<DialogTitle className="sr-only">Search</DialogTitle>
|
||||
|
||||
<div
|
||||
id="search-input-container"
|
||||
className="flex items-center gap-3 px-4 py-3"
|
||||
>
|
||||
<SearchIcon className="size-4 text-[#737373] shrink-0" />
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
{isSearching ? (
|
||||
<Loader2 className="size-4 text-[#737373] shrink-0 animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-4 text-[#737373] shrink-0" />
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search documents by title..."
|
||||
placeholder="Type to search your memories..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className={cn(
|
||||
"flex-1 bg-transparent text-white text-sm placeholder:text-[#737373] outline-none",
|
||||
dmSansClassName(),
|
||||
|
|
@ -142,66 +364,41 @@ export function DocumentsCommandPalette({
|
|||
|
||||
<div
|
||||
ref={listRef}
|
||||
id="search-results"
|
||||
className="flex flex-col min-h-[300px] max-h-[400px] overflow-y-auto py-1.5 px-1.5"
|
||||
className="flex flex-col min-h-[200px] max-h-[400px] overflow-y-auto py-1.5 px-1.5"
|
||||
>
|
||||
{filteredDocuments.length === 0 ? (
|
||||
{!hasQuery && cachedDocs.length > 0 && (
|
||||
<p className="px-3 pt-1 pb-1.5 text-[10px] uppercase tracking-wider text-[#737373]">
|
||||
Recent
|
||||
</p>
|
||||
)}
|
||||
{hasQuery && searchResults.length > 0 && (
|
||||
<p className="px-3 pt-1 pb-1.5 text-[10px] uppercase tracking-wider text-[#737373]">
|
||||
Results
|
||||
</p>
|
||||
)}
|
||||
{items
|
||||
.map((item, i) => ({ item, globalIndex: i }))
|
||||
.filter(({ item }) => item.kind !== "action")
|
||||
.map(({ item, globalIndex }) => renderItem(item, globalIndex))}
|
||||
|
||||
{items.some((i) => i.kind === "action") && (
|
||||
<p className="px-3 pt-3 pb-1.5 text-[10px] uppercase tracking-wider text-[#737373]">
|
||||
Actions
|
||||
</p>
|
||||
)}
|
||||
{items
|
||||
.map((item, i) => ({ item, globalIndex: i }))
|
||||
.filter(({ item }) => item.kind === "action")
|
||||
.map(({ item, globalIndex }) => renderItem(item, globalIndex))}
|
||||
|
||||
{hasQuery && !isSearching && searchResults.length === 0 && items.every((i) => i.kind === "action") && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-[#737373] text-sm">No documents found</p>
|
||||
<p className="text-[#737373] text-sm">No results found</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredDocuments.map((doc, index) => {
|
||||
const isSelected = index === selectedIndex
|
||||
return (
|
||||
<button
|
||||
key={doc.id}
|
||||
type="button"
|
||||
data-index={index}
|
||||
onClick={() => handleSelect(doc)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-[#293952]/40"
|
||||
: "opacity-70 hover:opacity-100 hover:bg-[#293952]/40",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center size-5 rounded-md shrink-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #14161A 0%, #0D0F12 100%)",
|
||||
boxShadow:
|
||||
"inset 0px 1px 1px rgba(255,255,255,0.03), inset 0px -1px 1px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
<DocumentIcon
|
||||
type={doc.type}
|
||||
url={doc.url}
|
||||
className="size-4"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex gap-1 justify-between items-center">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{doc.title || "Untitled"}
|
||||
</p>
|
||||
<p className="text-xs text-[#737373] text-nowrap">
|
||||
{new Date(doc.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="search-footer"
|
||||
className="flex items-center justify-between px-4 py-2.5 text-[11px] text-[#737373]"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 text-[11px] text-[#737373]">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex gap-0.5">
|
||||
|
|
@ -227,7 +424,9 @@ export function DocumentsCommandPalette({
|
|||
<span>Close</span>
|
||||
</span>
|
||||
</div>
|
||||
<span>{filteredDocuments.length} documents</span>
|
||||
{hasQuery && searchResults.length > 0 && (
|
||||
<span>{searchResults.length} results</span>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { memo, useState, useCallback, useRef } from "react"
|
||||
import { memo, useCallback, useRef } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import Image from "next/image"
|
||||
import { MemoryGraph } from "./memory-graph/memory-graph"
|
||||
import { useProject } from "@/stores"
|
||||
|
|
@ -9,6 +10,7 @@ import { Button } from "@ui/components/button"
|
|||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { ShareModal } from "./share-modal"
|
||||
import { shareParam } from "@/lib/search-params"
|
||||
|
||||
interface GraphLayoutViewProps {
|
||||
isChatOpen: boolean
|
||||
|
|
@ -17,18 +19,18 @@ interface GraphLayoutViewProps {
|
|||
export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
|
||||
const { selectedProject } = useProject()
|
||||
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useQueryState("share", shareParam)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
const containerTags = selectedProject ? [selectedProject] : undefined
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
setIsShareModalOpen(true)
|
||||
}, [])
|
||||
}, [setIsShareModalOpen])
|
||||
|
||||
const handleCloseShareModal = useCallback(() => {
|
||||
setIsShareModalOpen(false)
|
||||
}, [])
|
||||
}, [setIsShareModalOpen])
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-[calc(100vh-86px)]">
|
||||
|
|
|
|||
|
|
@ -36,9 +36,10 @@ import Link from "next/link"
|
|||
import { SpaceSelector } from "./space-selector"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { useState } from "react"
|
||||
import { FeedbackModal } from "./feedback-modal"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { feedbackParam } from "@/lib/search-params"
|
||||
|
||||
interface HeaderProps {
|
||||
onAddMemory?: () => void
|
||||
|
|
@ -59,12 +60,12 @@ export function Header({
|
|||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const { resetOrgOnboarded } = useOrgOnboarding()
|
||||
const [isFeedbackOpen, setIsFeedbackOpen] = useState(false)
|
||||
const [isFeedbackOpen, setIsFeedbackOpen] = useQueryState("feedback", feedbackParam)
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
|
||||
const handleTryOnboarding = () => {
|
||||
resetOrgOnboarded()
|
||||
router.push("/new/onboarding?step=input&flow=welcome")
|
||||
router.push("/onboarding?step=input&flow=welcome")
|
||||
}
|
||||
|
||||
const handleFeedback = () => {
|
||||
|
|
@ -113,7 +114,7 @@ export function Header({
|
|||
asChild
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<Link href="/new">
|
||||
<Link href="/">
|
||||
<Home className="h-4 w-4 text-[#737373]" />
|
||||
Home
|
||||
</Link>
|
||||
|
|
@ -241,7 +242,7 @@ export function Header({
|
|||
Feedback
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push("/new/settings")}
|
||||
onClick={() => router.push("/settings")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4 text-[#737373]" />
|
||||
|
|
@ -347,7 +348,7 @@ export function Header({
|
|||
</div>
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push("/new/settings")}
|
||||
onClick={() => router.push("/settings")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4 text-[#737373]" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQueryState, parseAsString, parseAsStringLiteral, parseAsInteger } from "nuqs"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -35,13 +36,20 @@ interface MCPStepsProps {
|
|||
}
|
||||
|
||||
export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
||||
const [selectedClient, setSelectedClient] = useState<
|
||||
keyof typeof clients | null
|
||||
>(null)
|
||||
const [selectedClient, setSelectedClient] = useQueryState(
|
||||
"mcpClient",
|
||||
parseAsString,
|
||||
)
|
||||
const [selectedProject] = useState<string>("sm_project_default")
|
||||
const [mcpUrlTab, setMcpUrlTab] = useState<"oneClick" | "manual">("oneClick")
|
||||
const [mcpUrlTab, setMcpUrlTab] = useQueryState(
|
||||
"mcpTab",
|
||||
parseAsStringLiteral(["oneClick", "manual"] as const).withDefault("oneClick"),
|
||||
)
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const [activeStep, setActiveStep] = useState<1 | 2 | 3>(1)
|
||||
const [activeStep, setActiveStep] = useQueryState(
|
||||
"mcpStep",
|
||||
parseAsInteger.withDefault(1),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
analytics.mcpViewOpened()
|
||||
|
|
@ -170,7 +178,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
{selectedClient && (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setSelectedClient(value as keyof typeof clients)
|
||||
setSelectedClient(value)
|
||||
setActiveStep(2)
|
||||
}}
|
||||
value={selectedClient || undefined}
|
||||
|
|
@ -185,7 +193,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
{selectedClient ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
alt={clients[selectedClient]}
|
||||
alt={clients[selectedClient as keyof typeof clients]}
|
||||
height={20}
|
||||
width={20}
|
||||
unoptimized
|
||||
|
|
@ -195,7 +203,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
: `/mcp-supported-tools/${selectedClient === "claude-code" ? "claude" : selectedClient}.png`
|
||||
}
|
||||
/>
|
||||
<span>{clients[selectedClient]}</span>
|
||||
<span>{clients[selectedClient as keyof typeof clients]}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder="Select a client" />
|
||||
|
|
@ -243,7 +251,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
|
|||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedClient(key as keyof typeof clients)
|
||||
setSelectedClient(key)
|
||||
setActiveStep(2)
|
||||
}}
|
||||
className={`mcp-client-button-group py-[6px] pl-2 pr-3 rounded-full border transition-colors cursor-pointer duration-200 ${
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { $fetch } from "@repo/lib/api"
|
|||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
|
||||
import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import type { z } from "zod"
|
||||
import { Masonry, useInfiniteLoader } from "masonic"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
|
@ -28,6 +29,7 @@ import { QuickNoteCard } from "./quick-note-card"
|
|||
import { HighlightsCard, type HighlightItem } from "./highlights-card"
|
||||
import { GraphCard } from "./memory-graph"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { categoriesParam } from "@/lib/search-params"
|
||||
|
||||
// Document category type
|
||||
type DocumentCategory =
|
||||
|
|
@ -95,9 +97,10 @@ export function MemoriesGrid({
|
|||
const { user } = useAuth()
|
||||
const { selectedProject } = useProject()
|
||||
const isMobile = useIsMobile()
|
||||
const [selectedCategories, setSelectedCategories] = useState<
|
||||
DocumentCategory[]
|
||||
>([])
|
||||
const [selectedCategories, setSelectedCategories] = useQueryState(
|
||||
"categories",
|
||||
categoriesParam,
|
||||
)
|
||||
|
||||
const { data: facetsData } = useQuery({
|
||||
queryKey: ["document-facets", selectedProject],
|
||||
|
|
@ -166,18 +169,23 @@ export function MemoriesGrid({
|
|||
enabled: !!user,
|
||||
})
|
||||
|
||||
const handleCategoryToggle = useCallback((category: DocumentCategory) => {
|
||||
setSelectedCategories((prev) => {
|
||||
if (prev.includes(category)) {
|
||||
return prev.filter((c) => c !== category)
|
||||
}
|
||||
return [...prev, category]
|
||||
})
|
||||
}, [])
|
||||
const handleCategoryToggle = useCallback(
|
||||
(category: DocumentCategory) => {
|
||||
setSelectedCategories((prev) => {
|
||||
const current = prev ?? []
|
||||
if (current.includes(category)) {
|
||||
const next = current.filter((c) => c !== category)
|
||||
return next.length === 0 ? null : next
|
||||
}
|
||||
return [...current, category]
|
||||
})
|
||||
},
|
||||
[setSelectedCategories],
|
||||
)
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setSelectedCategories([])
|
||||
}, [])
|
||||
setSelectedCategories(null)
|
||||
}, [setSelectedCategories])
|
||||
|
||||
const documents = useMemo(() => {
|
||||
return (
|
||||
|
|
@ -420,10 +428,12 @@ const DocumentCard = memo(
|
|||
.ogImage
|
||||
const needsOgData =
|
||||
document.url &&
|
||||
document.type !== "notion_doc" &&
|
||||
!document.url.includes("x.com") &&
|
||||
!document.url.includes("twitter.com") &&
|
||||
!document.url.includes("files.supermemory.ai") &&
|
||||
!document.url.includes("docs.googleapis.com") &&
|
||||
!document.url.includes("notion.so") &&
|
||||
(!document.title || !ogImage)
|
||||
|
||||
const hideURL = document.url?.includes("docs.googleapis.com")
|
||||
|
|
@ -433,19 +443,17 @@ const DocumentCard = memo(
|
|||
setIsLoadingOg(true)
|
||||
fetch(`/api/og?url=${encodeURIComponent(document.url)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) return null
|
||||
if (!res.ok) throw new Error("Failed")
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
setOgData({
|
||||
title: data.title,
|
||||
image: data.image,
|
||||
})
|
||||
}
|
||||
setOgData({
|
||||
title: data?.title,
|
||||
image: data?.image,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail if OG fetch fails
|
||||
setOgData({})
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingOg(false)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
import { DefaultChatTransport } from "ai"
|
||||
import { useAgent } from "agents/react"
|
||||
import { useAgentChat } from "@cloudflare/ai-chat/react"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
|
|
@ -76,21 +76,28 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
|
|||
const isProcessingRef = useRef(false)
|
||||
const draftRequestIdRef = useRef(0)
|
||||
|
||||
const backendUrl = new URL(process.env.NEXT_PUBLIC_BACKEND_URL!)
|
||||
const agent = useAgent({
|
||||
agent: "chat-agent",
|
||||
name: user?.id ?? "anonymous",
|
||||
host: backendUrl.host,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
agent.setState({
|
||||
model: "gemini-2.5-pro" as const,
|
||||
projectId: selectedProject,
|
||||
})
|
||||
}, [agent, selectedProject])
|
||||
|
||||
const {
|
||||
messages: chatMessages,
|
||||
sendMessage,
|
||||
status,
|
||||
} = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`,
|
||||
credentials: "include",
|
||||
body: {
|
||||
metadata: {
|
||||
projectId: selectedProject,
|
||||
model: "gemini-2.5-pro",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} = useAgentChat({
|
||||
agent,
|
||||
getInitialMessages: null,
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
const buildOnboardingContext = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export function IntegrationsStep() {
|
|||
const handleContinue = () => {
|
||||
markOrgOnboarded()
|
||||
analytics.onboardingCompleted()
|
||||
router.push("/new")
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
if (selectedCard === "Connect to AI") {
|
||||
|
|
@ -177,7 +177,7 @@ export function IntegrationsStep() {
|
|||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
|
||||
onClick={() => router.push("/new/onboarding/setup?step=relatable")}
|
||||
onClick={() => router.push("/onboarding/setup?step=relatable")}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function RelatableQuestion() {
|
|||
(idx) => relatableOptions[idx]?.text || "",
|
||||
)
|
||||
analytics.onboardingRelatableSelected({ options: selectedTexts })
|
||||
router.push("/new/onboarding/setup?step=integrations")
|
||||
router.push("/onboarding/setup?step=integrations")
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -42,11 +42,11 @@ export function OnboardingContentStep({
|
|||
const router = useRouter()
|
||||
|
||||
const handleContinue = () => {
|
||||
router.push("/new/onboarding/welcome?step=features")
|
||||
router.push("/onboarding/welcome?step=features")
|
||||
}
|
||||
|
||||
const handleAddMemories = () => {
|
||||
router.push("/new/onboarding/welcome?step=memories")
|
||||
router.push("/onboarding/welcome?step=memories")
|
||||
}
|
||||
|
||||
const isContinue = currentView === "continue"
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) {
|
|||
description_length: description.trim().length,
|
||||
})
|
||||
onSubmit(formData)
|
||||
router.push("/new/onboarding/setup?step=relatable")
|
||||
router.push("/onboarding/setup?step=relatable")
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Fetching..." : "Remember this →"}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ export default function Account() {
|
|||
try {
|
||||
await autumn.attach({
|
||||
productId: "api_pro",
|
||||
successUrl: "https://app.supermemory.ai/new/settings#account",
|
||||
successUrl: "https://app.supermemory.ai/settings#account",
|
||||
})
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ export default function ConnectionsMCP() {
|
|||
try {
|
||||
await autumn.attach({
|
||||
productId: "api_pro",
|
||||
successUrl: "https://app.supermemory.ai/new/settings#connections",
|
||||
successUrl: "https://app.supermemory.ai/settings#connections",
|
||||
})
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@ui/components/dialog"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
export function NewOnboardingModal() {
|
||||
const router = useRouter()
|
||||
const flagEnabled = true
|
||||
const isMobile = useIsMobile()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (flagEnabled) {
|
||||
setOpen(true)
|
||||
}
|
||||
}, [flagEnabled])
|
||||
|
||||
const handleContinue = () => {
|
||||
setOpen(false)
|
||||
router.push("/new/onboarding")
|
||||
}
|
||||
|
||||
if (!flagEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Experience the new onboarding</DialogTitle>
|
||||
<DialogDescription>
|
||||
We've redesigned the onboarding experience. Would you like to try
|
||||
it?
|
||||
{isMobile && (
|
||||
<span className="block mt-2 text-yellow-600 dark:text-yellow-500">
|
||||
Desktop view is recommended for the best experience.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Stay here
|
||||
</Button>
|
||||
<Button onClick={handleContinue}>Continue to new onboarding</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { NewOnboardingModal } from "./new-onboarding-modal"
|
||||
|
||||
export function OnboardingWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<NewOnboardingModal />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,443 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@repo/lib/api"
|
||||
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@repo/ui/components/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@repo/ui/components/dropdown-menu"
|
||||
import { Label } from "@repo/ui/components/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/ui/components/select"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import {
|
||||
ChevronDown,
|
||||
FolderIcon,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useState } from "react"
|
||||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
import { useProjectName } from "@/hooks/use-project-name"
|
||||
import { useProject } from "@/stores"
|
||||
import type { Project } from "@repo/lib/types"
|
||||
import { CreateProjectDialog } from "./create-project-dialog"
|
||||
|
||||
export function ProjectSelector() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const { selectedProject } = useProject()
|
||||
const projectName = useProjectName()
|
||||
const { switchProject, deleteProjectMutation } = useProjectMutations()
|
||||
const [deleteDialog, setDeleteDialog] = useState<{
|
||||
open: boolean
|
||||
project: null | { id: string; name: string; containerTag: string }
|
||||
action: "move" | "delete"
|
||||
targetProjectId: string
|
||||
}>({
|
||||
open: false,
|
||||
project: null,
|
||||
action: "move",
|
||||
targetProjectId: DEFAULT_PROJECT_ID,
|
||||
})
|
||||
|
||||
const { data: projects = [], isLoading } = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@get/projects")
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to load projects")
|
||||
}
|
||||
|
||||
return response.data?.projects || []
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
const selectedProjectData = projects.find(
|
||||
(p: Project) => p.containerTag === selectedProject,
|
||||
)
|
||||
const selectedEmoji = selectedProjectData?.emoji
|
||||
|
||||
const handleProjectSelect = (containerTag: string) => {
|
||||
switchProject(containerTag)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleCreateNewProject = () => {
|
||||
setIsOpen(false)
|
||||
setShowCreateDialog(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md transition-colors"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{selectedEmoji ? (
|
||||
<span className="text-sm">{selectedEmoji}</span>
|
||||
) : (
|
||||
<FolderIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="text-xs font-medium max-w-32 truncate">
|
||||
{isLoading ? "..." : projectName}
|
||||
</span>
|
||||
<motion.div
|
||||
animate={{ rotate: isOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
className="fixed inset-0 z-40"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="absolute top-full left-0 mt-1 w-56 bg-background/95 backdrop-blur-xl border border-border rounded-md shadow-xl z-50 overflow-hidden"
|
||||
initial={{ opacity: 0, y: -5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -5, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="p-1.5 max-h-64 overflow-y-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`flex items-center w-full justify-between p-2 rounded-md transition-colors cursor-pointer ${
|
||||
selectedProject === DEFAULT_PROJECT_ID
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/20"
|
||||
}`}
|
||||
onClick={() => handleProjectSelect(DEFAULT_PROJECT_ID)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderIcon className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">Default Project</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* User Projects */}
|
||||
{projects
|
||||
.filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID)
|
||||
.map((project: Project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={`flex items-center justify-between p-2 rounded-md transition-colors group ${
|
||||
selectedProject === project.containerTag
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleProjectSelect(project.containerTag)
|
||||
}
|
||||
>
|
||||
{project.emoji ? (
|
||||
<span className="text-sm">{project.emoji}</span>
|
||||
) : (
|
||||
<FolderIcon className="h-3.5 w-3.5 opacity-70" />
|
||||
)}
|
||||
<span className="text-xs font-medium truncate max-w-32">
|
||||
{project.name}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-xs hover:text-red-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteDialog({
|
||||
open: true,
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
containerTag: project.containerTag,
|
||||
},
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete Project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
className="flex items-center gap-2 p-2 rounded-md hover:bg-accent/50 transition-colors cursor-pointer border-t border-border mt-1"
|
||||
onClick={handleCreateNewProject}
|
||||
whileHover={{ x: 1 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: (projects.length + 1) * 0.03 }}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-foreground/70" />
|
||||
<span className="text-xs font-medium text-foreground/80">
|
||||
New Project
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<CreateProjectDialog
|
||||
open={showCreateDialog}
|
||||
onOpenChange={setShowCreateDialog}
|
||||
/>
|
||||
|
||||
{/* Delete Project Dialog */}
|
||||
<AnimatePresence>
|
||||
{deleteDialog.open && deleteDialog.project && (
|
||||
<Dialog
|
||||
onOpenChange={(open) =>
|
||||
setDeleteDialog((prev) => ({ ...prev, open }))
|
||||
}
|
||||
open={deleteDialog.open}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteDialog.project.name}
|
||||
"? Choose what to do with the documents in this project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
checked={deleteDialog.action === "move"}
|
||||
className="w-4 h-4"
|
||||
id="move"
|
||||
name="action"
|
||||
onChange={() =>
|
||||
setDeleteDialog((prev) => ({
|
||||
...prev,
|
||||
action: "move",
|
||||
}))
|
||||
}
|
||||
type="radio"
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm" htmlFor="move">
|
||||
Move documents to another project
|
||||
</Label>
|
||||
</div>
|
||||
{deleteDialog.action === "move" && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
className="ml-6"
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
setDeleteDialog((prev) => ({
|
||||
...prev,
|
||||
targetProjectId: value,
|
||||
}))
|
||||
}
|
||||
value={deleteDialog.targetProjectId}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select target project..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
value={
|
||||
projects.find(
|
||||
(p) => p.containerTag === DEFAULT_PROJECT_ID,
|
||||
)?.id || ""
|
||||
}
|
||||
>
|
||||
Default Project
|
||||
</SelectItem>
|
||||
{projects
|
||||
.filter(
|
||||
(p: Project) =>
|
||||
p.id !== deleteDialog.project?.id &&
|
||||
p.containerTag !== DEFAULT_PROJECT_ID,
|
||||
)
|
||||
.map((project: Project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
checked={deleteDialog.action === "delete"}
|
||||
className="w-4 h-4"
|
||||
id="delete"
|
||||
name="action"
|
||||
onChange={() =>
|
||||
setDeleteDialog((prev) => ({
|
||||
...prev,
|
||||
action: "delete",
|
||||
}))
|
||||
}
|
||||
type="radio"
|
||||
/>
|
||||
<Label
|
||||
className="cursor-pointer text-sm"
|
||||
htmlFor="delete"
|
||||
>
|
||||
Delete all documents in this project
|
||||
</Label>
|
||||
</div>
|
||||
{deleteDialog.action === "delete" && (
|
||||
<motion.p
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-sm text-red-600 dark:text-red-400 ml-6"
|
||||
initial={{ opacity: 0 }}
|
||||
>
|
||||
⚠️ This action cannot be undone. All documents will be
|
||||
permanently deleted.
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setDeleteDialog({
|
||||
open: false,
|
||||
project: null,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
className={
|
||||
deleteDialog.action === "delete"
|
||||
? "bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white"
|
||||
: ""
|
||||
}
|
||||
disabled={
|
||||
deleteProjectMutation.isPending ||
|
||||
(deleteDialog.action === "move" &&
|
||||
!deleteDialog.targetProjectId)
|
||||
}
|
||||
onClick={() => {
|
||||
if (deleteDialog.project) {
|
||||
deleteProjectMutation.mutate(
|
||||
{
|
||||
projectId: deleteDialog.project.id,
|
||||
action: deleteDialog.action,
|
||||
targetProjectId:
|
||||
deleteDialog.action === "move"
|
||||
? deleteDialog.targetProjectId
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDeleteDialog({
|
||||
open: false,
|
||||
project: null,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{deleteProjectMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
{deleteDialog.action === "move"
|
||||
? "Moving..."
|
||||
: "Deleting..."}
|
||||
</>
|
||||
) : deleteDialog.action === "move" ? (
|
||||
"Move & Delete Project"
|
||||
) : (
|
||||
"Delete Everything"
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</DialogFooter>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
"use client"
|
||||
import { cn } from "@lib/utils"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import type {
|
||||
TargetAndTransition,
|
||||
Transition,
|
||||
Variant,
|
||||
Variants,
|
||||
} from "motion/react"
|
||||
import React from "react"
|
||||
|
||||
export type PresetType = "blur" | "fade-in-blur" | "scale" | "fade" | "slide"
|
||||
|
||||
export type PerType = "word" | "char" | "line"
|
||||
|
||||
export type TextEffectProps = {
|
||||
children: string
|
||||
per?: PerType
|
||||
as?: keyof React.JSX.IntrinsicElements
|
||||
variants?: {
|
||||
container?: Variants
|
||||
item?: Variants
|
||||
}
|
||||
className?: string
|
||||
preset?: PresetType
|
||||
delay?: number
|
||||
speedReveal?: number
|
||||
speedSegment?: number
|
||||
trigger?: boolean
|
||||
onAnimationComplete?: () => void
|
||||
onAnimationStart?: () => void
|
||||
segmentWrapperClassName?: string
|
||||
containerTransition?: Transition
|
||||
segmentTransition?: Transition
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const defaultStaggerTimes: Record<PerType, number> = {
|
||||
char: 0.03,
|
||||
word: 0.05,
|
||||
line: 0.1,
|
||||
}
|
||||
|
||||
const defaultContainerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
transition: { staggerChildren: 0.05, staggerDirection: -1 },
|
||||
},
|
||||
}
|
||||
|
||||
const defaultItemVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
},
|
||||
exit: { opacity: 0 },
|
||||
}
|
||||
|
||||
const presetVariants: Record<
|
||||
PresetType,
|
||||
{ container: Variants; item: Variants }
|
||||
> = {
|
||||
blur: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, filter: "blur(12px)" },
|
||||
visible: { opacity: 1, filter: "blur(0px)" },
|
||||
exit: { opacity: 0, filter: "blur(12px)" },
|
||||
},
|
||||
},
|
||||
"fade-in-blur": {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, y: 20, filter: "blur(12px)" },
|
||||
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
exit: { opacity: 0, y: 20, filter: "blur(12px)" },
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, scale: 0 },
|
||||
visible: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0 },
|
||||
},
|
||||
},
|
||||
fade: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
},
|
||||
},
|
||||
slide: {
|
||||
container: defaultContainerVariants,
|
||||
item: {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: 20 },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const AnimationComponent: React.FC<{
|
||||
segment: string
|
||||
variants: Variants
|
||||
per: "line" | "word" | "char"
|
||||
segmentWrapperClassName?: string
|
||||
}> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => {
|
||||
const content =
|
||||
per === "line" ? (
|
||||
<motion.span variants={variants} className="block">
|
||||
{segment}
|
||||
</motion.span>
|
||||
) : per === "word" ? (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
variants={variants}
|
||||
className="inline-block whitespace-pre"
|
||||
>
|
||||
{segment}
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span className="inline-block whitespace-pre">
|
||||
{segment.split("").map((char, charIndex) => (
|
||||
<motion.span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Character position is stable within animation
|
||||
key={`char-${charIndex}`}
|
||||
aria-hidden="true"
|
||||
variants={variants}
|
||||
className="inline-block whitespace-pre"
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
)
|
||||
|
||||
if (!segmentWrapperClassName) {
|
||||
return content
|
||||
}
|
||||
|
||||
const defaultWrapperClassName = per === "line" ? "block" : "inline-block"
|
||||
|
||||
return (
|
||||
<span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
AnimationComponent.displayName = "AnimationComponent"
|
||||
|
||||
const splitText = (text: string, per: PerType) => {
|
||||
if (per === "line") return text.split("\n")
|
||||
return text.split(/(\s+)/)
|
||||
}
|
||||
|
||||
const hasTransition = (
|
||||
variant?: Variant,
|
||||
): variant is TargetAndTransition & { transition?: Transition } => {
|
||||
if (!variant) return false
|
||||
return typeof variant === "object" && "transition" in variant
|
||||
}
|
||||
|
||||
const createVariantsWithTransition = (
|
||||
baseVariants: Variants,
|
||||
transition?: Transition & { exit?: Transition },
|
||||
): Variants => {
|
||||
if (!transition) return baseVariants
|
||||
|
||||
const { exit: _, ...mainTransition } = transition
|
||||
|
||||
return {
|
||||
...baseVariants,
|
||||
visible: {
|
||||
...baseVariants.visible,
|
||||
transition: {
|
||||
...(hasTransition(baseVariants.visible)
|
||||
? baseVariants.visible.transition
|
||||
: {}),
|
||||
...mainTransition,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
...baseVariants.exit,
|
||||
transition: {
|
||||
...(hasTransition(baseVariants.exit)
|
||||
? baseVariants.exit.transition
|
||||
: {}),
|
||||
...mainTransition,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function TextEffect({
|
||||
children,
|
||||
per = "word",
|
||||
as = "p",
|
||||
variants,
|
||||
className,
|
||||
preset = "fade",
|
||||
delay = 0,
|
||||
speedReveal = 1,
|
||||
speedSegment = 1,
|
||||
trigger = true,
|
||||
onAnimationComplete,
|
||||
onAnimationStart,
|
||||
segmentWrapperClassName,
|
||||
containerTransition,
|
||||
segmentTransition,
|
||||
style,
|
||||
}: TextEffectProps) {
|
||||
const segments = splitText(children, per)
|
||||
const MotionTag = motion[as as keyof typeof motion] as typeof motion.div
|
||||
|
||||
const baseVariants = preset
|
||||
? presetVariants[preset]
|
||||
: { container: defaultContainerVariants, item: defaultItemVariants }
|
||||
|
||||
const stagger = defaultStaggerTimes[per] / speedReveal
|
||||
|
||||
const baseDuration = 0.3 / speedSegment
|
||||
|
||||
const customStagger = hasTransition(variants?.container?.visible ?? {})
|
||||
? (variants?.container?.visible as TargetAndTransition).transition
|
||||
?.staggerChildren
|
||||
: undefined
|
||||
|
||||
const customDelay = hasTransition(variants?.container?.visible ?? {})
|
||||
? (variants?.container?.visible as TargetAndTransition).transition
|
||||
?.delayChildren
|
||||
: undefined
|
||||
|
||||
const computedVariants = {
|
||||
container: createVariantsWithTransition(
|
||||
variants?.container || baseVariants.container,
|
||||
{
|
||||
staggerChildren: customStagger ?? stagger,
|
||||
delayChildren: customDelay ?? delay,
|
||||
...containerTransition,
|
||||
exit: {
|
||||
staggerChildren: customStagger ?? stagger,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
),
|
||||
item: createVariantsWithTransition(variants?.item || baseVariants.item, {
|
||||
duration: baseDuration,
|
||||
...segmentTransition,
|
||||
}),
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{trigger && (
|
||||
<MotionTag
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={computedVariants.container}
|
||||
className={className}
|
||||
onAnimationComplete={onAnimationComplete}
|
||||
onAnimationStart={onAnimationStart}
|
||||
style={style}
|
||||
>
|
||||
{per !== "line" ? <span className="sr-only">{children}</span> : null}
|
||||
{segments.map((segment, index) => (
|
||||
<AnimationComponent
|
||||
key={`${per}-${index}-${segment}`}
|
||||
segment={segment}
|
||||
variants={computedVariants.item}
|
||||
per={per}
|
||||
segmentWrapperClassName={segmentWrapperClassName}
|
||||
/>
|
||||
))}
|
||||
</MotionTag>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
"use client"
|
||||
import { cn } from "@lib/utils"
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
type Transition,
|
||||
type Variants,
|
||||
} from "motion/react"
|
||||
import { useMemo, useId } from "react"
|
||||
|
||||
export type TextMorphProps = {
|
||||
children: string
|
||||
as?: React.ElementType
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
variants?: Variants
|
||||
transition?: Transition
|
||||
}
|
||||
|
||||
export function TextMorph({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
style,
|
||||
variants,
|
||||
transition,
|
||||
}: TextMorphProps) {
|
||||
const uniqueId = useId()
|
||||
|
||||
const characters = useMemo(() => {
|
||||
const charCounts: Record<string, number> = {}
|
||||
|
||||
return children.split("").map((char) => {
|
||||
const lowerChar = char.toLowerCase()
|
||||
charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1
|
||||
|
||||
return {
|
||||
id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`,
|
||||
label: char === " " ? "\u00A0" : char,
|
||||
}
|
||||
})
|
||||
}, [children, uniqueId])
|
||||
|
||||
const defaultVariants: Variants = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
}
|
||||
|
||||
const defaultTransition: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 280,
|
||||
damping: 18,
|
||||
mass: 0.3,
|
||||
}
|
||||
|
||||
return (
|
||||
<Component className={cn(className)} aria-label={children} style={style}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{characters.map((character) => (
|
||||
<motion.span
|
||||
key={character.id}
|
||||
layoutId={character.id}
|
||||
className="inline-block"
|
||||
aria-hidden="true"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={variants || defaultVariants}
|
||||
transition={transition || defaultTransition}
|
||||
>
|
||||
{character.label}
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
"use client"
|
||||
import { cn } from "@lib/utils"
|
||||
import { motion } from "motion/react"
|
||||
import React, { type JSX, useMemo } from "react"
|
||||
|
||||
export type TextShimmerProps = {
|
||||
children: string
|
||||
as?: React.ElementType
|
||||
className?: string
|
||||
duration?: number
|
||||
spread?: number
|
||||
}
|
||||
|
||||
function TextShimmerComponent({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements,
|
||||
)
|
||||
|
||||
const dynamicSpread = useMemo(() => {
|
||||
return children.length * spread
|
||||
}, [children, spread])
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text",
|
||||
"text-transparent [--base-color:#a1a1aa] [--base-gradient-color:#000]",
|
||||
"[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]",
|
||||
"dark:[--base-color:#71717a] dark:[--base-gradient-color:#ffffff] dark:[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]",
|
||||
className,
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--base-color), var(--base-color))",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
)
|
||||
}
|
||||
|
||||
export const TextShimmer = React.memo(TextShimmerComponent)
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import { Button } from "@repo/ui/components/button"
|
||||
import { Loader2, type LucideIcon } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
interface ActionButtonsProps {
|
||||
onCancel: () => void
|
||||
onSubmit?: () => void
|
||||
submitText: string
|
||||
submitIcon?: LucideIcon
|
||||
isSubmitting?: boolean
|
||||
isSubmitDisabled?: boolean
|
||||
submitType?: "button" | "submit"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ActionButtons({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
submitText,
|
||||
submitIcon: SubmitIcon,
|
||||
isSubmitting = false,
|
||||
isSubmitDisabled = false,
|
||||
submitType = "submit",
|
||||
className = "",
|
||||
}: ActionButtonsProps) {
|
||||
return (
|
||||
<div className={`flex gap-3 order-1 sm:order-2 justify-end ${className}`}>
|
||||
<Button
|
||||
className="hover:bg-foreground/10 border-none flex-1 sm:flex-initial cursor-pointer"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
<Button
|
||||
className="w-full cursor-pointer text-black dark:text-white"
|
||||
disabled={isSubmitting || isSubmitDisabled}
|
||||
onClick={submitType === "button" ? onSubmit : undefined}
|
||||
type={submitType}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
{submitText.includes("Add")
|
||||
? "Adding..."
|
||||
: submitText.includes("Upload")
|
||||
? "Uploading..."
|
||||
: "Processing..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{SubmitIcon && <SubmitIcon className="h-4 w-4 mr-2" />}
|
||||
{submitText}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,54 +0,0 @@
|
|||
interface MemoryUsageRingProps {
|
||||
memoriesUsed: number
|
||||
memoriesLimit: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MemoryUsageRing({
|
||||
memoriesUsed,
|
||||
memoriesLimit,
|
||||
className = "",
|
||||
}: MemoryUsageRingProps) {
|
||||
const usagePercentage = memoriesUsed / memoriesLimit
|
||||
const strokeColor =
|
||||
memoriesUsed >= memoriesLimit * 0.8 ? "rgb(251 191 36)" : "rgb(34 197 94)"
|
||||
const circumference = 2 * Math.PI * 10
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative group cursor-help self-center sm:self-end mb-1 hidden sm:block ${className}`}
|
||||
title={`${memoriesUsed} of ${memoriesLimit} memories used`}
|
||||
>
|
||||
<svg className="w-6 h-6 transform -rotate-90" viewBox="0 0 24 24">
|
||||
<title>{`${memoriesUsed} of ${memoriesLimit} memories used`}</title>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
fill="none"
|
||||
r="10"
|
||||
stroke="rgb(255 255 255 / 0.1)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
className="transition-all duration-300"
|
||||
cx="12"
|
||||
cy="12"
|
||||
fill="none"
|
||||
r="10"
|
||||
stroke={strokeColor}
|
||||
strokeDasharray={`${circumference}`}
|
||||
strokeDashoffset={`${circumference * (1 - usagePercentage)}`}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Tooltip on hover */}
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-black/90 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap">
|
||||
{memoriesUsed} / {memoriesLimit}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/ui/components/select"
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
interface Project {
|
||||
id?: string
|
||||
containerTag: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ProjectSelectionProps {
|
||||
projects: Project[]
|
||||
selectedProject: string
|
||||
onProjectChange: (value: string) => void
|
||||
onCreateProject: () => void
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function ProjectSelection({
|
||||
projects,
|
||||
selectedProject,
|
||||
onProjectChange,
|
||||
onCreateProject,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
className = "",
|
||||
id = "project-select",
|
||||
}: ProjectSelectionProps) {
|
||||
const handleValueChange = (value: string) => {
|
||||
if (value === "create-new-project") {
|
||||
onCreateProject()
|
||||
} else {
|
||||
onProjectChange(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={`${id}-${selectedProject}`}
|
||||
disabled={isLoading || disabled}
|
||||
onValueChange={handleValueChange}
|
||||
value={selectedProject}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`bg-foreground/5 border-foreground/10 cursor-pointer ${className}`}
|
||||
id={id}
|
||||
>
|
||||
<SelectValue placeholder="Select a project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={5} className="z-[90]">
|
||||
<SelectItem
|
||||
className="hover:bg-foreground/10"
|
||||
key="default"
|
||||
value="sm_project_default"
|
||||
>
|
||||
Default Project
|
||||
</SelectItem>
|
||||
{projects
|
||||
.filter((p) => p.containerTag !== "sm_project_default" && p.id)
|
||||
.map((project) => (
|
||||
<SelectItem
|
||||
className="hover:bg-foreground/10"
|
||||
key={project.id || project.containerTag}
|
||||
value={project.containerTag}
|
||||
>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem
|
||||
className="hover:bg-foreground/10 border-t border-foreground/10 mt-1"
|
||||
key="create-new"
|
||||
value="create-new-project"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Create new project</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
interface TabButtonProps {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function TabButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
isActive,
|
||||
onClick,
|
||||
}: TabButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`flex items-center gap-1.5 text-xs sm:text-xs px-4 sm:px-3 py-2 sm:py-1 h-8 sm:h-6 rounded-sm transition-colors whitespace-nowrap min-w-0 ${
|
||||
isActive ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Icon className="h-4 w-4 sm:h-3 sm:w-3" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,557 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import isHotkey from "is-hotkey"
|
||||
import {
|
||||
Bold,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Italic,
|
||||
List,
|
||||
Quote,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import {
|
||||
type BaseEditor,
|
||||
createEditor,
|
||||
type Descendant,
|
||||
Editor,
|
||||
Transforms,
|
||||
} from "slate"
|
||||
import type { ReactEditor as ReactEditorType } from "slate-react"
|
||||
import {
|
||||
Editable,
|
||||
ReactEditor,
|
||||
type RenderElementProps,
|
||||
type RenderLeafProps,
|
||||
Slate,
|
||||
withReact,
|
||||
} from "slate-react"
|
||||
|
||||
type CustomEditor = BaseEditor & ReactEditorType
|
||||
|
||||
type ParagraphElement = {
|
||||
type: "paragraph"
|
||||
children: CustomText[]
|
||||
}
|
||||
|
||||
type HeadingElement = {
|
||||
type: "heading"
|
||||
level: number
|
||||
children: CustomText[]
|
||||
}
|
||||
|
||||
type ListItemElement = {
|
||||
type: "list-item"
|
||||
children: CustomText[]
|
||||
}
|
||||
|
||||
type BlockQuoteElement = {
|
||||
type: "block-quote"
|
||||
children: CustomText[]
|
||||
}
|
||||
|
||||
type CustomElement =
|
||||
| ParagraphElement
|
||||
| HeadingElement
|
||||
| ListItemElement
|
||||
| BlockQuoteElement
|
||||
|
||||
type FormattedText = {
|
||||
text: string
|
||||
bold?: true
|
||||
italic?: true
|
||||
code?: true
|
||||
}
|
||||
|
||||
type CustomText = FormattedText
|
||||
|
||||
declare module "slate" {
|
||||
interface CustomTypes {
|
||||
Editor: CustomEditor
|
||||
Element: CustomElement
|
||||
Text: CustomText
|
||||
}
|
||||
}
|
||||
|
||||
// Hotkey mappings
|
||||
const HOTKEYS: Record<string, keyof CustomText> = {
|
||||
"mod+b": "bold",
|
||||
"mod+i": "italic",
|
||||
"mod+`": "code",
|
||||
}
|
||||
|
||||
interface TextEditorProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
onBlur?: () => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
containerClassName?: string
|
||||
}
|
||||
|
||||
const initialValue: Descendant[] = [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
]
|
||||
|
||||
const serialize = (nodes: Descendant[]): string => {
|
||||
return nodes.map((n) => serializeNode(n)).join("\n")
|
||||
}
|
||||
|
||||
const serializeNode = (node: CustomElement | CustomText): string => {
|
||||
if ("text" in node) {
|
||||
let text = node.text
|
||||
if (node.bold) text = `**${text}**`
|
||||
if (node.italic) text = `*${text}*`
|
||||
if (node.code) text = `\`${text}\``
|
||||
return text
|
||||
}
|
||||
|
||||
const children = node.children
|
||||
? node.children.map(serializeNode).join("")
|
||||
: ""
|
||||
|
||||
switch (node.type) {
|
||||
case "paragraph":
|
||||
return children
|
||||
case "heading":
|
||||
return `${"#".repeat(node.level || 1)} ${children}`
|
||||
case "list-item":
|
||||
return `- ${children}`
|
||||
case "block-quote":
|
||||
return `> ${children}`
|
||||
default:
|
||||
return children
|
||||
}
|
||||
}
|
||||
|
||||
const deserialize = (text: string): Descendant[] => {
|
||||
if (!text.trim()) {
|
||||
return initialValue
|
||||
}
|
||||
|
||||
const lines = text.split("\n")
|
||||
const nodes: Descendant[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
if (trimmedLine.startsWith("# ")) {
|
||||
nodes.push({
|
||||
type: "heading",
|
||||
level: 1,
|
||||
children: [{ text: trimmedLine.slice(2) }],
|
||||
})
|
||||
} else if (trimmedLine.startsWith("## ")) {
|
||||
nodes.push({
|
||||
type: "heading",
|
||||
level: 2,
|
||||
children: [{ text: trimmedLine.slice(3) }],
|
||||
})
|
||||
} else if (trimmedLine.startsWith("### ")) {
|
||||
nodes.push({
|
||||
type: "heading",
|
||||
level: 3,
|
||||
children: [{ text: trimmedLine.slice(4) }],
|
||||
})
|
||||
} else if (trimmedLine.startsWith("- ")) {
|
||||
nodes.push({
|
||||
type: "list-item",
|
||||
children: [{ text: trimmedLine.slice(2) }],
|
||||
})
|
||||
} else if (trimmedLine.startsWith("> ")) {
|
||||
nodes.push({
|
||||
type: "block-quote",
|
||||
children: [{ text: trimmedLine.slice(2) }],
|
||||
})
|
||||
} else {
|
||||
nodes.push({
|
||||
type: "paragraph",
|
||||
children: [{ text: line }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nodes.length > 0 ? nodes : initialValue
|
||||
}
|
||||
|
||||
const isMarkActive = (editor: CustomEditor, format: keyof CustomText) => {
|
||||
const marks = Editor.marks(editor)
|
||||
return marks ? marks[format as keyof typeof marks] === true : false
|
||||
}
|
||||
|
||||
const toggleMark = (editor: CustomEditor, format: keyof CustomText) => {
|
||||
const isActive = isMarkActive(editor, format)
|
||||
|
||||
if (isActive) {
|
||||
Editor.removeMark(editor, format)
|
||||
} else {
|
||||
Editor.addMark(editor, format, true)
|
||||
}
|
||||
|
||||
// Focus back to editor after toggling
|
||||
ReactEditor.focus(editor)
|
||||
}
|
||||
|
||||
const isBlockActive = (
|
||||
editor: CustomEditor,
|
||||
format: string,
|
||||
level?: number,
|
||||
) => {
|
||||
const { selection } = editor
|
||||
if (!selection) return false
|
||||
|
||||
const [match] = Array.from(
|
||||
Editor.nodes(editor, {
|
||||
at: Editor.unhangRange(editor, selection),
|
||||
match: (n) =>
|
||||
!Editor.isEditor(n) &&
|
||||
(n as CustomElement).type === format &&
|
||||
(level === undefined || (n as HeadingElement).level === level),
|
||||
}),
|
||||
)
|
||||
|
||||
return !!match
|
||||
}
|
||||
|
||||
const toggleBlock = (editor: CustomEditor, format: string, level?: number) => {
|
||||
const isActive = isBlockActive(editor, format, level)
|
||||
const newProperties: any = {
|
||||
type: isActive ? "paragraph" : format,
|
||||
}
|
||||
|
||||
if (format === "heading" && level && !isActive) {
|
||||
newProperties.level = level
|
||||
}
|
||||
|
||||
Transforms.setNodes(editor, newProperties)
|
||||
|
||||
// Focus back to editor after toggling
|
||||
ReactEditor.focus(editor)
|
||||
}
|
||||
|
||||
export function TextEditor({
|
||||
value = "",
|
||||
onChange,
|
||||
onBlur,
|
||||
placeholder = "Start writing...",
|
||||
disabled = false,
|
||||
className,
|
||||
containerClassName,
|
||||
}: TextEditorProps) {
|
||||
const editor = useMemo(() => withReact(createEditor()) as CustomEditor, [])
|
||||
const [editorValue, setEditorValue] = useState<Descendant[]>(() =>
|
||||
deserialize(value),
|
||||
)
|
||||
const [selection, setSelection] = useState(editor.selection)
|
||||
|
||||
const renderElement = useCallback((props: RenderElementProps) => {
|
||||
switch (props.element.type) {
|
||||
case "heading": {
|
||||
const element = props.element as HeadingElement
|
||||
const HeadingTag = `h${element.level || 1}` as
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "h5"
|
||||
| "h6"
|
||||
return (
|
||||
<HeadingTag
|
||||
{...props.attributes}
|
||||
className={cn(
|
||||
"font-bold",
|
||||
element.level === 1 && "text-2xl mb-4",
|
||||
element.level === 2 && "text-xl mb-3",
|
||||
element.level === 3 && "text-lg mb-2",
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</HeadingTag>
|
||||
)
|
||||
}
|
||||
case "list-item":
|
||||
return (
|
||||
<li {...props.attributes} className="ml-4 list-disc">
|
||||
{props.children}
|
||||
</li>
|
||||
)
|
||||
case "block-quote":
|
||||
return (
|
||||
<blockquote
|
||||
{...props.attributes}
|
||||
className="border-l-4 border-foreground/20 pl-4 italic text-foreground/80"
|
||||
>
|
||||
{props.children}
|
||||
</blockquote>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<p {...props.attributes} className="mb-2">
|
||||
{props.children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const renderLeaf = useCallback((props: RenderLeafProps) => {
|
||||
let { attributes, children, leaf } = props
|
||||
|
||||
if (leaf.bold) {
|
||||
children = <strong>{children}</strong>
|
||||
}
|
||||
|
||||
if (leaf.italic) {
|
||||
children = <em>{children}</em>
|
||||
}
|
||||
|
||||
if (leaf.code) {
|
||||
children = (
|
||||
<code className="bg-foreground/10 px-1 rounded text-sm">
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return <span {...attributes}>{children}</span>
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
// Handle hotkeys for formatting
|
||||
for (const hotkey in HOTKEYS) {
|
||||
if (isHotkey(hotkey, event)) {
|
||||
event.preventDefault()
|
||||
const mark = HOTKEYS[hotkey]
|
||||
if (mark) {
|
||||
toggleMark(editor, mark)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle block formatting hotkeys
|
||||
if (isHotkey("mod+shift+1", event)) {
|
||||
event.preventDefault()
|
||||
toggleBlock(editor, "heading", 1)
|
||||
return
|
||||
}
|
||||
if (isHotkey("mod+shift+2", event)) {
|
||||
event.preventDefault()
|
||||
toggleBlock(editor, "heading", 2)
|
||||
return
|
||||
}
|
||||
if (isHotkey("mod+shift+3", event)) {
|
||||
event.preventDefault()
|
||||
toggleBlock(editor, "heading", 3)
|
||||
return
|
||||
}
|
||||
if (isHotkey("mod+shift+8", event)) {
|
||||
event.preventDefault()
|
||||
toggleBlock(editor, "list-item")
|
||||
return
|
||||
}
|
||||
if (isHotkey("mod+shift+.", event)) {
|
||||
event.preventDefault()
|
||||
toggleBlock(editor, "block-quote")
|
||||
return
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
|
||||
const handleSlateChange = useCallback(
|
||||
(newValue: Descendant[]) => {
|
||||
setEditorValue(newValue)
|
||||
const serializedValue = serialize(newValue)
|
||||
onChange?.(serializedValue)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
// Memoized active states that update when selection changes
|
||||
const activeStates = useMemo(
|
||||
() => ({
|
||||
bold: isMarkActive(editor, "bold"),
|
||||
italic: isMarkActive(editor, "italic"),
|
||||
code: isMarkActive(editor, "code"),
|
||||
heading1: isBlockActive(editor, "heading", 1),
|
||||
heading2: isBlockActive(editor, "heading", 2),
|
||||
heading3: isBlockActive(editor, "heading", 3),
|
||||
listItem: isBlockActive(editor, "list-item"),
|
||||
blockQuote: isBlockActive(editor, "block-quote"),
|
||||
}),
|
||||
[editor, selection],
|
||||
)
|
||||
|
||||
const ToolbarButton = ({
|
||||
icon: Icon,
|
||||
isActive,
|
||||
onMouseDown,
|
||||
title,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
isActive: boolean
|
||||
onMouseDown: (event: React.MouseEvent) => void
|
||||
title: string
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 !p-0 text-foreground/70 transition-all duration-200 rounded-sm cursor-pointer",
|
||||
"hover:bg-foreground/15 hover:text-foreground hover:scale-105",
|
||||
"active:scale-95",
|
||||
isActive && "bg-foreground/20 text-foreground",
|
||||
)}
|
||||
onMouseDown={onMouseDown}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
isActive && "scale-110",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-foreground/5 border border-foreground/10 rounded-md",
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
<div className="flex-1 min-h-48 overflow-y-auto">
|
||||
<Slate
|
||||
editor={editor}
|
||||
initialValue={editorValue}
|
||||
onValueChange={handleSlateChange}
|
||||
onSelectionChange={() => setSelection(editor.selection)}
|
||||
>
|
||||
<Editable
|
||||
renderElement={renderElement}
|
||||
renderLeaf={renderLeaf}
|
||||
placeholder={placeholder}
|
||||
renderPlaceholder={({ children, attributes }) => {
|
||||
return (
|
||||
<div {...attributes} className="mt-2">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={onBlur}
|
||||
readOnly={disabled}
|
||||
className={cn(
|
||||
"outline-none w-full h-full placeholder:text-foreground/50",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
style={{
|
||||
minHeight: "23rem",
|
||||
maxHeight: "23rem",
|
||||
padding: "12px",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
/>
|
||||
</Slate>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="p-1 flex items-center gap-2 bg-foreground/5 backdrop-blur-sm rounded-b-md">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Text formatting */}
|
||||
<ToolbarButton
|
||||
icon={Bold}
|
||||
isActive={activeStates.bold}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
toggleMark(editor, "bold")
|
||||
}}
|
||||
title="Bold (Ctrl/Cmd+B)"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Italic}
|
||||
isActive={activeStates.italic}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
toggleMark(editor, "italic")
|
||||
}}
|
||||
title="Italic (Ctrl/Cmd+I)"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Code}
|
||||
isActive={activeStates.code}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
toggleMark(editor, "code")
|
||||
}}
|
||||
title="Code (Ctrl/Cmd+`)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-foreground/30 mx-2" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Block formatting */}
|
||||
<ToolbarButton
|
||||
icon={Heading1}
|
||||
isActive={activeStates.heading1}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
toggleBlock(editor, "heading", 1)
|
||||
}}
|
||||
title="Heading 1 (Ctrl/Cmd+Shift+1)"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Heading2}
|
||||
isActive={activeStates.heading2}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
toggleBlock(editor, "heading", 2)
|
||||
}}
|
||||
title="Heading 2 (Ctrl/Cmd+Shift+2)"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Heading3}
|
||||
isActive={activeStates.heading3}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
toggleBlock(editor, "heading", 3)
|
||||
}}
|
||||
title="Heading 3"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={List}
|
||||
isActive={activeStates.listItem}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
toggleBlock(editor, "list-item")
|
||||
}}
|
||||
title="Bullet List"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Quote}
|
||||
isActive={activeStates.blockQuote}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
toggleBlock(editor, "block-quote")
|
||||
}}
|
||||
title="Quote"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
import { useAuth } from "@lib/auth-context"
|
||||
import {
|
||||
fetchConnectionsFeature,
|
||||
fetchMemoriesFeature,
|
||||
fetchSubscriptionStatus,
|
||||
} from "@lib/queries"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { HeadingH3Bold } from "@ui/text/heading/heading-h3-bold"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { AlertTriangle, CheckCircle, LoaderIcon, X } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import Link from "next/link"
|
||||
import { useEffect, useState } from "react"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export function BillingView() {
|
||||
const autumn = useCustomer()
|
||||
const { user } = useAuth()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
analytics.billingViewed()
|
||||
}, [])
|
||||
|
||||
const {
|
||||
data: status = {
|
||||
api_pro: { allowed: false, status: null },
|
||||
},
|
||||
isLoading: isCheckingStatus,
|
||||
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
|
||||
|
||||
const proStatus = status.api_pro
|
||||
const proProductStatus = proStatus?.status
|
||||
const isPastDue = proProductStatus === "past_due"
|
||||
const hasProProduct = proProductStatus !== null
|
||||
|
||||
const { data: memoriesCheck } = fetchMemoriesFeature(
|
||||
autumn,
|
||||
!autumn.isLoading && !isCheckingStatus,
|
||||
)
|
||||
|
||||
const memoriesUsed = memoriesCheck?.usage ?? 0
|
||||
const memoriesLimit = memoriesCheck?.included_usage ?? 0
|
||||
|
||||
const { data: connectionsCheck } = fetchConnectionsFeature(
|
||||
autumn,
|
||||
!autumn.isLoading && !isCheckingStatus,
|
||||
)
|
||||
|
||||
const connectionsUsed = connectionsCheck?.usage ?? 0
|
||||
|
||||
// Handle upgrade
|
||||
const handleUpgrade = async () => {
|
||||
analytics.upgradeInitiated()
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await autumn.attach({
|
||||
productId: "api_pro",
|
||||
successUrl: "https://app.supermemory.ai/",
|
||||
})
|
||||
analytics.upgradeCompleted()
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle manage billing
|
||||
const handleManageBilling = async () => {
|
||||
analytics.billingPortalOpened()
|
||||
await autumn.openBillingPortal({
|
||||
returnUrl: "https://app.supermemory.ai",
|
||||
})
|
||||
}
|
||||
|
||||
if (user?.isAnonymous) {
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-8"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Sign in to unlock premium features
|
||||
</p>
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
asChild
|
||||
className="bg-muted hover:bg-muted/80 text-foreground border-border"
|
||||
size="sm"
|
||||
>
|
||||
<Link href="/login">Sign in</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
if (hasProProduct) {
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<HeadingH3Bold className="text-foreground flex items-center gap-2">
|
||||
Pro Plan
|
||||
{isPastDue ? (
|
||||
<span className="text-xs bg-red-500/20 text-red-600 dark:text-red-400 px-2 py-0.5 rounded-full">
|
||||
Past Due
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs bg-green-500/20 text-green-600 dark:text-green-400 px-2 py-0.5 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</HeadingH3Bold>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isPastDue
|
||||
? "Your payment is past due. Please update your payment method to restore access."
|
||||
: "You're enjoying expanded memory capacity with supermemory Pro!"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isPastDue && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-red-600 dark:text-red-400 font-medium mb-1">
|
||||
Payment Required
|
||||
</p>
|
||||
<p className="text-xs text-red-600/80 dark:text-red-400/80">
|
||||
Your payment method failed or payment is past due. Update your
|
||||
payment information to restore access to all Pro features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Usage */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-foreground">Current Usage</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Memories</span>
|
||||
<span className="text-sm text-foreground">Unlimited</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Connections</span>
|
||||
<span className="text-sm text-foreground">
|
||||
{connectionsUsed} / 10
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleManageBilling}
|
||||
size="sm"
|
||||
variant="default"
|
||||
className={isPastDue ? "bg-red-600 hover:bg-red-700 text-white" : ""}
|
||||
>
|
||||
{isPastDue ? "Pay Past Due" : "Manage Billing"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
>
|
||||
{/* Current Usage - Free Plan */}
|
||||
<div className="space-y-3">
|
||||
<HeadingH3Bold className="text-foreground">
|
||||
Current Plan: Free
|
||||
</HeadingH3Bold>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Memories</span>
|
||||
<span
|
||||
className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-500" : "text-foreground"}`}
|
||||
>
|
||||
{memoriesUsed} / {memoriesLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted-foreground/50 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
memoriesUsed >= memoriesLimit ? "bg-red-500" : "bg-blue-500"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison */}
|
||||
<div className="space-y-4">
|
||||
<HeadingH3Bold className="text-foreground">
|
||||
Upgrade to Pro
|
||||
</HeadingH3Bold>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Free Plan */}
|
||||
<div className="p-4 bg-muted/50 rounded-lg border border-border">
|
||||
<h4 className="font-medium text-foreground mb-3">Free Plan</h4>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
200 memories
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
No connections
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
Basic search
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Pro Plan */}
|
||||
<div className="p-4 bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-lg border border-blue-500/20">
|
||||
<h4 className="font-medium text-foreground mb-3 flex items-center gap-2">
|
||||
Pro Plan
|
||||
<span className="text-xs bg-blue-500/20 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded-full">
|
||||
Recommended
|
||||
</span>
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center gap-2 text-sm text-white/90">
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
Unlimited memories
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm text-foreground">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
10 connections
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm text-foreground">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
Advanced search
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm text-foreground">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
Priority support
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white border-0 w-full"
|
||||
disabled={isLoading || isCheckingStatus}
|
||||
onClick={handleUpgrade}
|
||||
>
|
||||
{isLoading || isCheckingStatus ? (
|
||||
<>
|
||||
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
|
||||
Upgrading...
|
||||
</>
|
||||
) : (
|
||||
<div>Upgrade to Pro - $9/month (only for first 100 users)</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Cancel anytime. No questions asked.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,742 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useChat, useCompletion, type UIMessage } from "@ai-sdk/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { DefaultChatTransport } from "ai"
|
||||
import {
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
RotateCcw,
|
||||
X,
|
||||
Square,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Streamdown } from "streamdown"
|
||||
import { TextShimmer } from "@/components/text-shimmer"
|
||||
import { usePersistentChat, useProject } from "@/stores"
|
||||
import { useGraphHighlights } from "@/stores/highlights"
|
||||
import { ModelIcon } from "@/lib/models"
|
||||
import { Spinner } from "../../spinner"
|
||||
import { areUIMessageArraysEqual } from "@/stores/chat"
|
||||
|
||||
interface MemoryResult {
|
||||
documentId?: string
|
||||
title?: string
|
||||
content?: string
|
||||
url?: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
interface ExpandableMemoriesProps {
|
||||
foundCount: number
|
||||
results: MemoryResult[]
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
state?: string
|
||||
text?: string
|
||||
output?: {
|
||||
count?: number
|
||||
results?: Array<{
|
||||
documentId?: string
|
||||
title?: string
|
||||
content?: string
|
||||
url?: string
|
||||
score?: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
parts: MessagePart[]
|
||||
}
|
||||
|
||||
function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
if (foundCount === 0) {
|
||||
return (
|
||||
<div className="text-sm flex items-center gap-2 text-muted-foreground">
|
||||
<Check className="size-4" /> No memories found
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<button
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
type="button"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
Related memories
|
||||
</button>
|
||||
|
||||
{isExpanded && results.length > 0 && (
|
||||
<div className="mt-2 ml-6 space-y-2 max-h-48 overflow-y-auto grid grid-cols-3 gap-2">
|
||||
{results.map((result, index) => {
|
||||
const isClickable =
|
||||
result.url &&
|
||||
(result.url.startsWith("http://") ||
|
||||
result.url.startsWith("https://"))
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{result.title && (
|
||||
<div className="font-medium text-sm mb-1 text-foreground">
|
||||
{result.title}
|
||||
</div>
|
||||
)}
|
||||
{result.content && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{result.content}
|
||||
</div>
|
||||
)}
|
||||
{result.url && (
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400 mt-1 truncate">
|
||||
{result.url}
|
||||
</div>
|
||||
)}
|
||||
{result.score && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Score: {(result.score * 100).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (isClickable) {
|
||||
return (
|
||||
<a
|
||||
className="block p-2 bg-accent/50 rounded-md border border-border hover:bg-accent transition-colors cursor-pointer"
|
||||
href={result.url}
|
||||
key={result.documentId || index}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 bg-accent/50 rounded-md border border-border"
|
||||
key={result.documentId || index}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useStickyAutoScroll(triggerKeys: ReadonlyArray<unknown>) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const [isAutoScroll, setIsAutoScroll] = useState(true)
|
||||
const [isFarFromBottom, setIsFarFromBottom] = useState(false)
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
|
||||
const node = bottomRef.current
|
||||
if (node) node.scrollIntoView({ behavior, block: "end" })
|
||||
}, [])
|
||||
|
||||
useEffect(function observeBottomVisibility() {
|
||||
const container = scrollContainerRef.current
|
||||
const sentinel = bottomRef.current
|
||||
if (!container || !sentinel) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries || entries.length === 0) return
|
||||
const isIntersecting = entries.some((e) => e.isIntersecting)
|
||||
setIsAutoScroll(isIntersecting)
|
||||
},
|
||||
{ root: container, rootMargin: "0px 0px 80px 0px", threshold: 0 },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
function observeContentResize() {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (isAutoScroll) scrollToBottom("auto")
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight
|
||||
setIsFarFromBottom(distanceFromBottom > 100)
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
return () => resizeObserver.disconnect()
|
||||
},
|
||||
[isAutoScroll, scrollToBottom],
|
||||
)
|
||||
|
||||
function enableAutoScroll() {
|
||||
setIsAutoScroll(true)
|
||||
}
|
||||
|
||||
useEffect(
|
||||
function autoScrollOnNewContent() {
|
||||
if (isAutoScroll) scrollToBottom("auto")
|
||||
},
|
||||
[isAutoScroll, scrollToBottom, ...triggerKeys],
|
||||
)
|
||||
|
||||
const recomputeDistanceFromBottom = useCallback(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight
|
||||
setIsFarFromBottom(distanceFromBottom > 100)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
recomputeDistanceFromBottom()
|
||||
}, [recomputeDistanceFromBottom, ...triggerKeys])
|
||||
|
||||
function onScroll() {
|
||||
recomputeDistanceFromBottom()
|
||||
}
|
||||
|
||||
return {
|
||||
scrollContainerRef,
|
||||
bottomRef,
|
||||
isAutoScroll,
|
||||
isFarFromBottom,
|
||||
onScroll,
|
||||
enableAutoScroll,
|
||||
scrollToBottom,
|
||||
} as const
|
||||
}
|
||||
|
||||
export function ChatMessages() {
|
||||
const { selectedProject } = useProject()
|
||||
const {
|
||||
currentChatId,
|
||||
setCurrentChatId,
|
||||
setConversation,
|
||||
getCurrentConversation,
|
||||
setConversationTitle,
|
||||
getCurrentChat,
|
||||
} = usePersistentChat()
|
||||
|
||||
const storageKey = `chat-model-${currentChatId}`
|
||||
|
||||
const [input, setInput] = useState("")
|
||||
const [selectedModel, setSelectedModel] = useState<
|
||||
"gpt-5" | "claude-sonnet-4.5" | "gemini-2.5-pro"
|
||||
>("gemini-2.5-pro")
|
||||
const activeChatIdRef = useRef<string | null>(null)
|
||||
const shouldGenerateTitleRef = useRef<boolean>(false)
|
||||
const hasRunInitialMessageRef = useRef<boolean>(false)
|
||||
const lastSavedMessagesRef = useRef<UIMessage[] | null>(null)
|
||||
const lastSavedActiveIdRef = useRef<string | null>(null)
|
||||
const lastLoadedChatIdRef = useRef<string | null>(null)
|
||||
const lastLoadedMessagesRef = useRef<UIMessage[] | null>(null)
|
||||
|
||||
const { setDocumentIds } = useGraphHighlights()
|
||||
|
||||
const { messages, sendMessage, status, stop, setMessages, id, regenerate } =
|
||||
useChat({
|
||||
id: currentChatId ?? undefined,
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat`,
|
||||
credentials: "include",
|
||||
body: {
|
||||
metadata: {
|
||||
projectId: selectedProject,
|
||||
model: selectedModel,
|
||||
chatId: currentChatId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
onFinish: (result) => {
|
||||
const activeId = activeChatIdRef.current
|
||||
if (!activeId) return
|
||||
if (result.message.role !== "assistant") return
|
||||
|
||||
if (shouldGenerateTitleRef.current) {
|
||||
const textPart = result.message.parts.find(
|
||||
(p: { type?: string; text?: string }) => p?.type === "text",
|
||||
) as { text?: string } | undefined
|
||||
const text = textPart?.text?.trim()
|
||||
if (text) {
|
||||
shouldGenerateTitleRef.current = false
|
||||
complete(text)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
lastLoadedMessagesRef.current = messages
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
activeChatIdRef.current = currentChatId ?? id ?? null
|
||||
}, [currentChatId, id])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
if (currentChatId) {
|
||||
const savedModel = sessionStorage.getItem(storageKey) as
|
||||
| "gpt-5"
|
||||
| "claude-sonnet-4.5"
|
||||
| "gemini-2.5-pro"
|
||||
|
||||
if (
|
||||
savedModel &&
|
||||
["gpt-5", "claude-sonnet-4.5", "gemini-2.5-pro"].includes(savedModel)
|
||||
) {
|
||||
setSelectedModel(savedModel)
|
||||
}
|
||||
}
|
||||
}, [currentChatId, storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
if (currentChatId && !hasRunInitialMessageRef.current) {
|
||||
// Check if there's an initial message from the home page in sessionStorage
|
||||
const storageKey = `chat-initial-${currentChatId}`
|
||||
const initialMessage = sessionStorage.getItem(storageKey)
|
||||
|
||||
if (initialMessage) {
|
||||
// Clean up the storage and send the message
|
||||
sessionStorage.removeItem(storageKey)
|
||||
sendMessage({ text: initialMessage })
|
||||
hasRunInitialMessageRef.current = true
|
||||
}
|
||||
}
|
||||
}, [currentChatId, sendMessage])
|
||||
|
||||
useEffect(() => {
|
||||
if (id && id !== currentChatId) {
|
||||
setCurrentChatId(id)
|
||||
}
|
||||
}, [id, currentChatId, setCurrentChatId])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatId !== lastLoadedChatIdRef.current) {
|
||||
lastLoadedMessagesRef.current = null
|
||||
lastSavedMessagesRef.current = null
|
||||
}
|
||||
|
||||
if (currentChatId === lastLoadedChatIdRef.current) {
|
||||
setInput("")
|
||||
return
|
||||
}
|
||||
|
||||
const msgs = getCurrentConversation()
|
||||
|
||||
if (msgs && msgs.length > 0) {
|
||||
const currentMessages = lastLoadedMessagesRef.current
|
||||
if (!currentMessages || !areUIMessageArraysEqual(currentMessages, msgs)) {
|
||||
lastLoadedMessagesRef.current = msgs
|
||||
setMessages(msgs)
|
||||
}
|
||||
} else if (!currentChatId) {
|
||||
if (
|
||||
lastLoadedMessagesRef.current &&
|
||||
lastLoadedMessagesRef.current.length > 0
|
||||
) {
|
||||
lastLoadedMessagesRef.current = []
|
||||
setMessages([])
|
||||
}
|
||||
}
|
||||
|
||||
lastLoadedChatIdRef.current = currentChatId
|
||||
setInput("")
|
||||
}, [currentChatId, getCurrentConversation, setMessages])
|
||||
|
||||
useEffect(() => {
|
||||
const activeId = currentChatId ?? id
|
||||
if (!activeId || messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeId !== lastSavedActiveIdRef.current) {
|
||||
lastSavedMessagesRef.current = null
|
||||
lastSavedActiveIdRef.current = activeId
|
||||
}
|
||||
|
||||
const lastSaved = lastSavedMessagesRef.current
|
||||
if (lastSaved && areUIMessageArraysEqual(lastSaved, messages)) {
|
||||
return
|
||||
}
|
||||
|
||||
lastSavedMessagesRef.current = messages
|
||||
setConversation(activeId, messages)
|
||||
}, [messages, currentChatId, id, setConversation])
|
||||
|
||||
const { complete } = useCompletion({
|
||||
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/title`,
|
||||
credentials: "include",
|
||||
onFinish: (_, completion) => {
|
||||
const activeId = activeChatIdRef.current
|
||||
if (!completion || !activeId) return
|
||||
setConversationTitle(activeId, completion.trim())
|
||||
},
|
||||
})
|
||||
|
||||
// Update graph highlights from the most recent tool-searchMemories output
|
||||
useEffect(() => {
|
||||
try {
|
||||
const lastAssistant = [...messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant") as ChatMessage | undefined
|
||||
if (!lastAssistant) return
|
||||
const lastSearchPart = [...(lastAssistant.parts as MessagePart[])]
|
||||
.reverse()
|
||||
.find(
|
||||
(p) =>
|
||||
p?.type === "tool-searchMemories" &&
|
||||
p?.state === "output-available",
|
||||
) as MessagePart | undefined
|
||||
if (!lastSearchPart) return
|
||||
const output = lastSearchPart.output
|
||||
const ids = Array.isArray(output?.results)
|
||||
? ((output.results as MemoryResult[])
|
||||
.map((r) => r?.documentId)
|
||||
.filter(Boolean) as string[])
|
||||
: []
|
||||
if (ids.length > 0) {
|
||||
setDocumentIds(ids)
|
||||
}
|
||||
} catch {}
|
||||
}, [messages, setDocumentIds])
|
||||
|
||||
useEffect(() => {
|
||||
const currentSummary = getCurrentChat()
|
||||
const hasTitle = Boolean(
|
||||
currentSummary?.title && currentSummary.title.trim().length > 0,
|
||||
)
|
||||
shouldGenerateTitleRef.current = !hasTitle
|
||||
}, [getCurrentChat])
|
||||
|
||||
/**
|
||||
* Handles sending a message from the input area.
|
||||
* - Prevents sending during submitted (shows toast)
|
||||
* - Stops streaming when active
|
||||
* - Validates non-empty input (shows toast)
|
||||
* Returns true when a message is sent.
|
||||
*/
|
||||
const handleSendMessage = useCallback(() => {
|
||||
if (status === "submitted") {
|
||||
toast.warning("Please wait for the current response to complete", {
|
||||
id: "wait-for-response",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (status === "streaming") {
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
if (!input.trim()) {
|
||||
toast.warning("Please enter a message", { id: "empty-message" })
|
||||
return false
|
||||
}
|
||||
sendMessage({ text: input })
|
||||
setInput("")
|
||||
return true
|
||||
}, [status, input, sendMessage, stop])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
scrollContainerRef,
|
||||
bottomRef,
|
||||
isFarFromBottom,
|
||||
onScroll,
|
||||
enableAutoScroll,
|
||||
scrollToBottom,
|
||||
} = useStickyAutoScroll([messages, status])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col w-full">
|
||||
<div className="flex-1 relative">
|
||||
<div
|
||||
className="absolute inset-0 overflow-y-auto custom-scrollbar"
|
||||
onScroll={onScroll}
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div className="flex flex-col gap-2 max-w-4xl mx-auto px-4 md:px-2 pt-4 pb-7 scroll-pb-7">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex my-2",
|
||||
message.role === "user"
|
||||
? "items-center flex-row-reverse gap-2"
|
||||
: "flex-col",
|
||||
)}
|
||||
key={message.id}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 ",
|
||||
message.role === "user"
|
||||
? "bg-accent/50 px-3 py-1.5 border border-border rounded-lg"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{message.parts
|
||||
.filter((part) =>
|
||||
[
|
||||
"text",
|
||||
"tool-searchMemories",
|
||||
"tool-addMemory",
|
||||
].includes(part.type),
|
||||
)
|
||||
.map((part, index) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div key={`${message.id}-${part.type}-${index}`}>
|
||||
<Streamdown>{part.text}</Streamdown>
|
||||
</div>
|
||||
)
|
||||
case "tool-searchMemories": {
|
||||
switch (part.state) {
|
||||
case "input-available":
|
||||
case "input-streaming":
|
||||
return (
|
||||
<div
|
||||
className="text-sm flex items-center gap-2 text-muted-foreground"
|
||||
key={`${message.id}-${part.type}-${index}`}
|
||||
>
|
||||
<Spinner className="size-4" /> Searching
|
||||
memories...
|
||||
</div>
|
||||
)
|
||||
case "output-error":
|
||||
return (
|
||||
<div
|
||||
className="text-sm flex items-center gap-2 text-muted-foreground"
|
||||
key={`${message.id}-${part.type}-${index}`}
|
||||
>
|
||||
<X className="size-4" /> Error recalling
|
||||
memories
|
||||
</div>
|
||||
)
|
||||
case "output-available": {
|
||||
const output = part.output
|
||||
const foundCount =
|
||||
typeof output === "object" &&
|
||||
output !== null &&
|
||||
"count" in output
|
||||
? Number(output.count) || 0
|
||||
: 0
|
||||
// @ts-expect-error
|
||||
const results = Array.isArray(output?.results)
|
||||
? // @ts-expect-error
|
||||
output.results
|
||||
: []
|
||||
|
||||
return (
|
||||
<ExpandableMemories
|
||||
foundCount={foundCount}
|
||||
key={`${message.id}-${part.type}-${index}`}
|
||||
results={results}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
case "tool-addMemory": {
|
||||
switch (part.state) {
|
||||
case "input-available":
|
||||
return (
|
||||
<div
|
||||
className="text-sm flex items-center gap-2 text-muted-foreground"
|
||||
key={`${message.id}-${part.type}-${index}`}
|
||||
>
|
||||
<Spinner className="size-4" /> Adding
|
||||
memory...
|
||||
</div>
|
||||
)
|
||||
case "output-error":
|
||||
return (
|
||||
<div
|
||||
className="text-sm flex items-center gap-2 text-muted-foreground"
|
||||
key={`${message.id}-${part.type}-${index}`}
|
||||
>
|
||||
<X className="size-4" /> Error adding memory
|
||||
</div>
|
||||
)
|
||||
case "output-available":
|
||||
return (
|
||||
<div
|
||||
className="text-sm flex items-center gap-2 text-muted-foreground"
|
||||
key={`${message.id}-${part.type}-${index}`}
|
||||
>
|
||||
<Check className="size-4" /> Memory added
|
||||
</div>
|
||||
)
|
||||
case "input-streaming":
|
||||
return (
|
||||
<div
|
||||
className="text-sm flex items-center gap-2 text-muted-foreground"
|
||||
key={`${message.id}-${part.type}-${index}`}
|
||||
>
|
||||
<Spinner className="size-4" /> Adding
|
||||
memory...
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{message.role === "assistant" && (
|
||||
<div className="flex items-center gap-0.5 mt-0.5">
|
||||
<Button
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
message.parts
|
||||
.filter((p) => p.type === "text")
|
||||
?.map((p) => (p as MessagePart).text ?? "")
|
||||
.join("\n") ?? "",
|
||||
)
|
||||
toast.success("Copied to clipboard")
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => regenerate({ messageId: message.id })}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{status === "submitted" && (
|
||||
<div className="flex text-muted-foreground justify-start gap-2 px-4 py-3 items-center w-full">
|
||||
<Spinner className="size-4" />
|
||||
<TextShimmer className="text-sm" duration={1.5}>
|
||||
Thinking...
|
||||
</TextShimmer>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full w-fit mx-auto shadow-md z-10 absolute inset-x-0 bottom-4 flex justify-center",
|
||||
"transition-all duration-200 ease-out",
|
||||
isFarFromBottom
|
||||
? "opacity-100 scale-100 pointer-events-auto"
|
||||
: "opacity-0 scale-95 pointer-events-none",
|
||||
)}
|
||||
onClick={() => {
|
||||
enableAutoScroll()
|
||||
scrollToBottom("smooth")
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="default"
|
||||
>
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pb-4 px-4 md:px-2 max-w-4xl mx-auto w-full">
|
||||
<form
|
||||
className="flex flex-col items-end gap-3 border border-border rounded-[22px] p-3 relative shadow-lg dark:shadow-2xl"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const sent = handleSendMessage()
|
||||
if (sent) {
|
||||
enableAutoScroll()
|
||||
scrollToBottom("auto")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-busy={status === "streaming" || status === "submitted"}
|
||||
aria-disabled={status === "submitted"}
|
||||
placeholder="Ask your follow-up question..."
|
||||
className="w-full text-foreground placeholder:text-muted-foreground rounded-md outline-none resize-none text-base leading-relaxed px-3 py-3 bg-transparent"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ModelIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{/*{modelNames[selectedModel]}*/}
|
||||
</span>
|
||||
</div>
|
||||
{status === "streaming" || status === "submitted" ? (
|
||||
<Button
|
||||
onClick={() => stop()}
|
||||
aria-label="Stop generation"
|
||||
className="rounded-xl"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
type="button"
|
||||
>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
aria-label="Send message"
|
||||
disabled={!input.trim()}
|
||||
className="text-primary-foreground rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed bg-primary hover:bg-primary/90"
|
||||
size="icon"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@ui/components/dialog"
|
||||
import { ScrollArea } from "@ui/components/scroll-area"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { HistoryIcon, Plus, Trash2, X } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useChatOpen, usePersistentChat, useProject } from "@/stores"
|
||||
import { ChatMessages } from "./chat-messages"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
|
||||
export function ChatRewrite() {
|
||||
const { setIsOpen } = useChatOpen()
|
||||
const { selectedProject } = useProject()
|
||||
const { conversations, currentChatId, setCurrentChatId, getCurrentChat } =
|
||||
usePersistentChat()
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...conversations].sort((a, b) =>
|
||||
a.lastUpdated < b.lastUpdated ? 1 : -1,
|
||||
)
|
||||
}, [conversations])
|
||||
|
||||
function handleNewChat() {
|
||||
analytics.newChatStarted()
|
||||
const newId = generateId()
|
||||
setCurrentChatId(newId)
|
||||
setIsDialogOpen(false)
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoString: string): string {
|
||||
return formatDistanceToNow(new Date(isoString), { addSuffix: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-hidden border-l bg-background">
|
||||
<div className="border-b px-4 py-3 flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold line-clamp-1 text-ellipsis overflow-hidden">
|
||||
{getCurrentChat()?.title ?? "New Chat"}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => analytics.chatHistoryViewed()}
|
||||
>
|
||||
<HistoryIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader className="pb-4 border-b rounded-t-lg">
|
||||
<DialogTitle className="">Conversations</DialogTitle>
|
||||
<DialogDescription>
|
||||
Project{" "}
|
||||
<span className="font-mono font-medium">
|
||||
{selectedProject}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-96">
|
||||
<div className="flex flex-col gap-1">
|
||||
{sorted.map((c) => {
|
||||
const isActive = c.id === currentChatId
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={c.id}
|
||||
onClick={() => {
|
||||
setCurrentChatId(c.id)
|
||||
setIsDialogOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-md px-3 py-2 outline-none w-full text-left",
|
||||
"transition-colors",
|
||||
isActive ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
isActive ? "text-foreground" : undefined,
|
||||
)}
|
||||
>
|
||||
{c.title || "Untitled Chat"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Last updated {formatRelativeTime(c.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
analytics.chatDeleted()
|
||||
}}
|
||||
aria-label="Delete conversation"
|
||||
>
|
||||
<Trash2 className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{sorted.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground px-3 py-2">
|
||||
No conversations yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full border-dashed"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus className="size-4 mr-1" /> New Conversation
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button variant="outline" size="icon" onClick={handleNewChat}>
|
||||
<Plus className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<X className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ChatMessages />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import { Skeleton } from "@repo/ui/components/skeleton"
|
||||
import type { ConnectionResponseSchema } from "@repo/validation/api"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { z } from "zod"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useProject } from "@/stores"
|
||||
|
||||
// Define types
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
// Connector configurations
|
||||
const CONNECTORS = {
|
||||
"google-drive": {
|
||||
title: "Google Drive",
|
||||
description: "Connect your Google Docs, Sheets, and Slides",
|
||||
icon: GoogleDrive,
|
||||
},
|
||||
notion: {
|
||||
title: "Notion",
|
||||
description: "Import your Notion pages and databases",
|
||||
icon: Notion,
|
||||
},
|
||||
onedrive: {
|
||||
title: "OneDrive",
|
||||
description: "Access your Microsoft Office documents",
|
||||
icon: OneDrive,
|
||||
},
|
||||
} as const
|
||||
|
||||
type ConnectorProvider = keyof typeof CONNECTORS
|
||||
|
||||
export function ConnectionsTabContent() {
|
||||
const queryClient = useQueryClient()
|
||||
const { selectedProject } = useProject()
|
||||
const autumn = useCustomer()
|
||||
const [isProUser, setIsProUser] = useState(false)
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
await autumn.attach({
|
||||
productId: "api_pro",
|
||||
successUrl: "https://app.supermemory.ai/",
|
||||
})
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Set pro user status when autumn data loads
|
||||
useEffect(() => {
|
||||
if (!autumn.isLoading) {
|
||||
setIsProUser(
|
||||
autumn.customer?.products.some((product) => product.id === "api_pro") ??
|
||||
false,
|
||||
)
|
||||
}
|
||||
}, [autumn.isLoading, autumn.customer])
|
||||
|
||||
// Get connections data directly from autumn customer
|
||||
const connectionsFeature = autumn.customer?.features?.connections
|
||||
const connectionsUsed = connectionsFeature?.usage ?? 0
|
||||
const connectionsLimit = connectionsFeature?.included_usage ?? 0
|
||||
|
||||
const canAddConnection = connectionsUsed < connectionsLimit
|
||||
|
||||
// Fetch connections
|
||||
const {
|
||||
data: connections = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["connections"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/connections/list", {
|
||||
body: {
|
||||
containerTags: [],
|
||||
},
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to load connections")
|
||||
}
|
||||
|
||||
return response.data as Connection[]
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
})
|
||||
|
||||
// Show error toast if connections fail to load
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error("Failed to load connections", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
}
|
||||
}, [error])
|
||||
|
||||
// Add connection mutation
|
||||
const addConnectionMutation = useMutation({
|
||||
mutationFn: async (provider: ConnectorProvider) => {
|
||||
// Check if user can add connections
|
||||
if (!canAddConnection && !isProUser) {
|
||||
throw new Error(
|
||||
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
|
||||
)
|
||||
}
|
||||
|
||||
const response = await $fetch("@post/connections/:provider", {
|
||||
params: { provider },
|
||||
body: {
|
||||
redirectUrl: window.location.href,
|
||||
containerTags: [selectedProject],
|
||||
},
|
||||
})
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: its fine
|
||||
if ("data" in response && !("error" in response.data!)) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
throw new Error(response.error?.message || "Failed to connect")
|
||||
},
|
||||
onSuccess: (data, provider) => {
|
||||
analytics.connectionAdded(provider)
|
||||
analytics.connectionAuthStarted()
|
||||
autumn.track({
|
||||
featureId: "connections",
|
||||
value: 1,
|
||||
})
|
||||
if (data?.authLink) {
|
||||
window.location.href = data.authLink
|
||||
}
|
||||
},
|
||||
onError: (error, provider) => {
|
||||
analytics.connectionAuthFailed()
|
||||
toast.error(`Failed to connect ${provider}`, {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Delete connection mutation
|
||||
const deleteConnectionMutation = useMutation({
|
||||
mutationFn: async (connectionId: string) => {
|
||||
await $fetch(`@delete/connections/${connectionId}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
analytics.connectionDeleted()
|
||||
toast.success(
|
||||
"Connection removal has started. supermemory will permanently delete the documents in the next few minutes.",
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ["connections"] })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to remove connection", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const getProviderIcon = (provider: string) => {
|
||||
const connector = CONNECTORS[provider as ConnectorProvider]
|
||||
if (connector) {
|
||||
const Icon = connector.icon
|
||||
return <Icon className="h-10 w-10" />
|
||||
}
|
||||
return <span className="text-2xl">📎</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
Connect your favorite services to import documents
|
||||
</p>
|
||||
{isProUser && !autumn.isLoading && (
|
||||
<p className="text-xs text-foreground/50 mt-1">
|
||||
{connectionsUsed} of {connectionsLimit} connections used
|
||||
</p>
|
||||
)}
|
||||
{!isProUser && !autumn.isLoading && (
|
||||
<p className="text-xs text-foreground/50 mt-1">
|
||||
Connections require a Pro subscription
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show upgrade prompt for free users */}
|
||||
{!autumn.isLoading && !isProUser && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<p className="text-sm text-black-400 dark:text-yellow-400 mb-2">
|
||||
🔌 Connections are a Pro feature
|
||||
</p>
|
||||
<p className="text-xs text-foreground/60 mb-3">
|
||||
Connect Google Drive, Notion, OneDrive and more to automatically
|
||||
sync your documents.
|
||||
</p>
|
||||
<Button
|
||||
className="bg-yellow-500/20 text-black-400 hover:bg-yellow-500/30 dark:text-yellow-400 border-yellow-500/30 cursor-pointer"
|
||||
onClick={handleUpgrade}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="p-4 bg-foreground/5 rounded-lg"
|
||||
initial={{ opacity: 0 }}
|
||||
key={`skeleton-${Date.now()}-${i}`}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
>
|
||||
<Skeleton className="h-12 w-full bg-foreground/10" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : connections.length === 0 ? (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-4"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
>
|
||||
<p className="text-foreground/50 mb-2">No connections yet</p>
|
||||
<p className="text-xs text-foreground/40">
|
||||
Choose a service below to connect
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div className="space-y-2">
|
||||
<AnimatePresence>
|
||||
{connections.map((connection, index) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex items-center justify-between p-3 bg-foreground/5 rounded-lg hover:bg-foreground/10 transition-colors"
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
key={connection.id}
|
||||
layout
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getProviderIcon(connection.provider)}
|
||||
<div>
|
||||
<p className="font-medium text-foreground capitalize">
|
||||
{connection.provider.replace("-", " ")}
|
||||
</p>
|
||||
{connection.email && (
|
||||
<p className="text-sm text-foreground/60">
|
||||
{connection.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
className="text-foreground/50 hover:text-red-400 cursor-pointer"
|
||||
disabled={deleteConnectionMutation.isPending}
|
||||
onClick={() =>
|
||||
deleteConnectionMutation.mutate(connection.id)
|
||||
}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Available Connections Section */}
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-medium mb-4">Available Connections</h3>
|
||||
<div className="grid gap-3">
|
||||
{Object.entries(CONNECTORS).map(([provider, config], index) => {
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
key={provider}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Button
|
||||
className="justify-start h-auto p-4 bg-foreground/5 hover:bg-foreground/10 border-foreground/10 w-full cursor-pointer"
|
||||
disabled={addConnectionMutation.isPending}
|
||||
onClick={() => {
|
||||
addConnectionMutation.mutate(provider as ConnectorProvider)
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<Icon className="h-8 w-8 mr-3" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">{config.title}</div>
|
||||
<div className="text-sm text-foreground/60 mt-0.5">
|
||||
{config.description}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,311 +0,0 @@
|
|||
import { $fetch } from "@lib/api"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useForm } from "@tanstack/react-form"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@ui/components/dialog"
|
||||
import { Input } from "@ui/components/input"
|
||||
import { CopyableCell } from "@ui/copyable-cell"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import Image from "next/image"
|
||||
import { generateSlug } from "random-word-slugs"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod/v4"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { InstallationDialogContent } from "./installation-dialog-content"
|
||||
|
||||
// Validation schemas
|
||||
const mcpMigrationSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.min(1, "MCP Link is required")
|
||||
.regex(
|
||||
/^https:\/\/mcp\.supermemory\.ai\/[^/]+\/sse$/,
|
||||
"Link must be in format: https://mcp.supermemory.ai/userId/sse",
|
||||
),
|
||||
})
|
||||
|
||||
export function MCPView() {
|
||||
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false)
|
||||
const projectId = localStorage.getItem("selectedProject") ?? "default"
|
||||
const { org } = useAuth()
|
||||
const [apiKey, setApiKey] = useState<string>()
|
||||
const [isInstallDialogOpen, setIsInstallDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
analytics.mcpViewOpened()
|
||||
}, [])
|
||||
const apiKeyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (apiKey) return apiKey
|
||||
const res = await authClient.apiKey.create({
|
||||
metadata: {
|
||||
organizationId: org?.id,
|
||||
},
|
||||
name: generateSlug(),
|
||||
prefix: `sm_${org?.id}_`,
|
||||
})
|
||||
return res.key
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setApiKey(data)
|
||||
setIsInstallDialogOpen(true)
|
||||
},
|
||||
})
|
||||
|
||||
// Form for MCP migration
|
||||
const mcpMigrationForm = useForm({
|
||||
defaultValues: { url: "" },
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
const userId = extractUserIdFromMCPUrl(value.url)
|
||||
if (userId) {
|
||||
migrateMCPMutation.mutate({ userId, projectId })
|
||||
formApi.reset()
|
||||
}
|
||||
},
|
||||
validators: {
|
||||
onChange: mcpMigrationSchema,
|
||||
},
|
||||
})
|
||||
|
||||
const extractUserIdFromMCPUrl = (url: string): string | null => {
|
||||
const regex = /^https:\/\/mcp\.supermemory\.ai\/([^/]+)\/sse$/
|
||||
const match = url.trim().match(regex)
|
||||
return match?.[1] || null
|
||||
}
|
||||
|
||||
// Migrate MCP mutation
|
||||
const migrateMCPMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
userId,
|
||||
projectId,
|
||||
}: {
|
||||
userId: string
|
||||
projectId: string
|
||||
}) => {
|
||||
const response = await $fetch("@post/documents/migrate-mcp", {
|
||||
body: { userId, projectId },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to migrate documents",
|
||||
)
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success("Migration completed!", {
|
||||
description: `Successfully migrated ${data?.migratedCount} documents`,
|
||||
})
|
||||
setIsMigrateDialogOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Migration failed", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-sm text-white/70">
|
||||
Use MCP to create and access memories directly from your AI assistant.
|
||||
Integrate supermemory with Claude Desktop, Cursor, and other AI tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className="text-sm font-medium text-white/80 block mb-2"
|
||||
htmlFor="mcp-server-url"
|
||||
>
|
||||
MCP Server URL
|
||||
</label>
|
||||
<div className="p-3 bg-white/5 rounded border border-white/10">
|
||||
<CopyableCell
|
||||
className="font-mono text-sm text-blue-400"
|
||||
value="https://mcp.supermemory.ai/mcp"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-white/50 mt-2">
|
||||
Use this URL to configure supermemory in your AI assistant
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Dialog
|
||||
onOpenChange={setIsInstallDialogOpen}
|
||||
open={isInstallDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={apiKeyMutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
apiKeyMutation.mutate()
|
||||
}}
|
||||
>
|
||||
Install Now
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{apiKey && <InstallationDialogContent />}
|
||||
</Dialog>
|
||||
<motion.a
|
||||
className="inline-block"
|
||||
href="https://cursor.com/en-US/install-mcp?name=supermemory&config=eyJ1cmwiOiJodHRwczovL21jcC5zdXBlcm1lbW9yeS5haS9tY3AiLCJoZWFkZXJzIjp7fX0%3D"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Image
|
||||
alt="Add supermemory MCP server to Cursor"
|
||||
height="32"
|
||||
src="https://cursor.com/deeplink/mcp-install-dark.svg"
|
||||
width="128"
|
||||
/>
|
||||
</motion.a>
|
||||
|
||||
<div className="h-8 w-px bg-white/10" />
|
||||
|
||||
<motion.div whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
className="bg-white/5 hover:bg-white/10 border-white/10 text-white h-8"
|
||||
onClick={() => setIsMigrateDialogOpen(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Migrate from v1
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isMigrateDialogOpen && (
|
||||
<Dialog
|
||||
onOpenChange={setIsMigrateDialogOpen}
|
||||
open={isMigrateDialogOpen}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Migrate from MCP v1</DialogTitle>
|
||||
<DialogDescription className="text-white/60">
|
||||
Migrate your MCP documents from the legacy system.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
mcpMigrationForm.handleSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col gap-2"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<label className="text-sm font-medium" htmlFor="mcpUrl">
|
||||
MCP Link
|
||||
</label>
|
||||
<mcpMigrationForm.Field name="url">
|
||||
{({ state, handleChange, handleBlur }) => (
|
||||
<>
|
||||
<Input
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
id="mcpUrl"
|
||||
onBlur={handleBlur}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="https://mcp.supermemory.ai/your-user-id/sse"
|
||||
value={state.value}
|
||||
/>
|
||||
{state.meta.errors.length > 0 && (
|
||||
<motion.p
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
className="text-sm text-red-400 mt-1"
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
{state.meta.errors.join(", ")}
|
||||
</motion.p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</mcpMigrationForm.Field>
|
||||
<p className="text-xs text-white/50">
|
||||
Enter your old MCP Link in the format: <br />
|
||||
<span className="font-mono">
|
||||
https://mcp.supermemory.ai/userId/sse
|
||||
</span>
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
|
||||
onClick={() => {
|
||||
setIsMigrateDialogOpen(false)
|
||||
mcpMigrationForm.reset()
|
||||
}}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
|
||||
disabled={
|
||||
migrateMCPMutation.isPending ||
|
||||
!mcpMigrationForm.state.canSubmit
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
{migrateMCPMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Migrating...
|
||||
</>
|
||||
) : (
|
||||
"Migrate"
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@ui/components/dialog"
|
||||
import { Input } from "@ui/components/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select"
|
||||
import { Label } from "@ui/components/label"
|
||||
import { CopyIcon } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { $fetch } from "@repo/lib/api"
|
||||
import type { Project } from "@repo/lib/types"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
const clients = {
|
||||
cursor: "Cursor",
|
||||
claude: "Claude Desktop",
|
||||
vscode: "VSCode",
|
||||
cline: "Cline",
|
||||
"roo-cline": "Roo Cline",
|
||||
witsy: "Witsy",
|
||||
enconvo: "Enconvo",
|
||||
"gemini-cli": "Gemini CLI",
|
||||
"claude-code": "Claude Code",
|
||||
} as const
|
||||
|
||||
export function InstallationDialogContent() {
|
||||
const [client, setClient] = useState<keyof typeof clients>("cursor")
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>("none")
|
||||
|
||||
// Fetch projects
|
||||
const { data: projects = [], isLoading: isLoadingProjects } = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@get/projects")
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to load projects")
|
||||
}
|
||||
|
||||
return response.data?.projects || []
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
// Generate installation command based on selected project
|
||||
function generateInstallCommand() {
|
||||
let command = `npx -y install-mcp@latest https://mcp.supermemory.ai/mcp --client ${client} --oauth=yes`
|
||||
|
||||
if (selectedProject && selectedProject !== "none") {
|
||||
// Remove the "sm_project_" prefix from the containerTag
|
||||
const projectId = selectedProject.replace(/^sm_project_/, "")
|
||||
command += ` --project ${projectId}`
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install the supermemory MCP Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select the app and project you want to install supermemory MCP to,
|
||||
then run the following command:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client-select">Client Application</Label>
|
||||
<Select
|
||||
onValueChange={(value) => setClient(value as keyof typeof clients)}
|
||||
value={client}
|
||||
>
|
||||
<SelectTrigger id="client-select" className="w-full">
|
||||
<SelectValue placeholder="Select client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(clients).map(([key, value]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-select">Target Project (Optional)</Label>
|
||||
<Select
|
||||
onValueChange={setSelectedProject}
|
||||
value={selectedProject || "none"}
|
||||
disabled={isLoadingProjects}
|
||||
>
|
||||
<SelectTrigger id="project-select" className="w-full">
|
||||
<SelectValue placeholder="Select project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
|
||||
<SelectItem value="none" className="text-white hover:bg-white/10">
|
||||
Auto-select project
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="sm_project_default"
|
||||
className="text-white hover:bg-white/10"
|
||||
>
|
||||
Default Project
|
||||
</SelectItem>
|
||||
{projects
|
||||
.filter((p: Project) => p.containerTag !== "sm_project_default")
|
||||
.map((project: Project) => (
|
||||
<SelectItem
|
||||
key={project.id}
|
||||
value={project.containerTag}
|
||||
className="text-white hover:bg-white/10"
|
||||
>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="command-input">Installation Command</Label>
|
||||
<Input
|
||||
id="command-input"
|
||||
className="font-mono text-xs!"
|
||||
readOnly
|
||||
value={generateInstallCommand()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
const command = generateInstallCommand()
|
||||
navigator.clipboard.writeText(command)
|
||||
analytics.mcpInstallCmdCopied()
|
||||
toast.success("Copied to clipboard!")
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" /> Copy Installation Command
|
||||
</Button>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,486 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { authClient } from "@lib/auth"
|
||||
import {
|
||||
fetchConnectionsFeature,
|
||||
fetchMemoriesFeature,
|
||||
fetchSubscriptionStatus,
|
||||
} from "@lib/queries"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@repo/ui/components/alert-dialog"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import { Input } from "@repo/ui/components/input"
|
||||
import { Skeleton } from "@repo/ui/components/skeleton"
|
||||
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
CreditCard,
|
||||
LoaderIcon,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
|
||||
export function ProfileView() {
|
||||
const { user: session, org } = useAuth()
|
||||
const organizations = org
|
||||
const autumn = useCustomer()
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState("")
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
data: status = {
|
||||
api_pro: { allowed: false, status: null },
|
||||
},
|
||||
isLoading: isCheckingStatus,
|
||||
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
|
||||
|
||||
const proStatus = status.api_pro
|
||||
const isPro = proStatus?.allowed ?? false
|
||||
const proProductStatus = proStatus?.status
|
||||
const isPastDue = proProductStatus === "past_due"
|
||||
const hasProProduct = proProductStatus !== null
|
||||
|
||||
const { data: memoriesCheck } = fetchMemoriesFeature(
|
||||
autumn,
|
||||
!isCheckingStatus && !autumn.isLoading,
|
||||
)
|
||||
const memoriesUsed = memoriesCheck?.usage ?? 0
|
||||
const memoriesLimit = memoriesCheck?.included_usage ?? 0
|
||||
|
||||
const { data: connectionsCheck } = fetchConnectionsFeature(
|
||||
autumn,
|
||||
!isCheckingStatus && !autumn.isLoading,
|
||||
)
|
||||
const connectionsUsed = connectionsCheck?.usage ?? 0
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await autumn.attach({
|
||||
productId: "api_pro",
|
||||
successUrl: "https://app.supermemory.ai/",
|
||||
})
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleManageBilling = async () => {
|
||||
await autumn.openBillingPortal({
|
||||
returnUrl: "https://app.supermemory.ai",
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (deleteConfirmation !== "DELETE" || !org?.id) return
|
||||
|
||||
setIsDeleting(true)
|
||||
setDeleteError(null)
|
||||
|
||||
try {
|
||||
await authClient.organization.delete({
|
||||
organizationId: org.id,
|
||||
})
|
||||
|
||||
await authClient.signOut()
|
||||
|
||||
router.push("/login")
|
||||
} catch (error) {
|
||||
console.error("Failed to delete account:", error)
|
||||
setDeleteError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to delete account. Please try again or contact support.",
|
||||
)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (session?.isAnonymous) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-8"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
>
|
||||
<p className="text-foreground/70 mb-4">
|
||||
Sign in to access your profile and billing
|
||||
</p>
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
asChild
|
||||
className="bg-muted hover:bg-muted/80 text-foreground border-border"
|
||||
size="sm"
|
||||
>
|
||||
<Link href="/login">Sign in</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Profile Section */}
|
||||
<div className="bg-card border border-border rounded-lg p-3 sm:p-4 space-y-4">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
{session?.image ? (
|
||||
<img
|
||||
src={session.image}
|
||||
alt={session?.name || session?.email || "User"}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User className="w-6 h-6 sm:w-8 sm:h-8 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="space-y-1">
|
||||
{session?.name && (
|
||||
<h3 className="text-foreground font-semibold text-base sm:text-lg truncate">
|
||||
{session.name}
|
||||
</h3>
|
||||
)}
|
||||
<p className="text-foreground font-medium text-sm truncate">
|
||||
{session?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Profile Details */}
|
||||
<div className="border-t border-border pt-3 space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground block">Organization</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{organizations?.name || "Personal"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block">Member since</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{session?.createdAt
|
||||
? new Date(session.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
: "Recent"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing Section */}
|
||||
{autumn.isLoading || isCheckingStatus ? (
|
||||
<div className="bg-card border border-border rounded-lg p-3 sm:p-4 space-y-3">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Skeleton className="w-8 h-8 sm:w-10 sm:h-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-8" />
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Skeleton className="h-8 w-full rounded" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card border border-border rounded-lg p-3 sm:p-4 space-y-3">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-muted rounded-full flex items-center justify-center">
|
||||
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<HeadingH3Bold className="text-foreground text-sm">
|
||||
{hasProProduct ? "Pro Plan" : "Free Plan"}
|
||||
{isPastDue ? (
|
||||
<span className="ml-2 text-xs bg-red-500/20 text-red-600 dark:text-red-400 px-2 py-0.5 rounded-full">
|
||||
Past Due
|
||||
</span>
|
||||
) : isPro ? (
|
||||
<span className="ml-2 text-xs bg-green-500/20 text-green-600 dark:text-green-400 px-2 py-0.5 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
) : null}
|
||||
</HeadingH3Bold>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{hasProProduct ? "Expanded memory capacity" : "Basic plan"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPastDue && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-red-600 dark:text-red-400 font-medium">
|
||||
Payment Required
|
||||
</p>
|
||||
<p className="text-xs text-red-600/80 dark:text-red-400/80 mt-1">
|
||||
Your payment is past due. Please update your payment method to
|
||||
restore access to Pro features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Stats */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Memories</span>
|
||||
{hasProProduct ? (
|
||||
<span className="text-sm text-foreground">Unlimited</span>
|
||||
) : (
|
||||
<span
|
||||
className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-500" : "text-foreground"}`}
|
||||
>
|
||||
{memoriesUsed} / {memoriesLimit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!hasProProduct && (
|
||||
<div className="w-full bg-muted-foreground/50 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
memoriesUsed >= memoriesLimit ? "bg-red-500" : "bg-blue-500"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasProProduct && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Connections</span>
|
||||
<span className="text-sm text-foreground">
|
||||
{connectionsUsed} / 10
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Actions */}
|
||||
<div className="pt-2">
|
||||
{isPastDue ? (
|
||||
<Button
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white border-0"
|
||||
onClick={handleManageBilling}
|
||||
size="sm"
|
||||
variant="default"
|
||||
>
|
||||
Pay Past Due
|
||||
</Button>
|
||||
) : hasProProduct ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleManageBilling}
|
||||
size="sm"
|
||||
variant="default"
|
||||
>
|
||||
Manage Billing
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full bg-[#267ffa] hover:bg-[#267ffa]/90 text-white border-0"
|
||||
disabled={isLoading || isCheckingStatus}
|
||||
onClick={handleUpgrade}
|
||||
size="lg"
|
||||
>
|
||||
{isLoading || isCheckingStatus ? (
|
||||
<>
|
||||
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="hidden sm:inline">Upgrading...</span>
|
||||
<span className="sm:hidden">Loading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="hidden sm:inline">
|
||||
Upgrade to Pro - $9/month
|
||||
</span>
|
||||
<span className="sm:hidden">Upgrade to Pro</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasProProduct && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
{/* Free Plan */}
|
||||
<div className="p-3 bg-muted/50 rounded-lg border border-border">
|
||||
<h4 className="font-medium text-foreground mb-3 text-sm">
|
||||
Free Plan
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
|
||||
<CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" />
|
||||
200 memories
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
|
||||
<X className="h-3 w-3 sm:h-4 sm:w-4 text-red-500 flex-shrink-0" />
|
||||
No connections
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
|
||||
<CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" />
|
||||
Basic search
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Pro Plan */}
|
||||
<div className="p-3 bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-lg border border-blue-500/20">
|
||||
<h4 className="font-medium text-foreground mb-3 text-sm">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span>Pro Plan</span>
|
||||
<span className="text-xs bg-blue-500/20 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded-full">
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center gap-2 text-xs sm:text-sm text-foreground">
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
Unlimited memories
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-xs sm:text-sm text-foreground">
|
||||
<CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" />
|
||||
10 connections
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-xs sm:text-sm text-foreground">
|
||||
<CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" />
|
||||
Advanced search
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-xs sm:text-sm text-foreground">
|
||||
<CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" />
|
||||
Priority support
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center leading-relaxed">
|
||||
$9/month (only for first 100 users) • Cancel anytime. No
|
||||
questions asked.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Account */}
|
||||
{!session?.isAnonymous && org && (
|
||||
<div className="pt-4 mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">Delete account</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Permanently delete your data and cancel subscription
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-500/10"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete account?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
This will permanently delete your memories, connections,
|
||||
settings, and cancel any subscriptions.
|
||||
</p>
|
||||
<p className="text-foreground">
|
||||
Type{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-red-500">
|
||||
DELETE
|
||||
</code>{" "}
|
||||
to confirm:
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<Input
|
||||
value={deleteConfirmation}
|
||||
onChange={(e) => setDeleteConfirmation(e.target.value)}
|
||||
placeholder="DELETE"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{deleteError && (
|
||||
<p className="text-sm text-red-500">{deleteError}</p>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={() => {
|
||||
setDeleteConfirmation("")
|
||||
setDeleteError(null)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleteConfirmation !== "DELETE" || isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete account"}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,749 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@repo/ui/components/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@repo/ui/components/dropdown-menu"
|
||||
import { Input } from "@repo/ui/components/input"
|
||||
import { Label } from "@repo/ui/components/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/ui/components/select"
|
||||
import { Skeleton } from "@repo/ui/components/skeleton"
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { FolderIcon, Loader2, MoreVertical, Plus, Trash2 } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useProject } from "@/stores"
|
||||
|
||||
// Projects View Component
|
||||
export function ProjectsView() {
|
||||
const queryClient = useQueryClient()
|
||||
const { selectedProject, setSelectedProject } = useProject()
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [projectName, setProjectName] = useState("")
|
||||
const [deleteDialog, setDeleteDialog] = useState<{
|
||||
open: boolean
|
||||
project: null | { id: string; name: string; containerTag: string }
|
||||
action: "move" | "delete"
|
||||
targetProjectId: string
|
||||
}>({
|
||||
open: false,
|
||||
project: null,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
const [expDialog, setExpDialog] = useState<{
|
||||
open: boolean
|
||||
projectId: string
|
||||
}>({
|
||||
open: false,
|
||||
projectId: "",
|
||||
})
|
||||
|
||||
// Fetch projects
|
||||
const {
|
||||
data: projects = [],
|
||||
isLoading,
|
||||
// error,
|
||||
} = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@get/projects")
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to load projects")
|
||||
}
|
||||
|
||||
return response.data?.projects || []
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
// Create project mutation
|
||||
const createProjectMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const response = await $fetch("@post/projects", {
|
||||
body: { name },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to create project")
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Project created successfully!")
|
||||
setShowCreateDialog(false)
|
||||
setProjectName("")
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create project", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Delete project mutation
|
||||
const deleteProjectMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
projectId,
|
||||
action,
|
||||
targetProjectId,
|
||||
}: {
|
||||
projectId: string
|
||||
action: "move" | "delete"
|
||||
targetProjectId?: string
|
||||
}) => {
|
||||
const response = await $fetch(`@delete/projects/${projectId}`, {
|
||||
body: { action, targetProjectId },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to delete project")
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Project deleted successfully")
|
||||
setDeleteDialog({
|
||||
open: false,
|
||||
project: null,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] })
|
||||
|
||||
// If we deleted the selected project, switch to default
|
||||
if (deleteDialog.project?.containerTag === selectedProject) {
|
||||
setSelectedProject("sm_project_default")
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete project", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Enable experimental mode mutation
|
||||
const enableExperimentalMutation = useMutation({
|
||||
mutationFn: async (projectId: string) => {
|
||||
const response = await $fetch(
|
||||
`@post/projects/${projectId}/enable-experimental`,
|
||||
)
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to enable experimental mode",
|
||||
)
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Experimental mode enabled for project")
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] })
|
||||
setExpDialog({ open: false, projectId: "" })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to enable experimental mode", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Handle project selection
|
||||
const handleProjectSelect = (containerTag: string) => {
|
||||
setSelectedProject(containerTag)
|
||||
toast.success("Project switched successfully")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-white/70">
|
||||
Organize your memories into separate projects
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-sm text-white/50">Current project:</p>
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Project
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="p-4 bg-white/5 rounded-lg"
|
||||
initial={{ opacity: 0 }}
|
||||
key={`skeleton-project-${Date.now()}-${i}`}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
>
|
||||
<Skeleton className="h-12 w-full bg-white/10" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-8"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
>
|
||||
<p className="text-white/50 mb-4">No projects yet</p>
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
Create Your First Project
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div className="space-y-2">
|
||||
<AnimatePresence>
|
||||
{/* Default project */}
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${
|
||||
selectedProject === "sm_project_default"
|
||||
? "bg-white/20 border border-white/30"
|
||||
: "bg-white/5 hover:bg-white/10"
|
||||
}`}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
key="default-project"
|
||||
layout
|
||||
onClick={() => handleProjectSelect("sm_project_default")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
initial={{ rotate: -180, opacity: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<FolderIcon className="h-5 w-5 text-white/80" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<p className="font-medium text-white">Default Project</p>
|
||||
<p className="text-sm text-white/60">
|
||||
Your default memory storage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedProject === "sm_project_default" && (
|
||||
<motion.div
|
||||
animate={{ scale: 1 }}
|
||||
initial={{ scale: 0 }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
>
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* User projects */}
|
||||
{projects
|
||||
.filter((p) => p.containerTag !== "sm_project_default")
|
||||
.map((project, index) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${
|
||||
selectedProject === project.containerTag
|
||||
? "bg-white/20 border border-white/30"
|
||||
: "bg-white/5 hover:bg-white/10"
|
||||
}`}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
key={project.id}
|
||||
layout
|
||||
onClick={() => handleProjectSelect(project.containerTag)}
|
||||
transition={{ delay: (index + 1) * 0.05 }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
initial={{ rotate: -180, opacity: 0 }}
|
||||
transition={{ delay: (index + 1) * 0.05 + 0.2 }}
|
||||
>
|
||||
<FolderIcon className="h-5 w-5 text-white/80" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<p className="font-medium text-white">{project.name}</p>
|
||||
<p className="text-sm text-white/60">
|
||||
Created{" "}
|
||||
{new Date(project.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedProject === project.containerTag && (
|
||||
<motion.div
|
||||
animate={{ scale: 1 }}
|
||||
initial={{ scale: 0 }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
>
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full" />
|
||||
</motion.div>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="text-white/50 hover:text-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-black/90 border-white/10"
|
||||
>
|
||||
{/* Show experimental toggle only if NOT experimental and NOT default project */}
|
||||
{!project.isExperimental &&
|
||||
project.containerTag !== "sm_project_default" && (
|
||||
<DropdownMenuItem
|
||||
className="text-blue-400 hover:text-blue-300 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpDialog({
|
||||
open: true,
|
||||
projectId: project.id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="h-4 w-4 mr-2 rounded border border-blue-400" />
|
||||
Enable Experimental Mode
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{project.isExperimental && (
|
||||
<DropdownMenuItem
|
||||
className="text-blue-300/50"
|
||||
disabled
|
||||
>
|
||||
<div className="h-4 w-4 mr-2 rounded bg-blue-400" />
|
||||
Experimental Mode Active
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 hover:text-red-300 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteDialog({
|
||||
open: true,
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
containerTag: project.containerTag,
|
||||
},
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Create Project Dialog */}
|
||||
<AnimatePresence>
|
||||
{showCreateDialog && (
|
||||
<Dialog onOpenChange={setShowCreateDialog} open={showCreateDialog}>
|
||||
<DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
<DialogDescription className="text-white/60">
|
||||
Give your project a unique name
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col gap-2"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Label htmlFor="projectName">Project Name</Label>
|
||||
<Input
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
id="projectName"
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="My Awesome Project"
|
||||
value={projectName}
|
||||
/>
|
||||
<p className="text-xs text-white/50">
|
||||
This will help you organize your memories
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
|
||||
onClick={() => {
|
||||
setShowCreateDialog(false)
|
||||
setProjectName("")
|
||||
}}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
|
||||
disabled={
|
||||
createProjectMutation.isPending || !projectName.trim()
|
||||
}
|
||||
onClick={() => createProjectMutation.mutate(projectName)}
|
||||
type="button"
|
||||
>
|
||||
{createProjectMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Project"
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</DialogFooter>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Delete Project Dialog */}
|
||||
<AnimatePresence>
|
||||
{deleteDialog.open && deleteDialog.project && (
|
||||
<Dialog
|
||||
onOpenChange={(open) =>
|
||||
setDeleteDialog((prev) => ({ ...prev, open }))
|
||||
}
|
||||
open={deleteDialog.open}
|
||||
>
|
||||
<DialogContent className="sm:max-w-3xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Project</DialogTitle>
|
||||
<DialogDescription className="text-white/60">
|
||||
Are you sure you want to delete "{deleteDialog.project.name}
|
||||
"? Choose what to do with the documents in this project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
checked={deleteDialog.action === "move"}
|
||||
className="w-4 h-4"
|
||||
id="move"
|
||||
name="action"
|
||||
onChange={() =>
|
||||
setDeleteDialog((prev) => ({
|
||||
...prev,
|
||||
action: "move",
|
||||
}))
|
||||
}
|
||||
type="radio"
|
||||
/>
|
||||
<Label
|
||||
className="text-white cursor-pointer"
|
||||
htmlFor="move"
|
||||
>
|
||||
Move documents to another project
|
||||
</Label>
|
||||
</div>
|
||||
{deleteDialog.action === "move" && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
className="ml-6"
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
setDeleteDialog((prev) => ({
|
||||
...prev,
|
||||
targetProjectId: value,
|
||||
}))
|
||||
}
|
||||
value={deleteDialog.targetProjectId}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-white">
|
||||
<SelectValue placeholder="Select target project..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
|
||||
<SelectItem
|
||||
className="text-white hover:bg-white/10"
|
||||
value="sm_project_default"
|
||||
>
|
||||
Default Project
|
||||
</SelectItem>
|
||||
{projects
|
||||
.filter(
|
||||
(p) =>
|
||||
p.id !== deleteDialog.project?.id &&
|
||||
p.containerTag !== "sm_project_default",
|
||||
)
|
||||
.map((project) => (
|
||||
<SelectItem
|
||||
className="text-white hover:bg-white/10"
|
||||
key={project.id}
|
||||
value={project.id}
|
||||
>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
checked={deleteDialog.action === "delete"}
|
||||
className="w-4 h-4"
|
||||
id="delete"
|
||||
name="action"
|
||||
onChange={() =>
|
||||
setDeleteDialog((prev) => ({
|
||||
...prev,
|
||||
action: "delete",
|
||||
}))
|
||||
}
|
||||
type="radio"
|
||||
/>
|
||||
<Label
|
||||
className="text-white cursor-pointer"
|
||||
htmlFor="delete"
|
||||
>
|
||||
Delete all documents in this project
|
||||
</Label>
|
||||
</div>
|
||||
{deleteDialog.action === "delete" && (
|
||||
<motion.p
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-sm text-red-400 ml-6"
|
||||
initial={{ opacity: 0 }}
|
||||
>
|
||||
⚠️ This action cannot be undone. All documents will be
|
||||
permanently deleted.
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
|
||||
onClick={() =>
|
||||
setDeleteDialog({
|
||||
open: false,
|
||||
project: null,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
className={`${
|
||||
deleteDialog.action === "delete"
|
||||
? "bg-red-600 hover:bg-red-700"
|
||||
: "bg-white/10 hover:bg-white/20"
|
||||
} text-white border-white/20`}
|
||||
disabled={
|
||||
deleteProjectMutation.isPending ||
|
||||
(deleteDialog.action === "move" &&
|
||||
!deleteDialog.targetProjectId)
|
||||
}
|
||||
onClick={() => {
|
||||
if (deleteDialog.project) {
|
||||
deleteProjectMutation.mutate({
|
||||
projectId: deleteDialog.project.id,
|
||||
action: deleteDialog.action,
|
||||
targetProjectId:
|
||||
deleteDialog.action === "move"
|
||||
? deleteDialog.targetProjectId
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{deleteProjectMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
{deleteDialog.action === "move"
|
||||
? "Moving..."
|
||||
: "Deleting..."}
|
||||
</>
|
||||
) : deleteDialog.action === "move" ? (
|
||||
"Move & Delete Project"
|
||||
) : (
|
||||
"Delete Everything"
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</DialogFooter>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Experimental Mode Confirmation Dialog */}
|
||||
<AnimatePresence>
|
||||
{expDialog.open && (
|
||||
<Dialog
|
||||
onOpenChange={(open) => setExpDialog({ ...expDialog, open })}
|
||||
open={expDialog.open}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg bg-black/90 backdrop-blur-xl border-white/10 text-white">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col gap-4"
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
Enable Experimental Mode?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-white/60">
|
||||
Experimental mode enables beta features and advanced memory
|
||||
relationships for this project.
|
||||
<br />
|
||||
<br />
|
||||
<span className="text-yellow-400 font-medium">
|
||||
Warning:
|
||||
</span>{" "}
|
||||
This action is{" "}
|
||||
<span className="text-red-400 font-bold">irreversible</span>
|
||||
. Once enabled, you cannot return to regular mode for this
|
||||
project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
|
||||
onClick={() =>
|
||||
setExpDialog({ open: false, projectId: "" })
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
disabled={enableExperimentalMutation.isPending}
|
||||
onClick={() =>
|
||||
enableExperimentalMutation.mutate(expDialog.projectId)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{enableExperimentalMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Enabling...
|
||||
</>
|
||||
) : (
|
||||
"Enable Experimental Mode"
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</DialogFooter>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { toast } from "sonner"
|
|||
import { $fetch } from "@lib/api"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
|
|
@ -34,6 +35,7 @@ export function useDocumentMutations({
|
|||
onClose,
|
||||
}: UseDocumentMutationsOptions = {}) {
|
||||
const queryClient = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
|
||||
const noteMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
|
|
@ -47,6 +49,7 @@ export function useDocumentMutations({
|
|||
body: {
|
||||
content: content,
|
||||
containerTags: [project],
|
||||
entityContext: `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
},
|
||||
|
|
@ -134,6 +137,7 @@ export function useDocumentMutations({
|
|||
body: {
|
||||
content: url,
|
||||
containerTags: [project],
|
||||
entityContext: `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
},
|
||||
|
|
@ -230,6 +234,10 @@ export function useDocumentMutations({
|
|||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("containerTags", JSON.stringify([project]))
|
||||
formData.append(
|
||||
"entityContext",
|
||||
`This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`,
|
||||
)
|
||||
formData.append(
|
||||
"metadata",
|
||||
JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useMemo } from "react"
|
||||
import { useProject } from "@/stores"
|
||||
|
||||
/**
|
||||
* Returns the display name of the currently selected project.
|
||||
* Falls back to the containerTag / id if a matching project record
|
||||
* hasn’t been fetched yet.
|
||||
*/
|
||||
export function useProjectName() {
|
||||
const { selectedProject } = useProject()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// This query is populated by ProjectsView – we just read from the cache.
|
||||
const projects = queryClient.getQueryData(["projects"]) as
|
||||
| Array<{ name: string; containerTag: string }>
|
||||
| undefined
|
||||
|
||||
return useMemo(() => {
|
||||
if (selectedProject === "sm_project_default") return "Default Project"
|
||||
const found = projects?.find((p) => p.containerTag === selectedProject)
|
||||
return found?.name ?? selectedProject
|
||||
}, [projects, selectedProject])
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { useEffect, useState } from "react"
|
||||
|
||||
export default function useResizeObserver<T extends HTMLElement>(
|
||||
ref: React.RefObject<T | null>,
|
||||
) {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setSize({
|
||||
width: entry?.contentRect.width ?? 0,
|
||||
height: entry?.contentRect.height ?? 0,
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe(ref.current)
|
||||
return () => observer.disconnect()
|
||||
}, [ref])
|
||||
|
||||
return size
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { createContext, type ReactNode, useContext, useState } from "react"
|
||||
|
||||
type ActivePanel = "menu" | "chat" | null
|
||||
|
||||
interface MobilePanelContextType {
|
||||
activePanel: ActivePanel
|
||||
setActivePanel: (panel: ActivePanel) => void
|
||||
}
|
||||
|
||||
const MobilePanelContext = createContext<MobilePanelContextType | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
export function MobilePanelProvider({ children }: { children: ReactNode }) {
|
||||
const [activePanel, setActivePanel] = useState<ActivePanel>(null)
|
||||
|
||||
return (
|
||||
<MobilePanelContext.Provider value={{ activePanel, setActivePanel }}>
|
||||
{children}
|
||||
</MobilePanelContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useMobilePanel() {
|
||||
const context = useContext(MobilePanelContext)
|
||||
if (!context) {
|
||||
throw new Error("useMobilePanel must be used within a MobilePanelProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
28
apps/web/lib/search-params.ts
Normal file
28
apps/web/lib/search-params.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import {
|
||||
parseAsString,
|
||||
parseAsBoolean,
|
||||
parseAsStringLiteral,
|
||||
parseAsArrayOf,
|
||||
} from "nuqs"
|
||||
|
||||
// Modal states
|
||||
export const addDocumentParam = parseAsStringLiteral([
|
||||
"note",
|
||||
"link",
|
||||
"file",
|
||||
"connect",
|
||||
] as const)
|
||||
export const mcpParam = parseAsBoolean.withDefault(false)
|
||||
export const searchParam = parseAsBoolean.withDefault(false)
|
||||
export const qParam = parseAsString.withDefault("")
|
||||
export const docParam = parseAsString
|
||||
export const fullscreenParam = parseAsBoolean.withDefault(false)
|
||||
export const chatParam = parseAsBoolean
|
||||
export const threadParam = parseAsString
|
||||
export const shareParam = parseAsBoolean.withDefault(false)
|
||||
export const feedbackParam = parseAsBoolean.withDefault(false)
|
||||
|
||||
// View & filter states
|
||||
export const viewParam = parseAsStringLiteral(["graph", "list"] as const).withDefault("graph")
|
||||
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault([])
|
||||
export const projectParam = parseAsString.withDefault("sm_project_default")
|
||||
|
|
@ -1,96 +1,22 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { viewParam } from "@/lib/search-params"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useCallback } from "react"
|
||||
|
||||
type ViewMode = "graph" | "list"
|
||||
|
||||
interface ViewModeContextType {
|
||||
viewMode: ViewMode
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
isInitialized: boolean
|
||||
}
|
||||
|
||||
const ViewModeContext = createContext<ViewModeContextType | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
// Cookie utility functions
|
||||
const setCookie = (name: string, value: string, days = 365) => {
|
||||
if (typeof document === "undefined") return
|
||||
const expires = new Date()
|
||||
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
|
||||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
|
||||
}
|
||||
|
||||
const getCookie = (name: string): string | null => {
|
||||
if (typeof document === "undefined") return null
|
||||
const nameEQ = `${name}=`
|
||||
const ca = document.cookie.split(";")
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i]
|
||||
if (!c) continue
|
||||
while (c.charAt(0) === " ") c = c.substring(1, c.length)
|
||||
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const isMobileDevice = () => {
|
||||
if (typeof window === "undefined") return false
|
||||
return window.innerWidth < 768
|
||||
}
|
||||
|
||||
export function ViewModeProvider({ children }: { children: ReactNode }) {
|
||||
// Start with a default that works for SSR
|
||||
const [viewMode, setViewModeState] = useState<ViewMode>("graph")
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// Load preferences on the client side
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
// Check for saved preference first
|
||||
const savedMode = getCookie("memoryViewMode")
|
||||
if (savedMode === "list" || savedMode === "graph") {
|
||||
setViewModeState(savedMode)
|
||||
} else {
|
||||
// If no saved preference, default to list on mobile, graph on desktop
|
||||
setViewModeState(isMobileDevice() ? "list" : "graph")
|
||||
}
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}, [isInitialized])
|
||||
|
||||
// Save to cookie whenever view mode changes
|
||||
const handleSetViewMode = (mode: ViewMode) => {
|
||||
analytics.viewModeChanged(mode)
|
||||
setViewModeState(mode)
|
||||
setCookie("memoryViewMode", mode)
|
||||
}
|
||||
|
||||
return (
|
||||
<ViewModeContext.Provider
|
||||
value={{
|
||||
viewMode,
|
||||
setViewMode: handleSetViewMode,
|
||||
isInitialized,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ViewModeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useViewMode() {
|
||||
const context = useContext(ViewModeContext)
|
||||
if (!context) {
|
||||
throw new Error("useViewMode must be used within a ViewModeProvider")
|
||||
}
|
||||
return context
|
||||
const [viewMode, _setViewMode] = useQueryState("view", viewParam)
|
||||
|
||||
const setViewMode = useCallback(
|
||||
(mode: ViewMode) => {
|
||||
analytics.viewModeChanged(mode)
|
||||
_setViewMode(mode)
|
||||
},
|
||||
[_setViewMode],
|
||||
)
|
||||
|
||||
return { viewMode, setViewMode, isInitialized: true }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,99 +1,23 @@
|
|||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
"use client"
|
||||
|
||||
interface ProjectState {
|
||||
selectedProject: string
|
||||
setSelectedProject: (projectId: string) => void
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
selectedProject: "sm_project_default",
|
||||
setSelectedProject: (projectId) => set({ selectedProject: projectId }),
|
||||
}),
|
||||
{
|
||||
name: "selectedProject",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
interface MemoryGraphState {
|
||||
positionX: number
|
||||
positionY: number
|
||||
setPositionX: (x: number) => void
|
||||
setPositionY: (y: number) => void
|
||||
setPosition: (x: number, y: number) => void
|
||||
}
|
||||
|
||||
export const useMemoryGraphStore = create<MemoryGraphState>()((set) => ({
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
setPositionX: (x) => set({ positionX: x }),
|
||||
setPositionY: (y) => set({ positionY: y }),
|
||||
setPosition: (x, y) => set({ positionX: x, positionY: y }),
|
||||
}))
|
||||
|
||||
interface ChatState {
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
toggleChat: () => void
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()((set, get) => ({
|
||||
isOpen: false,
|
||||
setIsOpen: (isOpen) => set({ isOpen }),
|
||||
toggleChat: () => set({ isOpen: !get().isOpen }),
|
||||
}))
|
||||
import { useQueryState } from "nuqs"
|
||||
import { projectParam } from "@/lib/search-params"
|
||||
import { useCallback } from "react"
|
||||
|
||||
export function useProject() {
|
||||
const selectedProject = useProjectStore((state) => state.selectedProject)
|
||||
const setSelectedProject = useProjectStore(
|
||||
(state) => state.setSelectedProject,
|
||||
const [selectedProject, _setSelectedProject] = useQueryState(
|
||||
"project",
|
||||
projectParam,
|
||||
)
|
||||
|
||||
const setSelectedProject = useCallback(
|
||||
(projectId: string) => {
|
||||
_setSelectedProject(projectId)
|
||||
},
|
||||
[_setSelectedProject],
|
||||
)
|
||||
|
||||
return { selectedProject, setSelectedProject }
|
||||
}
|
||||
|
||||
export function useMemoryGraphPosition() {
|
||||
const positionX = useMemoryGraphStore((state) => state.positionX)
|
||||
const positionY = useMemoryGraphStore((state) => state.positionY)
|
||||
const setPositionX = useMemoryGraphStore((state) => state.setPositionX)
|
||||
const setPositionY = useMemoryGraphStore((state) => state.setPositionY)
|
||||
const setPosition = useMemoryGraphStore((state) => state.setPosition)
|
||||
|
||||
return {
|
||||
x: positionX,
|
||||
y: positionY,
|
||||
setX: setPositionX,
|
||||
setY: setPositionY,
|
||||
setPosition,
|
||||
}
|
||||
}
|
||||
|
||||
export function useChatOpen() {
|
||||
const isOpen = useChatStore((state) => state.isOpen)
|
||||
const setIsOpen = useChatStore((state) => state.setIsOpen)
|
||||
const toggleChat = useChatStore((state) => state.toggleChat)
|
||||
return { isOpen, setIsOpen, toggleChat }
|
||||
}
|
||||
|
||||
interface GraphModalState {
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
toggleGraphModal: () => void
|
||||
}
|
||||
|
||||
export const useGraphModalStore = create<GraphModalState>()((set, get) => ({
|
||||
isOpen: false,
|
||||
setIsOpen: (isOpen) => set({ isOpen }),
|
||||
toggleGraphModal: () => set({ isOpen: !get().isOpen }),
|
||||
}))
|
||||
|
||||
export function useGraphModal() {
|
||||
const isOpen = useGraphModalStore((state) => state.isOpen)
|
||||
const setIsOpen = useGraphModalStore((state) => state.setIsOpen)
|
||||
const toggleGraphModal = useGraphModalStore((state) => state.toggleGraphModal)
|
||||
return { isOpen, setIsOpen, toggleGraphModal }
|
||||
}
|
||||
|
||||
export { usePersistentChat, usePersistentChatStore } from "./chat"
|
||||
|
|
|
|||
|
|
@ -157,6 +157,12 @@ export const MemoryUpdateSchema = z.object({
|
|||
"Optional custom ID of the memory. This could be an ID from your database that will uniquely identify this memory.",
|
||||
example: "mem_abc123",
|
||||
}),
|
||||
entityContext: z.string().max(1500).optional().openapi({
|
||||
description:
|
||||
"Context for memory extraction on this container tag. Helps guide how memories are extracted and understood.",
|
||||
example:
|
||||
"This user is John, saving items in a personal knowledge management system.",
|
||||
}),
|
||||
metadata: MetadataSchema.optional().openapi({
|
||||
description:
|
||||
"Optional metadata for the memory. This is used to store additional information about the memory. You can use this to store any additional information you need about the memory. Metadata can be filtered through. Keys must be strings and are case sensitive. Values can be strings, numbers, or booleans. You cannot nest objects.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue