mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-08 03:08:21 +00:00
723 lines
22 KiB
TypeScript
723 lines
22 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 {
|
|
Gift,
|
|
LayoutGrid,
|
|
List,
|
|
LoaderIcon,
|
|
MessageSquare,
|
|
Unplug,
|
|
} from "lucide-react";
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
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);
|
|
|
|
// 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);
|
|
}, 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 ? 3 : 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/memories/documents", {
|
|
body: {
|
|
page: pageParam as number,
|
|
limit: (pageParam as number) === 1 ? (IS_DEV ? 3 : 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],
|
|
);
|
|
|
|
// 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
|
|
documents={allDocuments}
|
|
error={error}
|
|
hasMore={hasMore}
|
|
isLoading={isPending}
|
|
isLoadingMore={isLoadingMore}
|
|
legendId={TOUR_STEP_IDS.LEGEND}
|
|
loadMoreDocuments={loadMoreDocuments}
|
|
showSpacesSelector={false}
|
|
totalLoaded={totalLoaded}
|
|
variant="consumer"
|
|
highlightDocumentIds={allHighlightDocumentIds}
|
|
highlightsVisible={isOpen}
|
|
occludedRightPx={isOpen && !isMobile ? 600 : 0}
|
|
autoLoadOnViewport={false}
|
|
isExperimental={isCurrentProjectExperimental}
|
|
>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="rounded-xl overflow-hidden">
|
|
<div className="relative z-10 text-slate-200 px-6 py-4 text-center">
|
|
<p className="text-lg font-medium mb-2">
|
|
No Memories to Visualize
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline"
|
|
onClick={() => setShowAddMemoryView(true)}
|
|
>
|
|
Create one?
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</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">
|
|
<div className="rounded-xl overflow-hidden">
|
|
<div className="relative z-10 text-slate-200 px-6 py-4 text-center">
|
|
<p className="text-lg font-medium mb-2">
|
|
No Memories to Visualize
|
|
</p>
|
|
<button
|
|
className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline"
|
|
onClick={() => setShowAddMemoryView(true)}
|
|
type="button"
|
|
>
|
|
Create one?
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</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
|
|
variant="outline"
|
|
size="sm"
|
|
className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3"
|
|
>
|
|
<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>
|
|
)}
|
|
</motion.div>
|
|
|
|
{/* Chat panel - positioned absolutely */}
|
|
<motion.div
|
|
className="fixed top-0 right-0 h-full z-50 md:z-auto"
|
|
style={{
|
|
width: isOpen ? (isMobile ? '100vw' : '600px') : 0,
|
|
pointerEvents: isOpen ? 'auto' : 'none',
|
|
}}
|
|
id={TOUR_STEP_IDS.FLOATING_CHAT}
|
|
>
|
|
<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 router = useRouter();
|
|
const { user } = useAuth();
|
|
|
|
// Check waitlist status
|
|
const {
|
|
data: waitlistStatus,
|
|
isLoading: isCheckingWaitlist,
|
|
error: waitlistError,
|
|
} = useQuery({
|
|
queryKey: ["waitlist-status", user?.id],
|
|
queryFn: async () => {
|
|
try {
|
|
const response = await $fetch("@get/waitlist/status");
|
|
return response.data;
|
|
} catch (error) {
|
|
console.error("Error checking waitlist status:", error);
|
|
// Return null to indicate error, will handle in useEffect
|
|
return null;
|
|
}
|
|
},
|
|
enabled: !!user && !user.isAnonymous,
|
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
retry: 1, // Only retry once on failure
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (waitlistStatus && !waitlistStatus.accessGranted) {
|
|
router.push("/waitlist");
|
|
}
|
|
}, []);
|
|
|
|
// Show loading state while checking authentication and waitlist status
|
|
if (!user || isCheckingWaitlist) {
|
|
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 />
|
|
</>
|
|
);
|
|
}
|