supermemory/apps/web/app/page.tsx
Dhravya Shah 4942525ee9 fix
2025-09-18 12:16:45 -07:00

787 lines
23 KiB
TypeScript

"use client"
import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@repo/lib/api"
import { MemoryGraph } from "@repo/ui/memory-graph"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { Logo, LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { GlassMenuEffect } from "@ui/other/glass-effect"
import {
HelpCircle,
LayoutGrid,
List,
LoaderIcon,
MessageSquare,
Unplug,
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { z } from "zod"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { InstallPrompt } from "@/components/install-prompt"
import { MemoryListView } from "@/components/memory-list-view"
import Menu from "@/components/menu"
import { ProjectSelector } from "@/components/project-selector"
import { ReferralUpgradeModal } from "@/components/referral-upgrade-modal"
import type { TourStep } from "@/components/tour"
import { TourAlertDialog, useTour } from "@/components/tour"
import { AddMemoryView } from "@/components/views/add-memory"
import { ChatRewrite } from "@/components/views/chat"
import { TOUR_STEP_IDS, TOUR_STORAGE_KEY } from "@/lib/tour-constants"
import { useViewMode } from "@/lib/view-mode-context"
import { useChatOpen, useProject } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
const MemoryGraphPage = () => {
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const isMobile = useIsMobile()
const { viewMode, setViewMode, isInitialized } = useViewMode()
const { selectedProject } = useProject()
const { setSteps, isTourCompleted } = useTour()
const { isOpen, setIsOpen } = useChatOpen()
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([])
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showReferralModal, setShowReferralModal] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const [isHelpHovered, setIsHelpHovered] = useState(false)
// Fetch projects meta to detect experimental flag
const { data: projectsMeta = [] } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects")
return response.data?.projects ?? []
},
staleTime: 5 * 60 * 1000,
})
const isCurrentProjectExperimental = !!projectsMeta.find(
(p: any) => p.containerTag === selectedProject,
)?.isExperimental
// Tour state
const [showTourDialog, setShowTourDialog] = useState(false)
// Define tour steps with useMemo to prevent recreation
const tourSteps: TourStep[] = useMemo(() => {
return [
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Memories Overview
</h3>
<p className="text-gray-200">
This is your memory graph. Each node represents a memory, and
connections show relationships between them.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MEMORY_GRAPH,
position: "center",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Add Memories
</h3>
<p className="text-gray-200">
Click here to add new memories to your knowledge base. You can add
text, links, or connect external sources.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_ADD_MEMORY,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Connections
</h3>
<p className="text-gray-200">
Connect your external accounts like Google Drive, Notion, or
OneDrive to automatically sync and organize your content.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_CONNECTIONS,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">Projects</h3>
<p className="text-gray-200">
Organize your memories into projects. Switch between different
contexts easily.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_PROJECTS,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
MCP Servers
</h3>
<p className="text-gray-200">
Access Model Context Protocol servers to give AI tools access to
your memories securely.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_MCP,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">Billing</h3>
<p className="text-gray-200">
Manage your subscription and billing information.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.MENU_BILLING,
position: "right",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
View Toggle
</h3>
<p className="text-gray-200">
Switch between graph view and list view to see your memories in
different ways.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.VIEW_TOGGLE,
position: "left",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">Legend</h3>
<p className="text-gray-200">
Understand the different types of nodes and connections in your
memory graph.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.LEGEND,
position: "left",
},
{
content: (
<div>
<h3 className="font-semibold text-lg mb-2 text-white">
Chat Assistant
</h3>
<p className="text-gray-200">
Ask questions or add new memories using our AI-powered chat
interface.
</p>
</div>
),
selectorId: TOUR_STEP_IDS.FLOATING_CHAT,
position: "left",
},
]
}, [])
// Check if tour has been completed before
useEffect(() => {
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true"
if (!hasCompletedTour && !isTourCompleted) {
const timer = setTimeout(() => {
setShowTourDialog(true)
setShowConnectAIModal(false)
}, 1000) // Show after 1 second
return () => clearTimeout(timer)
}
}, [isTourCompleted])
// Set up tour steps
useEffect(() => {
setSteps(tourSteps)
}, [setSteps, tourSteps])
// Save tour completion to localStorage
useEffect(() => {
if (isTourCompleted) {
localStorage.setItem(TOUR_STORAGE_KEY, "true")
}
}, [isTourCompleted])
// Progressive loading via useInfiniteQuery
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) => {
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,
})
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])
// Reset injected docs when project changes
useEffect(() => {
setInjectedDocs([])
}, [selectedProject])
// Surgical fetch of missing highlighted documents (customId-based IDs from search)
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 as any).customId) present.add((d as any).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/memories/documents/by-ids", {
body: {
ids: missing,
by: "customId",
containerTags: selectedProject ? [selectedProject] : undefined,
},
disableValidation: true,
})
if (cancelled || (resp as any)?.error) return
const extraDocs = (resp as any)?.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.join("|"),
baseDocuments,
injectedDocs,
selectedProject,
$fetch,
])
// Handle view mode change
const handleViewModeChange = useCallback(
(mode: "graph" | "list") => {
setViewMode(mode)
},
[setViewMode],
)
useEffect(() => {
const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true"
if (hasCompletedTour && allDocuments.length === 0 && !showTourDialog) {
setShowConnectAIModal(true)
} else if (showTourDialog) {
setShowConnectAIModal(false)
}
}, [allDocuments.length, showTourDialog])
// Prevent body scrolling
useEffect(() => {
document.body.style.overflow = "hidden"
document.body.style.height = "100vh"
document.documentElement.style.overflow = "hidden"
document.documentElement.style.height = "100vh"
return () => {
document.body.style.overflow = ""
document.body.style.height = ""
document.documentElement.style.overflow = ""
document.documentElement.style.height = ""
}
}, [])
return (
<div className="relative h-screen bg-[#0f1419] overflow-hidden touch-none">
{/* Main content area */}
<motion.div
animate={{
marginRight: isOpen && !isMobile ? 600 : 0,
}}
className="h-full relative"
transition={{
duration: 0.2,
ease: [0.4, 0, 0.2, 1], // Material Design easing - snappy but smooth
}}
>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="absolute md:top-4 md:right-4 md:bottom-auto md:left-auto bottom-8 left-6 z-20 rounded-xl overflow-hidden"
id={TOUR_STEP_IDS.VIEW_TOGGLE}
initial={{ opacity: 0, y: -20 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<GlassMenuEffect rounded="rounded-xl" />
<div className="relative z-10 p-2 flex gap-1">
<motion.button
animate={{
color: viewMode === "graph" ? "#93c5fd" : "#cbd5e1",
}}
className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors"
onClick={() => handleViewModeChange("graph")}
transition={{ duration: 0.2 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{viewMode === "graph" && (
<motion.div
className="absolute inset-0 bg-blue-500/20 rounded-md"
layoutId="activeBackground"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<span className="relative z-10 flex items-center gap-2">
<LayoutGrid className="w-4 h-4" />
<span className="hidden md:inline">Graph</span>
</span>
</motion.button>
<motion.button
animate={{
color: viewMode === "list" ? "#93c5fd" : "#cbd5e1",
}}
className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors"
onClick={() => handleViewModeChange("list")}
transition={{ duration: 0.2 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{viewMode === "list" && (
<motion.div
className="absolute inset-0 bg-blue-500/20 rounded-md"
layoutId="activeBackground"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<span className="relative z-10 flex items-center gap-2">
<List className="w-4 h-4" />
<span className="hidden md:inline">List</span>
</span>
</motion.button>
</div>
</motion.div>
{/* Animated content switching */}
<AnimatePresence mode="wait">
{viewMode === "graph" ? (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0"
exit={{ opacity: 0, scale: 0.95 }}
id={TOUR_STEP_IDS.MEMORY_GRAPH}
initial={{ opacity: 0, scale: 0.95 }}
key="graph"
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
>
<MemoryGraph
autoLoadOnViewport={false}
documents={allDocuments}
error={error}
hasMore={hasMore}
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isOpen}
isExperimental={isCurrentProjectExperimental}
isLoading={isPending}
isLoadingMore={isLoadingMore}
legendId={TOUR_STEP_IDS.LEGEND}
loadMoreDocuments={loadMoreDocuments}
occludedRightPx={isOpen && !isMobile ? 600 : 0}
showSpacesSelector={false}
totalLoaded={totalLoaded}
variant="consumer"
>
<div className="absolute inset-0 flex items-center justify-center">
<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">
<p className="text-lg font-medium mb-4">
Get Started with supermemory
</p>
<div className="flex flex-col gap-3">
<p className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
Click here to set up your AI connection
</p>
<p className="text-xs text-white/60">or</p>
<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>
</MemoryGraph>
</motion.div>
) : (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0 md:ml-18"
exit={{ opacity: 0, scale: 0.95 }}
id={TOUR_STEP_IDS.MEMORY_LIST}
initial={{ opacity: 0, scale: 0.95 }}
key="list"
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
>
<MemoryListView
documents={allDocuments}
error={error}
hasMore={hasMore}
isLoading={isPending}
isLoadingMore={isLoadingMore}
loadMoreDocuments={loadMoreDocuments}
totalLoaded={totalLoaded}
>
<div className="absolute inset-0 flex items-center justify-center">
<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">
<p className="text-lg font-medium mb-4">
Get Started with supermemory
</p>
<div className="flex flex-col gap-3">
<p className="text-sm text-blue-400 hover:text-blue-300 transition-colors">
Click here to set up your AI connection
</p>
<p className="text-xs text-white/60">or</p>
<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>
</MemoryListView>
</motion.div>
)}
</AnimatePresence>
{/* Top Bar */}
<div className="absolute top-2 left-0 right-0 z-10 p-4 flex items-center justify-between">
<div className="flex items-center gap-3 justify-between w-full md:w-fit md:justify-start">
<Link
className="pointer-events-auto"
href="https://supermemory.ai"
rel="noopener noreferrer"
target="_blank"
>
<LogoFull
className="h-8 hidden md:block"
id={TOUR_STEP_IDS.LOGO}
/>
<Logo className="h-8 md:hidden" id={TOUR_STEP_IDS.LOGO} />
</Link>
<div className="hidden sm:block">
<ProjectSelector />
</div>
<ConnectAIModal>
<Button
className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3"
size="sm"
variant="outline"
>
<Unplug className="h-4 w-4" />
<span className="hidden sm:inline ml-2">
Connect to your AI
</span>
<span className="sm:hidden ml-1">Connect AI</span>
</Button>
</ConnectAIModal>
</div>
<div>
<Menu />
</div>
</div>
{/* Floating Open Chat Button */}
{!isOpen && !isMobile && (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="fixed bottom-6 right-6 z-50"
initial={{ opacity: 0, scale: 0.8 }}
transition={{
type: "spring",
stiffness: 300,
damping: 25,
}}
>
<Button
className="px-4 bg-white hover:bg-white/80 text-[#001A39] shadow-lg hover:shadow-xl transition-all duration-200 rounded-full flex items-center gap-2 cursor-pointer"
onClick={() => setIsOpen(true)}
size="lg"
>
<MessageSquare className="h-5 w-5" />
<span className="font-medium">Open Chat</span>
</Button>
</motion.div>
)}
<button
className="fixed bottom-6 left-6 z-50 flex items-center overflow-hidden rounded-full shadow-2xl bg-transparent border-none cursor-pointer"
onMouseEnter={() => setIsHelpHovered(true)}
onMouseLeave={() => setIsHelpHovered(false)}
type="button"
>
<div className="absolute inset-0 rounded-full">
<GlassMenuEffect rounded="rounded-full" />
</div>
<div className="relative z-10 p-3 text-white">
<HelpCircle className="h-5 w-5" />
</div>
<div
className={`relative z-10 flex items-center text-white transition-all duration-300 overflow-hidden ${
isHelpHovered
? "opacity-100 max-w-32 pr-4 pl-0 py-3"
: "opacity-0 max-w-0 px-0 py-3"
}`}
>
<a
className="flex items-center gap-2 text-sm font-medium hover:text-white/80 transition-colors whitespace-nowrap"
href="mailto:dhravya@supermemory.com"
>
<span>Need Help?</span>
</a>
</div>
</button>
</motion.div>
{/* Chat panel - positioned absolutely */}
<motion.div
className="fixed top-0 right-0 h-full z-50 md:z-auto"
id={TOUR_STEP_IDS.FLOATING_CHAT}
style={{
width: isOpen ? (isMobile ? "100vw" : "600px") : 0,
pointerEvents: isOpen ? "auto" : "none",
}}
>
<motion.div
animate={{ x: isOpen ? 0 : isMobile ? "100%" : 600 }}
className="absolute inset-0"
exit={{ x: isMobile ? "100%" : 600 }}
initial={{ x: isMobile ? "100%" : 600 }}
key="chat"
transition={{
type: "spring",
stiffness: 500,
damping: 40,
}}
>
<ChatRewrite />
</motion.div>
</motion.div>
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
{/* Tour Alert Dialog */}
<TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} />
{/* Referral/Upgrade Modal */}
<ReferralUpgradeModal
isOpen={showReferralModal}
onClose={() => setShowReferralModal(false)}
/>
</div>
)
}
// Wrapper component to handle auth and waitlist checks
export default function Page() {
const { user, session } = useAuth()
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 }, "*")
url.searchParams.delete("extension-auth-success")
window.history.replaceState({}, "", url.toString())
}
}
}, [user, session])
// Show loading state while checking authentication and waitlist status
if (!user) {
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 we have a user and they have access, show the main component
return (
<>
<MemoryGraphPage />
<InstallPrompt />
</>
)
}