This commit is contained in:
Dhravya Shah 2025-10-01 18:11:49 -07:00
commit 514f6a6f41
87 changed files with 6460 additions and 10484 deletions

View file

@ -0,0 +1,39 @@
"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, getCurrentChat } = usePersistentChat()
const chatId = params.id as string
useEffect(() => {
if (chatId) {
setCurrentChatId(chatId)
}
}, [chatId, setCurrentChatId])
const currentChat = getCurrentChat()
return (
<div className="flex flex-col w-full">
<div className="flex flex-col h-[93vh]">
<div className="flex justify-center w-full">
<h1 className="text-lg font-semibold flex-1 truncate text-center">
{currentChat?.title || "New Chat"}
</h1>
</div>
<div className="flex-1 flex justify-center min-h-0 w-full px-4">
<div className="flex flex-col min-h-0 w-full max-w-4xl">
<ChatMessages />
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,57 @@
"use client"
import { Header } from "@/components/header"
import { AddMemoryView } from "@/components/views/add-memory"
import { useEffect, useState } from "react"
export default function NavigationLayout({
children,
}: {
children: React.ReactNode
}) {
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
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 min-h-screen">
<div className="sticky top-0 z-50 bg-background/80 backdrop-blur-md border-b border-white/10">
<Header onAddMemory={() => setShowAddMemoryView(true)} />
</div>
{children}
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
</div>
)
}

View file

@ -0,0 +1,81 @@
"use client"
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
import { useAuth } from "@lib/auth-context"
import { ChevronsDown, LoaderIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { InstallPrompt } from "@/components/install-prompt"
import { ChatInput } from "@/components/chat-input"
import { BackgroundPlus } from "@ui/components/grid-plus"
import { Memories } from "@/components/memories"
export default function Page() {
const { user, session } = useAuth()
const { shouldShowOnboarding, isLoading: onboardingLoading } =
useOnboardingStorage()
const router = useRouter()
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])
useEffect(() => {
if (user && !onboardingLoading && shouldShowOnboarding()) {
router.push("/onboarding")
}
}, [user, shouldShowOnboarding, onboardingLoading, router])
if (!user || onboardingLoading) {
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 />
</div>
)
}

View file

@ -0,0 +1,12 @@
"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>
)
}

View file

@ -0,0 +1,10 @@
"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>
)
}

View file

@ -0,0 +1,52 @@
"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>
)
}

View file

@ -0,0 +1,12 @@
"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>
)
}

View file

@ -0,0 +1,125 @@
"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/supermemoryai", "_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 5,000 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/supermemoryai", "_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>
)
}

View file

@ -1,69 +1,71 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import "../globals.css";
import "@ui/globals.css";
import { AuthProvider } from "@lib/auth-context";
import { ErrorTrackingProvider } from "@lib/error-tracking";
import { PostHogProvider } from "@lib/posthog";
import { QueryProvider } from "@lib/query-client";
import { AutumnProvider } from "autumn-js/react";
import { Suspense } from "react";
import { Toaster } from "sonner";
import { TourProvider } from "@/components/tour";
import { MobilePanelProvider } from "@/lib/mobile-panel-context";
import type { Metadata } from "next"
import { Space_Grotesk } from "next/font/google"
import "../globals.css"
import "@ui/globals.css"
import { AuthProvider } from "@lib/auth-context"
import { ErrorTrackingProvider } from "@lib/error-tracking"
import { PostHogProvider } from "@lib/posthog"
import { QueryProvider } from "@lib/query-client"
import { AutumnProvider } from "autumn-js/react"
import { Suspense } from "react"
import { Toaster } from "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";
import { ViewModeProvider } from "@/lib/view-mode-context"
const sans = Inter({
const font = Space_Grotesk({
subsets: ["latin"],
variable: "--font-sans",
});
const mono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
});
})
export const metadata: Metadata = {
metadataBase: new URL("https://app.supermemory.ai"),
description: "Your memories, wherever you are",
title: "supermemory app",
};
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode
}>) {
return (
<html className="dark bg-sm-black" lang="en">
<body
className={`${sans.variable} ${mono.variable} antialiased bg-[#0f1419]`}
>
<AutumnProvider
backendUrl={
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
}
includeCredentials={true}
<html lang="en" suppressHydrationWarning>
<body className={`${font.variable} antialiased overflow-x-hidden`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<QueryProvider>
<AuthProvider>
<ViewModeProvider>
<MobilePanelProvider>
<PostHogProvider>
<ErrorTrackingProvider>
<TourProvider>
<Suspense>{children}</Suspense>
<Toaster richColors theme="dark" />
</TourProvider>
</ErrorTrackingProvider>
</PostHogProvider>
</MobilePanelProvider>
</ViewModeProvider>
</AuthProvider>
</QueryProvider>
</AutumnProvider>
<AutumnProvider
backendUrl={
process.env.NEXT_PUBLIC_BACKEND_URL ??
"https://api.supermemory.ai"
}
includeCredentials={true}
>
<QueryProvider>
<AuthProvider>
<ViewModeProvider>
<MobilePanelProvider>
<PostHogProvider>
<ErrorTrackingProvider>
<NuqsAdapter>
<Suspense>{children}</Suspense>
<Toaster richColors />
</NuqsAdapter>
</ErrorTrackingProvider>
</PostHogProvider>
</MobilePanelProvider>
</ViewModeProvider>
</AuthProvider>
</QueryProvider>
</AutumnProvider>
</ThemeProvider>
</body>
</html>
);
)
}

View file

@ -0,0 +1,62 @@
"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>
)
}

View file

@ -0,0 +1,97 @@
"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/memories", {
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">
<div className="space-y-4">
<NavMenu>
<p className="text-base text-white/60">
Step {getStepNumberFor("bio")} of {totalSteps}
</p>
</NavMenu>
<h1 className="max-sm:text-4xl text-white font-medium">
Tell us about yourself
</h1>
<p className="text-2xl max-sm:text-lg text-white/80">
What should Supermemory know about you?
</p>
</div>
<Textarea
autoFocus
className="font-sans mt-6 text-base! 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)]"
placeholder="I'm a software engineer from San Francisco..."
rows={3}
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
<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" }}
className="flex justify-end mt-2 absolute -bottom-12 right-0"
>
<Button
variant="link"
size="lg"
className="text-white/80 font-medium! text-lg underline 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" }}
className="flex justify-end mt-2 absolute -bottom-12 right-0"
>
<Button
variant="link"
size="lg"
className="text-white/80 font-medium! text-lg underline w-fit px-0! cursor-pointer"
onClick={handleNext}
>
Skip For Now
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View file

@ -0,0 +1,720 @@
"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 "framer-motion";
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 : parseFloat(String(latest.x || 0));
const latestY = typeof latest.y === 'number' ? latest.y : 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 = 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={idx}
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">
<div className="flex items-center justify-between w-full">
<div className="flex flex-col items-start text-left gap-4">
<NavMenu>
<p className="text-base text-white/60">
Step {getStepNumberFor("extension")} of {totalSteps}
</p>
</NavMenu>
<h1 className="text-white font-medium">
Install the Chrome extension
</h1>
<p className="text-white/80 text-2xl">
{/* Install the Supermemory extension to start saving and organizing everything that matters. */}
Bring Supermemory everywhere
</p>
</div>
<div className="flex flex-col items-center text-center gap-4">
<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 mt-6 pl-4.5 pr-6 py-4 text-2xl font-sans tracking-tight font-medium flex items-center gap-4"
>
<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-8"
/>
Add to Chrome
</a>
</div>
</div>
<div className="grid grid-cols-3 w-fit gap-4 text-base font-sans font-medium tracking-normal">
<div className="flex flex-col 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-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-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 className="flex w-full justify-end">
<Button
variant="link"
size="lg"
className="text-black/40 hover:text-black font-medium! text-lg underline w-fit px-0! cursor-pointer"
onClick={nextStep}
>
Continue
<ChevronRightIcon className="size-4" />
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,229 @@
"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>
);
}

View file

@ -0,0 +1,85 @@
"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 max-sm:text-4xl max-sm: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>
)
}

View file

@ -0,0 +1,251 @@
"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 "framer-motion"
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">
<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://api.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://api.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>
<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" }}
className="flex justify-end"
>
<Button
variant="link"
size="lg"
className="text-black/40 font-medium! text-lg underline 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" }}
className="flex justify-end"
>
<Button
variant="link"
size="lg"
className="text-black/40 font-medium! text-lg underline w-fit px-0! cursor-pointer"
onClick={nextStep}
>
Skip For Now
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View file

@ -0,0 +1,101 @@
"use client"
import { Button } from "@repo/ui/components/button"
import { useOnboarding } from "./onboarding-context"
import { useAuth } from "@lib/auth-context"
import Link from "next/link"
import { useEffect, useState } from "react"
import { Input } from "@ui/components/input"
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)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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">
<NavMenu>
<p className="text-base text-white/60">
Step {getStepNumberFor("name")} of {totalSteps}
</p>
</NavMenu>
<p className="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>
)
}

View file

@ -0,0 +1,44 @@
"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>
);
}

View file

@ -0,0 +1,35 @@
"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">
<div
className="absolute inset-0 transition-transform duration-700 ease-in-out"
style={{
backgroundImage,
backgroundSize: "cover",
backgroundPosition: "bottom",
backgroundRepeat: "no-repeat",
transform: `scale(${zoomScale})`,
transformOrigin: "center bottom",
}}
/>
<div className="relative z-10">{children}</div>
</div>
)
}

View file

@ -0,0 +1,232 @@
"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
}

View file

@ -0,0 +1,110 @@
"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-4xl px-6 py-8 flex flex-col justify-center max-sm:w-full",
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>
)
}

View file

@ -0,0 +1,26 @@
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 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 (
<OnboardingProvider>
<OnboardingProgressBar />
<OnboardingBackground>
<OnboardingForm />
</OnboardingBackground>
</OnboardingProvider>
)
}

View file

@ -0,0 +1,25 @@
"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>
)
}

View file

@ -0,0 +1,33 @@
"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">
<h1 className="text-white font-medium">Welcome to Supermemory</h1>
<p className="text-white/80 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-2xl underline cursor-pointer font-medium text-white/80 hover:text-white transition-colors"
>
Get started
<ArrowRightIcon className="size-4 ml-2" />
</button>
</div>
)
}

View file

@ -1,787 +0,0 @@
"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/documents/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 />
</>
)
}

View file

@ -8,5 +8,12 @@
"recommended": true
}
}
},
"assist": {
"actions": {
"source": {
"useSortedAttributes": "off"
}
}
}
}

View file

@ -1,58 +0,0 @@
import { cn } from "@lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
data-slot="button"
{...props}
/>
);
}
export { Button, buttonVariants };

View file

@ -0,0 +1,82 @@
"use client"
import { useState } 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 { useAuth } from "@lib/auth-context"
export function ChatInput() {
const [message, setMessage] = useState("")
const router = useRouter()
const { setCurrentChatId } = usePersistentChat()
const { user } = useAuth()
const handleSend = () => {
if (!message.trim()) return
const newChatId = generateId()
setCurrentChatId(newChatId)
// Store the initial message in sessionStorage for the chat page to pick up
sessionStorage.setItem(`chat-initial-${newChatId}`, message.trim())
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 bg-accent py-2 px-3 rounded-b-[14px]">
<ProjectSelector />
<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>
</form>
</div>
</div>
</div>
)
}

View file

@ -193,7 +193,7 @@ export function ConnectAIModal({
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Connect Supermemory to Your AI</DialogTitle>
<DialogTitle>Connect supermemory to Your AI</DialogTitle>
<DialogDescription>
Connect supermemory to your favorite AI tools using the Model
Context Protocol (MCP). This allows your AI assistant to create,
@ -205,9 +205,7 @@ export function ConnectAIModal({
{/* Step 1: Client Selection */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${"bg-white/10 text-white/60"}`}
>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium bg-muted text-muted-foreground">
1
</div>
<h3 className="text-sm font-medium">Select Your AI Client</h3>
@ -220,8 +218,8 @@ export function ConnectAIModal({
<button
className={`pr-3 pl-1 rounded-full border cursor-pointer transition-all ${
selectedClient === key
? "border-blue-500 bg-blue-500/10"
: "border-white/10 hover:border-white/20 hover:bg-white/5"
? "border-primary bg-primary/10"
: "border-border hover:border-border/60 hover:bg-muted/50"
}`}
key={key}
onClick={() =>
@ -233,7 +231,7 @@ export function ConnectAIModal({
<div className="w-8 h-8 flex items-center justify-center">
<Image
alt={clientName}
className="rounded object-contain text-white fill-white"
className="rounded object-contain"
height={20}
onError={(e) => {
const target = e.target as HTMLImageElement
@ -245,7 +243,7 @@ export function ConnectAIModal({
) {
const fallback = document.createElement("span")
fallback.className =
"fallback-text text-sm font-bold text-white/40"
"fallback-text text-sm font-bold text-muted-foreground"
fallback.textContent = clientName
.substring(0, 2)
.toUpperCase()
@ -260,7 +258,7 @@ export function ConnectAIModal({
width={20}
/>
</div>
<span className="text-sm font-medium text-white/80">
<span className="text-sm font-medium text-foreground/80">
{clientName}
</span>
</div>
@ -274,7 +272,7 @@ export function ConnectAIModal({
<div className="space-y-4">
<div className="flex justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-white/10 text-white/60 flex items-center justify-center text-sm font-medium">
<div className="w-8 h-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-sm font-medium">
2
</div>
<h3 className="text-sm font-medium">
@ -294,12 +292,12 @@ export function ConnectAIModal({
>
{/* Tabs */}
<div className="flex justify-end">
<div className="flex bg-white/5 rounded-full p-1 border border-white/10">
<div className="flex bg-muted/50 rounded-full p-1 border border-border">
<button
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
cursorInstallTab === "oneClick"
? "bg-white/10 text-white border border-white/20"
: "text-white/60 hover:text-white/80"
? "bg-background text-foreground border border-border shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setCursorInstallTab("oneClick")}
type="button"
@ -309,8 +307,8 @@ export function ConnectAIModal({
<button
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${
cursorInstallTab === "manual"
? "bg-white/10 text-white border border-white/20"
: "text-white/60 hover:text-white/80"
? "bg-background text-foreground border border-border shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setCursorInstallTab("manual")}
type="button"
@ -329,11 +327,11 @@ export function ConnectAIModal({
<div className="space-y-4">
<div className="flex flex-col items-center gap-4 p-6 border border-green-500/20 rounded-lg bg-green-500/5">
<div className="text-center">
<p className="text-sm text-white/80 mb-2">
<p className="text-sm text-foreground/80 mb-2">
Click the button below to automatically install and
configure Supermemory in Cursor
</p>
<p className="text-xs text-white/50">
<p className="text-xs text-muted-foreground">
This will install the MCP server without any
additional setup required
</p>
@ -353,13 +351,13 @@ export function ConnectAIModal({
/>
</a>
</div>
<p className="text-xs text-white/40 text-center">
<p className="text-xs text-muted-foreground/60 text-center">
Make sure you have Cursor installed on your system
</p>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-white/70">
<p className="text-sm text-muted-foreground">
Choose a project and follow the installation steps below
</p>
<div className="max-w-md">
@ -371,17 +369,11 @@ export function ConnectAIModal({
<SelectTrigger className="w-full">
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
<SelectItem
className="text-white hover:bg-white/10"
value="none"
>
<SelectContent>
<SelectItem value="none">
Auto-select project
</SelectItem>
<SelectItem
className="text-white hover:bg-white/10"
value="sm_project_default"
>
<SelectItem value="sm_project_default">
Default Project
</SelectItem>
{projects
@ -391,7 +383,6 @@ export function ConnectAIModal({
)
.map((project: Project) => (
<SelectItem
className="text-white hover:bg-white/10"
key={project.id}
value={project.containerTag}
>
@ -426,7 +417,7 @@ export function ConnectAIModal({
<CopyIcon className="size-4" />
</Button>
</div>
<p className="text-xs text-white/50">
<p className="text-xs text-muted-foreground">
Use this URL to configure supermemory in your AI assistant
</p>
</div>
@ -440,17 +431,9 @@ export function ConnectAIModal({
<SelectTrigger className="w-full">
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent className="bg-black/90 backdrop-blur-xl border-white/10">
<SelectItem
className="text-white hover:bg-white/10"
value="none"
>
Auto-select project
</SelectItem>
<SelectItem
className="text-white hover:bg-white/10"
value="sm_project_default"
>
<SelectContent>
<SelectItem value="none">Auto-select project</SelectItem>
<SelectItem value="sm_project_default">
Default Project
</SelectItem>
{projects
@ -460,7 +443,6 @@ export function ConnectAIModal({
)
.map((project: Project) => (
<SelectItem
className="text-white hover:bg-white/10"
key={project.id}
value={project.containerTag}
>
@ -480,7 +462,7 @@ export function ConnectAIModal({
(selectedClient !== "cursor" || cursorInstallTab === "manual") && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-white/10 text-white/60 flex items-center justify-center text-sm font-medium">
<div className="w-8 h-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-sm font-medium">
3
</div>
<h3 className="text-sm font-medium">
@ -506,7 +488,7 @@ export function ConnectAIModal({
</Button>
</div>
<p className="text-xs text-white/50">
<p className="text-xs text-muted-foreground">
{selectedClient === "cursor" && cursorInstallTab === "manual"
? "Copy and run this command in your terminal for manual installation (or switch to the one-click option above)"
: "Copy and run this command in your terminal to install the MCP server"}
@ -518,19 +500,19 @@ export function ConnectAIModal({
{!selectedClient && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-white/10 text-white/60 flex items-center justify-center text-sm font-medium">
<div className="w-8 h-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-sm font-medium">
3
</div>
<h3 className="text-sm font-medium">Installation Command</h3>
</div>
<div className="relative">
<div className="w-full h-10 bg-white/5 border border-white/10 rounded-md flex items-center px-3">
<div className="w-full h-4 bg-white/20 rounded animate-pulse blur-sm" />
<div className="w-full h-10 bg-muted border border-border rounded-md flex items-center px-3">
<div className="w-full h-4 bg-muted-foreground/20 rounded animate-pulse blur-sm" />
</div>
</div>
<p className="text-xs text-white/30">
<p className="text-xs text-muted-foreground/50">
Select a client above to see the installation command
</p>
</div>
@ -539,18 +521,18 @@ export function ConnectAIModal({
<div className="gap-2 hidden">
<div>
<label
className="text-sm font-medium text-white/80 block mb-2"
className="text-sm font-medium text-foreground/80 block mb-2"
htmlFor="mcp-server-url-desktop"
>
MCP Server URL
</label>
<p className="text-xs text-white/50 mt-2">
<p className="text-xs text-muted-foreground mt-2">
Use this URL to configure supermemory in your AI assistant
</p>
</div>
<div className="p-1 bg-white/5 rounded-lg border border-white/10 items-center flex px-2">
<div className="p-1 bg-muted rounded-lg border border-border items-center flex px-2">
<CopyableCell
className="font-mono text-xs text-blue-400"
className="font-mono text-xs text-primary"
value="https://api.supermemory.ai/mcp"
/>
</div>
@ -599,11 +581,11 @@ export function ConnectAIModal({
onOpenChange={setIsMigrateDialogOpen}
open={isMigrateDialogOpen}
>
<DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
<DialogContent className="sm:max-w-2xl bg-popover border-border text-popover-foreground">
<div>
<DialogHeader>
<DialogTitle>Migrate from MCP v1</DialogTitle>
<DialogDescription className="text-white/60">
<DialogDescription className="text-muted-foreground">
Migrate your MCP documents from the legacy system.
</DialogDescription>
</DialogHeader>
@ -623,7 +605,7 @@ export function ConnectAIModal({
{({ state, handleChange, handleBlur }) => (
<>
<Input
className="bg-white/5 border-white/10 text-white"
className="bg-input border-border text-foreground"
id="mcpUrl"
onBlur={handleBlur}
onChange={(e) => handleChange(e.target.value)}
@ -631,14 +613,14 @@ export function ConnectAIModal({
value={state.value}
/>
{state.meta.errors.length > 0 && (
<p className="text-sm text-red-400 mt-1">
<p className="text-sm text-destructive mt-1">
{state.meta.errors.join(", ")}
</p>
)}
</>
)}
</mcpMigrationForm.Field>
<p className="text-xs text-white/50">
<p className="text-xs text-muted-foreground">
Enter your old MCP Link in the format: <br />
<span className="font-mono">
https://mcp.supermemory.ai/userId/sse
@ -648,7 +630,6 @@ export function ConnectAIModal({
</div>
<div className="flex justify-end gap-3 mt-4">
<Button
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
onClick={() => {
setIsMigrateDialogOpen(false)
mcpMigrationForm.reset()
@ -659,7 +640,6 @@ export function ConnectAIModal({
Cancel
</Button>
<Button
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
disabled={
migrateMCPMutation.isPending ||
!mcpMigrationForm.state.canSubmit

View file

@ -0,0 +1,164 @@
"use client"
import { Card, CardContent } from "@repo/ui/components/card"
import { Badge } from "@repo/ui/components/badge"
import { ExternalLink, FileText, Brain } from "lucide-react"
import { useState } from "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
showExternalLink?: boolean
activeMemories?: Array<{ id: string; isForgotten?: boolean }>
lastModified?: string | Date
}
export const GoogleDocsCard = ({
title,
url,
description,
className,
onClick,
showExternalLink = true,
activeMemories,
lastModified,
}: GoogleDocsCardProps) => {
const [imageError, setImageError] = useState(false)
const handleCardClick = () => {
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"),
}}
>
<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>
{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 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"

View file

@ -0,0 +1,133 @@
import { Badge } from "@repo/ui/components/badge"
import { Card, CardContent, CardHeader } from "@repo/ui/components/card"
import { colors } from "@repo/ui/memory-graph/constants"
import { Brain, ExternalLink } from "lucide-react"
import { cn } from "@lib/utils"
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,
}: NoteCardProps) => {
return (
<Card
className="w-full p-4 transition-all cursor-pointer group relative overflow-hidden gap-2 shadow-xs"
onClick={() => {
analytics.documentCardClicked()
onOpenDetails(document)
}}
style={{
backgroundColor: getPastelBackgroundColor(
document.id || document.title || "note",
),
width: width,
}}
>
<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>
{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-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"

View file

@ -0,0 +1,105 @@
import { Suspense } 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 { Brain } 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,
}: {
data: Tweet
activeMemories?: Array<{ id: string; isForgotten?: boolean }>
}) => {
return (
<div
className="relative transition-all"
style={{
backgroundColor: getPastelBackgroundColor(data.id_str || "tweet"),
}}
>
<CustomTweet components={{}} tweet={data} />
{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"

View file

@ -0,0 +1,103 @@
"use client"
import { Card, CardContent } from "@repo/ui/components/card"
import { ExternalLink } from "lucide-react"
import { useState } from "react"
import { cn } from "@lib/utils"
import { getPastelBackgroundColor } from "../memories-utils"
interface WebsiteCardProps {
title: string
url: string
image?: string
description?: string
className?: string
onClick?: () => void
showExternalLink?: boolean
}
export const WebsiteCard = ({
title,
url,
image,
description,
className,
onClick,
showExternalLink = true,
}: WebsiteCardProps) => {
const [imageError, setImageError] = useState(false)
const handleCardClick = () => {
if (onClick) {
onClick()
} 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",
className,
)}
onClick={handleCardClick}
style={{
backgroundColor: getPastelBackgroundColor(url || title || "website"),
}}
>
<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}
{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>
{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"

View file

@ -0,0 +1,175 @@
import { Button } from "@ui/components/button"
import { Logo, LogoFull } from "@ui/assets/Logo"
import Link from "next/link"
import { MoonIcon, Plus, SunIcon, MonitorIcon, Network } 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 { useAuth } from "@lib/auth-context"
import { ConnectAIModal } from "./connect-ai-modal"
import { useTheme } from "next-themes"
import { cn } from "@lib/utils"
import { useRouter } from "next/navigation"
import { MCPIcon } from "./menu"
import { authClient } from "@lib/auth"
import { analytics } from "@/lib/analytics"
import { useGraphModal } from "@/stores"
export function Header({ onAddMemory }: { onAddMemory?: () => void }) {
const { user } = useAuth()
const { theme, setTheme } = useTheme()
const router = useRouter()
const { setIsOpen: setGraphModalOpen } = useGraphModal()
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">
<Link
className="pointer-events-auto"
href={
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://app.supermemory.ai"
}
rel="noopener noreferrer"
>
<LogoFull className="h-8 hidden md:block" />
<Logo className="h-8 md:hidden" />
</Link>
<div className="flex items-center gap-1.5 md:gap-3">
<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>
<Button
variant="ghost"
size="sm"
onClick={() => setGraphModalOpen(true)}
className="gap-1.5"
>
<Network className="h-4 w-4" />
<span className="hidden sm:inline">Graph View</span>
</Button>
<ConnectAIModal>
<Button variant="ghost" size="sm" className="gap-1.5">
<MCPIcon className="h-4 w-4" />
<span className="hidden lg:inline">Connect to AI (MCP)</span>
</Button>
</ConnectAIModal>
<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")}>
Profile
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push("/settings/billing")}
>
Billing
</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-primay-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-primay-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-primay-foreground"
: "text-muted-foreground",
"h-3 w-3 group-hover:text-foreground",
)}
/>
</Button>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleSignOut()}>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,269 @@
"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}
/>
)
}
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}
/>
)
}
if (document.url?.includes("https://")) {
return (
<WebsiteCard
url={document.url}
title={document.title || "Untitled Document"}
image={document.ogImage}
/>
)
}
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,
),
}))
.filter((doc) => doc.memoryEntries.length > 0)
}, [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 flex items-center justify-center p-4">
<div className="rounded-xl overflow-hidden">
<div
className="relative z-10 px-6 py-4"
>
<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
className="h-full overflow-auto custom-scrollbar sm-tweet-theme"
data-theme="light"
>
<Masonry
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}
/>
</>
)
}

View file

@ -0,0 +1,62 @@
import { memo, useMemo } from "react"
import DOMPurify from "dompurify"
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, processedContent } = useMemo(() => {
const contentIsHTML = isHTMLContent(content)
if (contentIsHTML) {
return {
isHTML: true,
processedContent: DOMPurify.sanitize(content),
}
}
return {
isHTML: false,
processedContent: content,
}
}, [content])
if (isHTML) {
return (
<div
className={`${className} bg-background`}
// biome-ignore lint/security/noDangerouslySetInnerHtml: Content is sanitized with DOMPurify
dangerouslySetInnerHTML={{ __html: processedContent }}
/>
)
}
return (
<p
className={`text-sm leading-relaxed whitespace-pre-wrap text-foreground ${className}`}
>
{processedContent}
</p>
)
},
)
HTMLContentRenderer.displayName = "HTMLContentRenderer"

View file

@ -0,0 +1,109 @@
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}`
}
// 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)"
}

View file

@ -0,0 +1,385 @@
import { getDocumentIcon } from "@/lib/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"
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-start 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",
)}
</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>
{document.url && (
<>
<span></span>
<button
className="flex items-center gap-0.5 md:gap-1 transition-all hover:gap-1 md:hover:gap-2 text-primary hover:text-primary/80 whitespace-nowrap"
onClick={() => {
const sourceUrl = getSourceUrl(document)
window.open(sourceUrl ?? undefined, "_blank")
}}
type="button"
>
<span className="hidden sm:inline">View 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>
</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 mb-2 flex items-start gap-2 py-2 text-muted-foreground">
Active Memories ({activeMemories.length})
</div>
<div className="space-y-3">
{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">
<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:px-4 overflow-y-auto custom-scrollbar transition-all duration-300">
<h3 className="font-medium text-[10px] md:text-xs text-muted-foreground uppercase pb-1 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 lg:border-l border-border">
<div className="flex-1 flex flex-col">
<div className="flex-1 memory-dialog-scroll overflow-y-auto p-3 md:p-4">
<MemoryContent />
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
},
)

View file

@ -0,0 +1,276 @@
"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 { MemoryGraph } from "@repo/ui/memory-graph"
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { MasonryMemoryList } from "@/components/masonry-memory-list"
import { AddMemoryView } from "@/components/views/add-memory"
import { useChatOpen, useProject, useGraphModal } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
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 { isOpen: showGraphModal, setIsOpen: setShowGraphModal } =
useGraphModal()
const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([])
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
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,
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) {
setShowConnectAIModal(true)
}
}, [allDocuments.length])
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">
<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>
</MasonryMemoryList>
{showAddMemoryView && (
<AddMemoryView
initialTab="note"
onClose={() => setShowAddMemoryView(false)}
/>
)}
</div>
{/* Memory Graph Modal */}
<Dialog open={showGraphModal} onOpenChange={setShowGraphModal}>
<DialogContent
className="w-[95vw] h-[95vh] p-0 max-w-6xl sm:max-w-6xl"
showCloseButton={true}
>
<div className="w-full h-full">
<MemoryGraph
documents={allDocuments}
error={error}
hasMore={hasMore}
isLoading={isPending}
isLoadingMore={isLoadingMore}
loadMoreDocuments={loadMoreDocuments}
totalLoaded={totalLoaded}
variant="console"
showSpacesSelector={true}
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isOpen}
>
<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">
<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>
</MemoryGraph>
</div>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -1,53 +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]!);
};
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}`;
}
// Fallback to existing URL for all other document types
return document.url;
};

View file

@ -1,415 +0,0 @@
import { getDocumentIcon } from '@/lib/document-icon';
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@repo/ui/components/drawer';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@repo/ui/components/sheet';
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from '@repo/ui/components/tabs';
import { colors } from '@repo/ui/memory-graph/constants';
import type { DocumentsWithMemoriesResponseSchema } from '@repo/validation/api';
import { Badge } from '@ui/components/badge';
import { Brain, Calendar, CircleUserRound, ExternalLink, List, Sparkles } from 'lucide-react';
import { memo } from 'react';
import type { z } from 'zod';
import { formatDate, getSourceUrl } from '.';
import { Label1Regular } from '@ui/text/label/label-1-regular';
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>;
type DocumentWithMemories = DocumentsResponse['documents'][0];
type MemoryEntry = DocumentWithMemories['memoryEntries'][0];
const formatDocumentType = (type: string) => {
// Special case for PDF
if (type.toLowerCase() === 'pdf') return 'PDF';
// Replace underscores with spaces and capitalize each word
return type
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => {
return (
<button
className="p-4 rounded-lg transition-all relative overflow-hidden cursor-pointer"
style={{
backgroundColor: memory.isLatest
? colors.memory.primary
: 'rgba(255, 255, 255, 0.02)',
}}
tabIndex={0}
type="button"
>
<div className="flex items-start gap-2 relative z-10">
<div
className="p-1 rounded"
style={{
backgroundColor: memory.isLatest
? colors.memory.secondary
: 'transparent',
}}
>
<Brain
className={`w-4 h-4 flex-shrink-0 transition-all ${
memory.isLatest ? 'text-blue-400' : 'text-blue-400/50'
}`}
/>
</div>
<div className="flex-1 space-y-2">
<Label1Regular
className="text-sm leading-relaxed text-left"
style={{ color: colors.text.primary }}
>
{memory.memory}
</Label1Regular>
<div className="flex gap-2 justify-between">
<div
className="flex items-center gap-4 text-xs"
style={{ color: colors.text.muted }}
>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{formatDate(memory.createdAt)}
</span>
<span className="font-mono">v{memory.version}</span>
{memory.sourceRelevanceScore && (
<span
className="flex items-center gap-1"
style={{
color:
memory.sourceRelevanceScore > 70
? colors.accent.emerald
: colors.text.muted,
}}
>
<Sparkles className="w-3 h-3" />
{memory.sourceRelevanceScore}%
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{memory.isForgotten && (
<Badge
className="text-xs border-red-500/30 backdrop-blur-sm"
style={{
backgroundColor: colors.status.forgotten,
color: '#dc2626',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="destructive"
>
Forgotten
</Badge>
)}
{memory.isLatest && (
<Badge
className="text-xs"
style={{
backgroundColor: colors.memory.secondary,
color: colors.text.primary,
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="default"
>
Latest
</Badge>
)}
{memory.forgetAfter && (
<Badge
className="text-xs backdrop-blur-sm"
style={{
color: colors.status.expiring,
backgroundColor: 'rgba(251, 165, 36, 0.1)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
variant="outline"
>
Expires: {formatDate(memory.forgetAfter)}
</Badge>
)}
</div>
</div>
</div>
</div>
</button>
);
});
export const MemoryDetail = memo(
({
document,
isOpen,
onClose,
isMobile,
}: {
document: DocumentWithMemories | null;
isOpen: boolean;
onClose: () => void;
isMobile: boolean;
}) => {
if (!document) return null;
const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten);
const forgottenMemories = document.memoryEntries.filter(
(m) => m.isForgotten
);
const HeaderContent = ({
TitleComponent,
}: {
TitleComponent: typeof SheetTitle | typeof DrawerTitle;
}) => (
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 flex-1">
<div
className="p-2 rounded-lg"
style={{
backgroundColor: colors.background.secondary,
}}
>
{getDocumentIcon(document.type, 'w-5 h-5')}
</div>
<div className="flex-1">
<TitleComponent style={{ color: colors.text.primary }}>
{document.title || 'Untitled Document'}
</TitleComponent>
<div
className="flex items-center gap-2 mt-1 text-xs"
style={{ color: colors.text.muted }}
>
<span>{formatDocumentType(document.type)}</span>
<span></span>
<span>{formatDate(document.createdAt)}</span>
{document.url && (
<>
<span></span>
<button
className="flex items-center gap-1 transition-all hover:gap-2"
onClick={() => {
const sourceUrl = getSourceUrl(document);
window.open(sourceUrl ?? undefined, '_blank');
}}
style={{ color: colors.accent.primary }}
type="button"
>
View source
<ExternalLink className="w-3 h-3" />
</button>
</>
)}
</div>
</div>
</div>
</div>
);
const ContentAndSummarySection = () => {
const hasContent = document.content && document.content.trim().length > 0;
const hasSummary = document.summary && document.summary.trim().length > 0;
if (!hasContent && !hasSummary) return null;
const defaultTab = hasContent ? 'content' : 'summary';
return (
<div className="mt-4">
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList
className={`grid w-full bg-white/5 border border-white/10 h-11 ${
hasContent && hasSummary ? 'grid-cols-2' : 'grid-cols-1'
}`}
>
{hasContent && (
<TabsTrigger
value="content"
className="text-xs bg-transparent h-8"
style={{ color: colors.text.secondary }}
>
<CircleUserRound className="w-3 h-3" />
Original Content
</TabsTrigger>
)}
{hasSummary && (
<TabsTrigger
value="summary"
className="text-xs flex items-center gap-1 bg-transparent h-8"
style={{ color: colors.text.secondary }}
>
<List className="w-3 h-3" />
Summary
</TabsTrigger>
)}
</TabsList>
{hasContent && (
<TabsContent value="content" className="mt-3">
<div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-white/[0.03] border border-white/[0.08]">
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
style={{ color: colors.text.primary }}
>
{document.content}
</p>
</div>
</TabsContent>
)}
{hasSummary && (
<TabsContent value="summary" className="mt-3">
<div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-indigo-500/5 border border-indigo-500/15">
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
style={{ color: colors.text.muted }}
>
{document.summary}
</p>
</div>
</TabsContent>
)}
</Tabs>
</div>
);
};
const MemoryContent = () => (
<div className="space-y-6 px-6">
{activeMemories.length > 0 && (
<div>
<div
className="text-sm font-medium mb-2 flex items-start gap-2 py-2"
style={{
color: colors.text.secondary,
}}
>
Active Memories ({activeMemories.length})
</div>
<div className="space-y-3">
{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"
style={{
color: colors.text.muted,
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
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"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<Brain
className="w-12 h-12 mx-auto mb-4 opacity-30"
style={{ color: colors.text.muted }}
/>
<p style={{ color: colors.text.muted }}>
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-[90vh]"
style={{
backgroundColor: colors.background.secondary,
borderTop: `1px solid ${colors.document.border}`,
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
{/* Header section with glass effect */}
<div
className="p-4 relative border-b"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
borderBottom: `1px solid ${colors.document.border}`,
}}
>
<DrawerHeader className="pb-0 px-0 text-left">
<HeaderContent TitleComponent={DrawerTitle} />
</DrawerHeader>
<ContentAndSummarySection />
</div>
<div className="flex-1 memory-drawer-scroll overflow-y-auto">
<MemoryContent />
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Sheet onOpenChange={onClose} open={isOpen}>
<SheetContent
className="w-full sm:max-w-2xl border-0 p-0 overflow-hidden"
style={{
backgroundColor: colors.background.secondary,
}}
>
<div
className="p-6 relative"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<SheetHeader className="pb-0">
<HeaderContent TitleComponent={SheetTitle} />
</SheetHeader>
<ContentAndSummarySection />
</div>
<div className="h-[calc(100vh-200px)] memory-sheet-scroll overflow-y-auto">
<MemoryContent />
</div>
</SheetContent>
</Sheet>
);
}
);

View file

@ -26,9 +26,9 @@ import { analytics } from "@/lib/analytics"
import { useDeleteDocument } from "@lib/queries"
import { useProject } from "@/stores"
import { MemoryDetail } from "./memories/memory-detail"
import { MemoryDetail } from "./memories-utils/memory-detail"
import { getDocumentIcon } from "@/lib/document-icon"
import { formatDate, getSourceUrl } from "./memories"
import { formatDate, getSourceUrl } from "./memories-utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -44,31 +44,6 @@ interface MemoryListViewProps {
loadMoreDocuments: () => Promise<void>
}
const GreetingMessage = memo(() => {
const getGreeting = () => {
const hour = new Date().getHours()
if (hour < 12) return "Good morning"
if (hour < 17) return "Good afternoon"
return "Good evening"
}
return (
<div className="flex items-center gap-3 mb-3 px-4 md:mb-6 md:mt-3">
<div>
<h1
className="text-lg md:text-xl font-semibold"
style={{ color: colors.text.primary }}
>
{getGreeting()}!
</h1>
<p className="text-xs md:text-sm" style={{ color: colors.text.muted }}>
Welcome back to your memory collection
</p>
</div>
</div>
)
})
const DocumentCard = memo(
({
document,
@ -86,7 +61,7 @@ const DocumentCard = memo(
return (
<Card
className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden border-0 gap-2 md:w-full"
className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden gap-2 md:w-full shadow-xs"
onClick={() => {
analytics.documentCardClicked()
onOpenDetails(document)
@ -312,7 +287,7 @@ export const MemoryListView = ({
hasMore,
isLoadingMore,
loadMoreDocuments,
virtualizer.getVirtualItems(),
virtualizer.getVirtualItems,
virtualItems.length,
])
@ -322,7 +297,6 @@ export const MemoryListView = ({
<div
className="h-full overflow-hidden relative pb-20"
ref={containerRef}
style={{ backgroundColor: colors.background.primary }}
>
{error ? (
<div className="h-full flex items-center justify-center p-4">
@ -358,8 +332,6 @@ export const MemoryListView = ({
ref={parentRef}
className="h-full overflow-auto mt-20 custom-scrollbar"
>
<GreetingMessage />
<div
className="w-full relative"
style={{
@ -375,13 +347,13 @@ export const MemoryListView = ({
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
className="absolute top-0 left-0 w-full sm-tweet-theme"
style={{
transform: `translateY(${virtualRow.start + virtualRow.index * gap}px)`,
}}
>
<div
className="grid justify-start"
className="grid justify-center"
style={{
gridTemplateColumns: `repeat(${columns}, ${columnWidth}px)`,
gap: `${gap}px`,

View file

@ -10,21 +10,19 @@ 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 { MessageSquareMore, Plus, Puzzle, User, X } from "lucide-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 { TOUR_STEP_IDS } from "@/lib/tour-constants"
import { useChatOpen } from "@/stores"
import { ProjectSelector } from "./project-selector"
import { useTour } from "./tour"
import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory"
import { IntegrationsView } from "./views/integrations"
import { ProfileView } from "./views/profile"
const MCPIcon = ({ className }: { className?: string }) => {
export const MCPIcon = ({ className }: { className?: string }) => {
return (
<svg
className={className}
@ -65,11 +63,10 @@ function Menu({ id }: { id?: string }) {
const [showConnectAIModal, setShowConnectAIModal] = useState(false)
const isMobile = useIsMobile()
const { activePanel, setActivePanel } = useMobilePanel()
const { setMenuExpanded } = useTour()
const autumn = useCustomer()
const { setIsOpen } = useChatOpen()
const { data: memoriesCheck } = fetchMemoriesFeature(autumn)
const { data: memoriesCheck } = fetchMemoriesFeature(autumn, !autumn.isLoading)
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 0
@ -99,14 +96,6 @@ function Menu({ id }: { id?: string }) {
const shouldShowLimitWarning =
!isProUser && memoriesUsed >= memoriesLimit * 0.8
// Map menu item keys to tour IDs
const menuItemTourIds: Record<string, string> = {
addUrl: TOUR_STEP_IDS.MENU_ADD_MEMORY,
projects: TOUR_STEP_IDS.MENU_PROJECTS,
mcp: TOUR_STEP_IDS.MENU_MCP,
integrations: "", // No tour ID for integrations yet
}
const menuItems = [
{
icon: Plus,
@ -114,12 +103,6 @@ function Menu({ id }: { id?: string }) {
key: "addUrl" as const,
disabled: false,
},
{
icon: MessageSquareMore,
text: "Chat",
key: "chat" as const,
disabled: false,
},
{
icon: Puzzle,
text: "Integrations",
@ -221,14 +204,6 @@ function Menu({ id }: { id?: string }) {
}
}, [isMobile, activePanel])
// Notify tour provider about expansion state changes
useEffect(() => {
const isExpanded = isMobile
? isMobileMenuOpen || !!expandedView
: isHovered || !!expandedView
setMenuExpanded(isExpanded)
}, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded])
// Calculate width based on state
const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56
@ -267,14 +242,9 @@ function Menu({ id }: { id?: string }) {
},
}}
>
{/* Glass effect background */}
<motion.div className="absolute inset-0" layout>
<GlassMenuEffect />
</motion.div>
{/* Menu content */}
<motion.div
className="relative z-20 flex flex-col gap-6 w-full"
className="relative z-20 flex flex-col gap-6 w-full bg-white"
layout
>
<AnimatePresence
@ -323,8 +293,7 @@ function Menu({ id }: { id?: string }) {
duration: 0.1,
},
}}
className={`flex w-full items-center text-white/80 transition-colors duration-100 hover:text-white cursor-pointer relative ${isHovered || expandedView ? "px-1" : ""}`}
id={menuItemTourIds[item.key]}
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)}
@ -346,14 +315,14 @@ function Menu({ id }: { id?: string }) {
initial={{ scale: 0.8 }}
layout="position"
>
<item.icon className="duration-200 h-6 w-6 drop-shadow-lg flex-shrink-0" />
<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="drop-shadow-lg pl-3 whitespace-nowrap"
className="pl-3 whitespace-nowrap"
initial={{ opacity: 0, x: -10 }}
style={{
transform: "translateZ(0)",
@ -373,7 +342,7 @@ function Menu({ id }: { id?: string }) {
opacity: 1,
scaleX: 1,
}}
className="w-full h-px bg-white/20 mt-3 origin-left"
className="w-full h-px bg-black/20 mt-3 origin-left"
initial={{ opacity: 0, scaleX: 0 }}
transition={{
duration: 0.3,
@ -590,7 +559,6 @@ function Menu({ id }: { id?: string }) {
},
}}
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"
id={menuItemTourIds[item.key]}
initial={{ opacity: 0, y: 10 }}
layout
onClick={() => {

View file

@ -1,8 +1,8 @@
"use client";
"use client"
import { $fetch } from "@repo/lib/api";
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants";
import { Button } from "@repo/ui/components/button";
import { $fetch } from "@repo/lib/api"
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
import { Button } from "@repo/ui/components/button"
import {
Dialog,
DialogContent,
@ -10,141 +10,139 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@repo/ui/components/dialog";
} from "@repo/ui/components/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@repo/ui/components/dropdown-menu";
import { Label } from "@repo/ui/components/label";
} 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
} from "@repo/ui/components/select"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
ChevronDown,
FolderIcon,
Loader2,
MoreHorizontal,
MoreVertical,
Plus,
Trash2,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { toast } from "sonner";
import { useProjectMutations } from "@/hooks/use-project-mutations";
import { useProjectName } from "@/hooks/use-project-name";
import { useProject } from "@/stores";
import { CreateProjectDialog } from "./create-project-dialog";
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useState } from "react"
import { toast } from "sonner"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { useProjectName } from "@/hooks/use-project-name"
import { useProject } from "@/stores"
import { CreateProjectDialog } from "./create-project-dialog"
interface Project {
id: string;
name: string;
containerTag: string;
createdAt: string;
updatedAt: string;
isExperimental?: boolean;
id: string
name: string
containerTag: string
createdAt: string
updatedAt: string
isExperimental?: boolean
}
export function ProjectSelector() {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const { selectedProject } = useProject();
const projectName = useProjectName();
const { switchProject, deleteProjectMutation } = useProjectMutations();
const queryClient = useQueryClient()
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: 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 [expDialog, setExpDialog] = useState<{
open: boolean;
projectId: string;
open: boolean
projectId: string
}>({
open: false,
projectId: "",
});
})
const { data: projects = [], isLoading } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const response = await $fetch("@get/projects");
const response = await $fetch("@get/projects")
if (response.error) {
throw new Error(response.error?.message || "Failed to load projects");
throw new Error(response.error?.message || "Failed to load projects")
}
return response.data?.projects || [];
return response.data?.projects || []
},
staleTime: 30 * 1000,
});
})
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;
return response.data
},
onSuccess: () => {
toast.success("Experimental mode enabled for project");
queryClient.invalidateQueries({ queryKey: ["projects"] });
setExpDialog({ open: false, projectId: "" });
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",
});
})
},
});
})
const handleProjectSelect = (containerTag: string) => {
switchProject(containerTag);
setIsOpen(false);
};
switchProject(containerTag)
setIsOpen(false)
}
const handleCreateNewProject = () => {
setIsOpen(false);
setShowCreateDialog(true);
};
setIsOpen(false)
setShowCreateDialog(true)
}
return (
<div className="relative">
<motion.button
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-white/5 hover:bg-white/10 transition-colors"
<Button
variant="ghost"
className="flex items-center gap-1.5 px-2 py-1.5 rounded-md transition-colors"
onClick={() => setIsOpen(!isOpen)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<FolderIcon className="h-3.5 w-3.5 text-white/70" />
<span className="text-xs font-medium text-white/90 max-w-32 truncate">
<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.15 }}
>
<ChevronDown className="h-3 w-3 text-white/50" />
<ChevronDown className="h-3 w-3" />
</motion.div>
</motion.button>
</Button>
<AnimatePresence>
{isOpen && (
@ -158,29 +156,27 @@ export function ProjectSelector() {
/>
<motion.div
className="absolute top-full left-0 mt-1 w-56 bg-[#0f1419] backdrop-blur-xl border border-white/10 rounded-md shadow-xl z-50 overflow-hidden"
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">
{/* Default Project */}
<motion.div
<Button
variant="ghost"
className={`flex items-center justify-between p-2 rounded-md transition-colors cursor-pointer ${
selectedProject === DEFAULT_PROJECT_ID
? "bg-white/15"
: "hover:bg-white/8"
? "bg-accent"
: "hover:bg-accent/50"
}`}
onClick={() => handleProjectSelect(DEFAULT_PROJECT_ID)}
>
<div className="flex items-center gap-2">
<FolderIcon className="h-3.5 w-3.5 text-white/70" />
<span className="text-xs font-medium text-white">
Default
</span>
<FolderIcon className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Default</span>
</div>
</motion.div>
</Button>
{/* User Projects */}
{projects
@ -190,71 +186,69 @@ export function ProjectSelector() {
key={project.id}
className={`flex items-center justify-between p-2 rounded-md transition-colors group ${
selectedProject === project.containerTag
? "bg-white/15"
: "hover:bg-white/8"
? "bg-accent"
: "hover:bg-accent/50"
}`}
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.03 }}
>
<div
<button
className="flex items-center gap-2 flex-1 cursor-pointer"
type="button"
onClick={() =>
handleProjectSelect(project.containerTag)
}
>
<FolderIcon className="h-3.5 w-3.5 text-white/70" />
<span className="text-xs font-medium text-white truncate max-w-32">
<FolderIcon className="h-3.5 w-3.5 opacity-70" />
<span className="text-xs font-medium truncate max-w-32">
{project.name}
</span>
</div>
</button>
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<motion.button
className="p-1 hover:bg-white/10 rounded transition-all"
className="p-1 hover:bg-accent rounded transition-all"
onClick={(e) => e.stopPropagation()}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<MoreHorizontal className="h-3 w-3 text-white/50" />
<MoreHorizontal className="h-3 w-3" />
</motion.button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-black/90 border-white/10"
>
<DropdownMenuContent align="end">
{/* Show experimental toggle only if NOT experimental and NOT default project */}
{!project.isExperimental &&
project.containerTag !== DEFAULT_PROJECT_ID && (
<DropdownMenuItem
className="text-blue-400 hover:text-blue-300 cursor-pointer text-xs"
className="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 cursor-pointer text-xs"
onClick={(e) => {
e.stopPropagation();
e.stopPropagation()
setExpDialog({
open: true,
projectId: project.id,
});
setIsOpen(false);
})
setIsOpen(false)
}}
>
<div className="h-3 w-3 mr-2 rounded border border-blue-400" />
<div className="h-3 w-3 mr-2 rounded border border-blue-600 dark:border-blue-400" />
Enable Experimental Mode
</DropdownMenuItem>
)}
{project.isExperimental && (
<DropdownMenuItem
className="text-blue-300/50 text-xs"
className="text-blue-600/50 dark:text-blue-300/50 text-xs"
disabled
>
<div className="h-3 w-3 mr-2 rounded bg-blue-400" />
<div className="h-3 w-3 mr-2 rounded bg-blue-600 dark:bg-blue-400" />
Experimental Mode Active
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-red-400 hover:text-red-300 cursor-pointer text-xs"
className="text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300 cursor-pointer text-xs"
onClick={(e) => {
e.stopPropagation();
e.stopPropagation()
setDeleteDialog({
open: true,
project: {
@ -264,8 +258,8 @@ export function ProjectSelector() {
},
action: "move",
targetProjectId: "",
});
setIsOpen(false);
})
setIsOpen(false)
}}
>
<Trash2 className="h-3 w-3 mr-2" />
@ -278,15 +272,15 @@ export function ProjectSelector() {
))}
<motion.div
className="flex items-center gap-2 p-2 rounded-md hover:bg-white/8 transition-colors cursor-pointer border-t border-white/10 mt-1"
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-white/70" />
<span className="text-xs font-medium text-white/80">
<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>
@ -310,7 +304,7 @@ export function ProjectSelector() {
}
open={deleteDialog.open}
>
<DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white">
<DialogContent className="sm:max-w-2xl">
<motion.div
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
@ -318,7 +312,7 @@ export function ProjectSelector() {
>
<DialogHeader>
<DialogTitle>Delete Project</DialogTitle>
<DialogDescription className="text-white/60">
<DialogDescription>
Are you sure you want to delete "{deleteDialog.project.name}
"? Choose what to do with the documents in this project.
</DialogDescription>
@ -339,10 +333,7 @@ export function ProjectSelector() {
}
type="radio"
/>
<Label
className="text-white cursor-pointer text-sm"
htmlFor="move"
>
<Label className="cursor-pointer text-sm" htmlFor="move">
Move documents to another project
</Label>
</div>
@ -362,14 +353,11 @@ export function ProjectSelector() {
}
value={deleteDialog.targetProjectId}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-white">
<SelectTrigger className="w-full">
<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={DEFAULT_PROJECT_ID}
>
<SelectContent>
<SelectItem value={DEFAULT_PROJECT_ID}>
Default Project
</SelectItem>
{projects
@ -379,11 +367,7 @@ export function ProjectSelector() {
p.containerTag !== DEFAULT_PROJECT_ID,
)
.map((project: Project) => (
<SelectItem
className="text-white hover:bg-white/10"
key={project.id}
value={project.id}
>
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
@ -406,7 +390,7 @@ export function ProjectSelector() {
type="radio"
/>
<Label
className="text-white cursor-pointer text-sm"
className="cursor-pointer text-sm"
htmlFor="delete"
>
Delete all documents in this project
@ -415,7 +399,7 @@ export function ProjectSelector() {
{deleteDialog.action === "delete" && (
<motion.p
animate={{ opacity: 1 }}
className="text-sm text-red-400 ml-6"
className="text-sm text-red-600 dark:text-red-400 ml-6"
initial={{ opacity: 0 }}
>
This action cannot be undone. All documents will be
@ -430,7 +414,6 @@ export function ProjectSelector() {
whileTap={{ scale: 0.95 }}
>
<Button
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
onClick={() =>
setDeleteDialog({
open: false,
@ -450,11 +433,11 @@ export function ProjectSelector() {
whileTap={{ scale: 0.95 }}
>
<Button
className={`${
className={
deleteDialog.action === "delete"
? "bg-red-600 hover:bg-red-700"
: "bg-white/10 hover:bg-white/20"
} text-white border-white/20`}
? "bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white"
: ""
}
disabled={
deleteProjectMutation.isPending ||
(deleteDialog.action === "move" &&
@ -478,10 +461,10 @@ export function ProjectSelector() {
project: null,
action: "move",
targetProjectId: "",
});
})
},
},
);
)
}
}}
type="button"
@ -514,7 +497,7 @@ export function ProjectSelector() {
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">
<DialogContent className="sm:max-w-lg">
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col gap-4"
@ -522,19 +505,19 @@ export function ProjectSelector() {
initial={{ opacity: 0, scale: 0.95 }}
>
<DialogHeader>
<DialogTitle className="text-white">
Enable Experimental Mode?
</DialogTitle>
<DialogDescription className="text-white/60">
<DialogTitle>Enable Experimental Mode?</DialogTitle>
<DialogDescription>
Experimental mode enables beta features and advanced memory
relationships for this project.
<br />
<br />
<span className="text-yellow-400 font-medium">
<span className="text-yellow-600 dark:text-yellow-400 font-medium">
Warning:
</span>{" "}
This action is{" "}
<span className="text-red-400 font-bold">irreversible</span>
<span className="text-red-600 dark:text-red-400 font-bold">
irreversible
</span>
. Once enabled, you cannot return to regular mode for this
project.
</DialogDescription>
@ -545,7 +528,6 @@ export function ProjectSelector() {
whileTap={{ scale: 0.95 }}
>
<Button
className="bg-white/5 hover:bg-white/10 border-white/10 text-white"
onClick={() =>
setExpDialog({ open: false, projectId: "" })
}
@ -560,7 +542,7 @@ export function ProjectSelector() {
whileTap={{ scale: 0.95 }}
>
<Button
className="bg-blue-600 hover:bg-blue-700 text-white"
className="bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white"
disabled={enableExperimentalMutation.isPending}
onClick={() =>
enableExperimentalMutation.mutate(expDialog.projectId)
@ -584,5 +566,5 @@ export function ProjectSelector() {
)}
</AnimatePresence>
</div>
);
)
}

View file

@ -1,304 +0,0 @@
"use client";
import { useAuth } from "@lib/auth-context";
import { fetchMemoriesFeature, fetchSubscriptionStatus } from "@lib/queries";
import { Button } from "@repo/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@repo/ui/components/dialog";
import { Input } from "@repo/ui/components/input";
import { Label } from "@repo/ui/components/label";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@repo/ui/components/tabs";
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold";
import { useCustomer } from "autumn-js/react";
import {
CheckCircle,
Copy,
CreditCard,
Gift,
LoaderIcon,
Share2,
Users,
} from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useState } from "react";
interface ReferralUpgradeModalProps {
isOpen: boolean;
onClose: () => void;
}
export function ReferralUpgradeModal({
isOpen,
onClose,
}: ReferralUpgradeModalProps) {
const { user } = useAuth();
const autumn = useCustomer();
const [isLoading, setIsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any);
const memoriesUsed = memoriesCheck?.usage ?? 0;
const memoriesLimit = memoriesCheck?.included_usage ?? 0;
// Fetch subscription status
const {
data: status = {
consumer_pro: null,
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn as any);
const isPro = status.consumer_pro;
// Handle upgrade
const handleUpgrade = async () => {
setIsLoading(true);
try {
await autumn.attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/",
});
window.location.reload();
} catch (error) {
console.error(error);
setIsLoading(false);
}
};
// Generate referral link (you'll need to implement this based on your referral system)
const referralLink = `https://app.supermemory.ai/ref/${user?.id || "user"}`;
const handleCopyReferralLink = async () => {
try {
await navigator.clipboard.writeText(referralLink);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: "Join Supermemory",
text: "Check out Supermemory - the best way to organize and search your digital memories!",
url: referralLink,
});
} catch (error) {
console.error("Error sharing:", error);
}
} else {
handleCopyReferralLink();
}
};
if (user?.isAnonymous) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md bg-[#0f1419] 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>Get More Memories</DialogTitle>
<DialogDescription className="text-white/60">
Sign in to access referrals and upgrade options
</DialogDescription>
</DialogHeader>
<div className="text-center py-6">
<Button
asChild
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
>
<Link href="/login">Sign in</Link>
</Button>
</div>
</motion.div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg bg-[#0f1419] 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 className="mb-4">
<DialogTitle>Get More Memories</DialogTitle>
<DialogDescription className="text-white/60">
Expand your memory capacity through referrals or upgrades
</DialogDescription>
</DialogHeader>
{/* Current Usage */}
<div className="bg-white/5 rounded-lg p-4 mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-white/70">Current Usage</span>
<span
className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`}
>
{memoriesUsed} / {memoriesLimit} memories
</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
memoriesUsed >= memoriesLimit
? "bg-red-500"
: isPro
? "bg-green-500"
: "bg-blue-500"
}`}
style={{
width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`,
}}
/>
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="refer" className="w-full">
<TabsList className="grid w-full grid-cols-2 bg-white/5">
<TabsTrigger value="refer" className="flex items-center gap-2">
<Users className="w-4 h-4" />
Refer Friends
</TabsTrigger>
{!isPro && (
<TabsTrigger
value="upgrade"
className="flex items-center gap-2"
>
<CreditCard className="w-4 h-4" />
Upgrade Plan
</TabsTrigger>
)}
</TabsList>
<TabsContent value="refer" className="space-y-4 mt-6">
<div className="text-center">
<Gift className="w-12 h-12 text-blue-400 mx-auto mb-3" />
<HeadingH3Bold className="text-white mb-2">
Invite Friends, Get More Memories
</HeadingH3Bold>
<p className="text-white/70 text-sm">
For every friend who joins, you both get +5 extra memories!
</p>
</div>
<div className="bg-white/5 rounded-lg p-4">
<Label className="text-sm text-white/70 mb-2 block">
Your Referral Link
</Label>
<div className="flex gap-2">
<Input
className="flex-1 bg-white/10 border-white/20 text-white"
readOnly
value={referralLink}
/>
<Button
className="bg-white/5 hover:bg-white/10 text-white border-white/20"
onClick={handleCopyReferralLink}
size="sm"
variant="outline"
>
{copied ? (
<CheckCircle className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
<Button
className="w-full bg-white/5 hover:bg-white/10 text-white border-white/20"
onClick={handleShare}
variant="outline"
>
<Share2 className="w-4 h-4 mr-2" />
Share Link
</Button>
</TabsContent>
{!isPro && (
<TabsContent value="upgrade" className="space-y-4 mt-6">
<div className="text-center">
<CreditCard className="w-12 h-12 text-purple-400 mx-auto mb-3" />
<HeadingH3Bold className="text-white mb-2">
Upgrade to Pro
</HeadingH3Bold>
<p className="text-white/70 text-sm">
Get unlimited memories and advanced features
</p>
</div>
<div className="bg-white/5 rounded-lg border border-white/10 p-4">
<h4 className="font-medium text-white mb-3">
Pro Plan Benefits
</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" />
500 memories (vs {memoriesLimit} free)
</li>
<li className="flex items-center gap-2 text-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
10 connections
</li>
<li className="flex items-center gap-2 text-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
Advanced search
</li>
<li className="flex items-center gap-2 text-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
Priority support
</li>
</ul>
</div>
<Button
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
disabled={isLoading || isCheckingStatus}
onClick={handleUpgrade}
>
{isLoading || isCheckingStatus ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
Upgrading...
</>
) : (
"Upgrade to Pro - $15/month"
)}
</Button>
<p className="text-xs text-white/50 text-center">
Cancel anytime. No questions asked.
</p>
</TabsContent>
)}
</Tabs>
</motion.div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,294 @@
'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
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>
);
}

View file

@ -0,0 +1,74 @@
'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 (
// @ts-expect-error - style is optional
<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>
);
}

View file

@ -38,7 +38,7 @@ function TextShimmerComponent({
initial={{ backgroundPosition: "100% center" }}
animate={{ backgroundPosition: "0% center" }}
transition={{
repeat: Infinity,
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}

View file

@ -1,414 +0,0 @@
"use client";
import { Button } from "@repo/ui/components/button";
import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { analytics } from "@/lib/analytics";
// Types
export interface TourStep {
content: React.ReactNode;
selectorId: string;
position?: "top" | "bottom" | "left" | "right" | "center";
onClickWithinArea?: () => void;
}
interface TourContextType {
currentStep: number;
totalSteps: number;
nextStep: () => void;
previousStep: () => void;
endTour: () => void;
isActive: boolean;
isPaused: boolean;
startTour: () => void;
setSteps: (steps: TourStep[]) => void;
steps: TourStep[];
isTourCompleted: boolean;
setIsTourCompleted: (completed: boolean) => void;
// Expansion state tracking
setMenuExpanded: (expanded: boolean) => void;
setChatExpanded: (expanded: boolean) => void;
}
// Context
const TourContext = React.createContext<TourContextType | undefined>(undefined);
export function useTour() {
const context = React.useContext(TourContext);
if (!context) {
throw new Error("useTour must be used within a TourProvider");
}
return context;
}
// Provider
interface TourProviderProps {
children: React.ReactNode;
onComplete?: () => void;
className?: string;
isTourCompleted?: boolean;
}
export function TourProvider({
children,
onComplete,
className,
isTourCompleted: initialCompleted = false,
}: TourProviderProps) {
const [currentStep, setCurrentStep] = React.useState(-1);
const [steps, setSteps] = React.useState<TourStep[]>([]);
const [isActive, setIsActive] = React.useState(false);
const [isTourCompleted, setIsTourCompleted] =
React.useState(initialCompleted);
// Track expansion states
const [isMenuExpanded, setIsMenuExpanded] = React.useState(false);
const [isChatExpanded, setIsChatExpanded] = React.useState(false);
// Calculate if tour should be paused
const isPaused = React.useMemo(() => {
return isActive && (isMenuExpanded || isChatExpanded);
}, [isActive, isMenuExpanded, isChatExpanded]);
const startTour = React.useCallback(() => {
console.debug("Starting tour with", steps.length, "steps");
analytics.tourStarted();
setCurrentStep(0);
setIsActive(true);
}, [steps]);
const endTour = React.useCallback(() => {
setCurrentStep(-1);
setIsActive(false);
setIsTourCompleted(true); // Mark tour as completed when ended/skipped
analytics.tourSkipped();
if (onComplete) {
onComplete();
}
}, [onComplete]);
const nextStep = React.useCallback(() => {
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1);
} else {
analytics.tourCompleted();
endTour();
setIsTourCompleted(true);
}
}, [currentStep, steps.length, endTour]);
const previousStep = React.useCallback(() => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
}, [currentStep]);
const setMenuExpanded = React.useCallback((expanded: boolean) => {
setIsMenuExpanded(expanded);
}, []);
const setChatExpanded = React.useCallback((expanded: boolean) => {
setIsChatExpanded(expanded);
}, []);
const value = React.useMemo(
() => ({
currentStep,
totalSteps: steps.length,
nextStep,
previousStep,
endTour,
isActive,
isPaused,
startTour,
setSteps,
steps,
isTourCompleted,
setIsTourCompleted,
setMenuExpanded,
setChatExpanded,
}),
[
currentStep,
steps,
nextStep,
previousStep,
endTour,
isActive,
isPaused,
startTour,
isTourCompleted,
setMenuExpanded,
setChatExpanded,
],
);
return (
<TourContext.Provider value={value}>
{children}
{isActive && !isPaused && (
<>
{console.log(
"Rendering TourHighlight for step:",
currentStep,
currentStep >= 0 && currentStep < steps.length
? steps[currentStep]
: "No step",
)}
<TourHighlight
className={className}
currentStepIndex={currentStep}
steps={steps}
/>
</>
)}
</TourContext.Provider>
);
}
// Tour Highlight Component
function TourHighlight({
currentStepIndex,
steps,
className,
}: {
currentStepIndex: number;
steps: TourStep[];
className?: string;
}) {
const { nextStep, previousStep, endTour } = useTour();
const [elementRect, setElementRect] = React.useState<DOMRect | null>(null);
// Get current step safely
const step =
currentStepIndex >= 0 && currentStepIndex < steps.length
? steps[currentStepIndex]
: null;
React.useEffect(() => {
if (!step) return;
// Use requestAnimationFrame to ensure DOM is ready
const rafId = requestAnimationFrame(() => {
const element = document.getElementById(step.selectorId);
console.debug(
"Looking for element with ID:",
step.selectorId,
"Found:",
!!element,
);
if (element) {
const rect = element.getBoundingClientRect();
console.debug("Element rect:", {
id: step.selectorId,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
bottom: rect.bottom,
right: rect.right,
});
setElementRect(rect);
}
});
// Add click listener for onClickWithinArea
let clickHandler: ((e: MouseEvent) => void) | null = null;
if (step.onClickWithinArea) {
const element = document.getElementById(step.selectorId);
if (element) {
clickHandler = (e: MouseEvent) => {
if (element.contains(e.target as Node)) {
step.onClickWithinArea?.();
}
};
document.addEventListener("click", clickHandler);
}
}
return () => {
cancelAnimationFrame(rafId);
if (clickHandler) {
document.removeEventListener("click", clickHandler);
}
};
}, [step]);
if (!step) return null;
// Keep the wrapper mounted but animate the content
return (
<AnimatePresence mode="wait">
{elementRect && (
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key={`tour-step-${currentStepIndex}`}
transition={{ duration: 0.2 }}
>
{/* Highlight Border */}
<motion.div
animate={{ opacity: 1, scale: 1 }}
className={`fixed z-[101] pointer-events-none ${className}`}
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
style={{
top: elementRect.top + window.scrollY,
left: elementRect.left + window.scrollX,
width: elementRect.width,
height: elementRect.height,
}}
>
<div className="absolute inset-0 rounded-lg outline-4 outline-blue-500/50 outline-offset-0" />
</motion.div>
{/* Tooltip with Glass Effect */}
<motion.div
animate={{ opacity: 1, y: 0 }}
className="fixed z-[102] w-72 rounded-lg shadow-xl overflow-hidden"
exit={{ opacity: 0, y: step.position === "top" ? 10 : -10 }}
initial={{ opacity: 0, y: step.position === "top" ? 10 : -10 }}
style={{
top: (() => {
const baseTop =
step.position === "bottom"
? elementRect.bottom + 8
: step.position === "top"
? elementRect.top - 200
: elementRect.top + elementRect.height / 2 - 100;
// Ensure tooltip stays within viewport
const maxTop = window.innerHeight - 250; // Leave space for tooltip height
const minTop = 10;
return Math.max(minTop, Math.min(baseTop, maxTop));
})(),
left: (() => {
const baseLeft =
step.position === "right"
? elementRect.right + 8
: step.position === "left"
? elementRect.left - 300
: elementRect.left + elementRect.width / 2 - 150;
// Ensure tooltip stays within viewport
const maxLeft = window.innerWidth - 300; // Tooltip width
const minLeft = 10;
return Math.max(minLeft, Math.min(baseLeft, maxLeft));
})(),
}}
>
{/* Glass effect background */}
<GlassMenuEffect rounded="rounded-lg" />
{/* Content */}
<div className="relative z-10 p-4">
<div className="mb-4 text-white">{step.content}</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-300">
{currentStepIndex + 1} / {steps.length}
</span>
<div className="flex gap-2">
<Button
className="border-white/20 text-white hover:bg-white/10"
onClick={endTour}
size="sm"
variant="outline"
>
Skip
</Button>
{currentStepIndex > 0 && (
<Button
className="border-white/20 text-white hover:bg-white/10"
onClick={previousStep}
size="sm"
variant="outline"
>
Previous
</Button>
)}
<Button
className="bg-white/20 text-white hover:bg-white/30"
onClick={nextStep}
size="sm"
>
{currentStepIndex === steps.length - 1 ? "Finish" : "Next"}
</Button>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
// Tour Alert Dialog
interface TourAlertDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function TourAlertDialog({ open, onOpenChange }: TourAlertDialogProps) {
const { startTour, setIsTourCompleted } = useTour();
const handleStart = () => {
console.debug("TourAlertDialog: Starting tour");
onOpenChange(false);
startTour();
};
const handleSkip = () => {
analytics.tourSkipped();
setIsTourCompleted(true); // Mark tour as completed when skipped
onOpenChange(false);
};
if (!open) return null;
return (
<AnimatePresence>
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[102] w-[90vw] max-w-2xl rounded-lg shadow-xl overflow-hidden"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
>
{/* Glass effect background */}
<GlassMenuEffect rounded="rounded-lg" />
{/* Content */}
<div className="relative z-10 p-8 md:p-10 lg:p-12">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Welcome to supermemory
</h2>
<p className="text-lg md:text-xl text-gray-200 mb-8 leading-relaxed">
This is your personal knowledge graph where all your memories are
stored and connected. Let's take a quick tour to help you get
familiar with the interface.
</p>
<div className="flex gap-4 justify-end">
<Button
className="border-white/20 text-white hover:bg-white/10 px-6 py-2 text-base"
onClick={handleSkip}
size="lg"
variant="outline"
>
Skip Tour
</Button>
<Button
className="bg-white/20 text-white hover:bg-white/30 px-6 py-2 text-base"
onClick={handleStart}
size="lg"
>
Start Tour
</Button>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View file

@ -26,7 +26,7 @@ export function ActionButtons({
return (
<div className={`flex gap-3 order-1 sm:order-2 justify-end ${className}`}>
<Button
className="hover:bg-white/10 text-white border-none flex-1 sm:flex-initial"
className="hover:bg-foreground/10 border-none flex-1 sm:flex-initial"
onClick={onCancel}
type="button"
variant="ghost"
@ -40,7 +40,7 @@ export function ActionButtons({
className="flex-1 sm:flex-initial"
>
<Button
className="bg-white/10 hover:bg-white/20 text-white border-white/20 w-full"
className="bg-foreground hover:bg-foreground/20 border-foreground/20 w-full"
disabled={isSubmitting || isSubmitDisabled}
onClick={submitType === 'button' ? onSubmit : undefined}
type={submitType}

File diff suppressed because it is too large Load diff

View file

@ -50,18 +50,18 @@ export function ProjectSelection({
value={selectedProject}
>
<SelectTrigger
className={`bg-white/5 border-white/10 text-white ${className}`}
className={`bg-foreground/5 border-foreground/10 ${className}`}
id={id}
>
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent
className="bg-black/90 backdrop-blur-xl border-white/10 z-[90]"
className="bg-black/90 backdrop-blur-xl border-foreground/10 z-[90]"
position="popper"
sideOffset={5}
>
<SelectItem
className="text-white hover:bg-white/10"
className="hover:bg-foreground/10"
key="default"
value="sm_project_default"
>
@ -71,7 +71,7 @@ export function ProjectSelection({
.filter((p) => p.containerTag !== 'sm_project_default' && p.id)
.map((project) => (
<SelectItem
className="text-white hover:bg-white/10"
className="hover:bg-foreground/10"
key={project.id || project.containerTag}
value={project.containerTag}
>
@ -79,7 +79,7 @@ export function ProjectSelection({
</SelectItem>
))}
<SelectItem
className="text-white hover:bg-white/10 border-t border-white/10 mt-1"
className="hover:bg-foreground/10 border-t border-foreground/10 mt-1"
key="create-new"
value="create-new-project"
>

View file

@ -285,7 +285,7 @@ export function TextEditor({
return (
<blockquote
{...props.attributes}
className="border-l-4 border-white/20 pl-4 italic text-white/80"
className="border-l-4 border-foreground/20 pl-4 italic text-foreground/80"
>
{props.children}
</blockquote>
@ -312,7 +312,7 @@ export function TextEditor({
if (leaf.code) {
children = (
<code className="bg-white/10 px-1 rounded text-sm">{children}</code>
<code className="bg-foreground/10 px-1 rounded text-sm">{children}</code>
);
}
@ -402,10 +402,10 @@ export function TextEditor({
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 !p-0 text-white/70 transition-all duration-200 rounded-sm",
"hover:bg-white/15 hover:text-white hover:scale-105",
"h-8 w-8 !p-0 text-foreground/70 transition-all duration-200 rounded-sm",
"hover:bg-foreground/15 hover:text-foreground hover:scale-105",
"active:scale-95",
isActive && "bg-white/20 text-white",
isActive && "bg-foreground/20 text-foreground",
)}
onMouseDown={onMouseDown}
title={title}
@ -444,7 +444,7 @@ export function TextEditor({
onBlur={onBlur}
readOnly={disabled}
className={cn(
"outline-none w-full h-full text-white placeholder:text-white/50",
"outline-none w-full h-full placeholder:text-foreground/50",
disabled && "opacity-50 cursor-not-allowed",
)}
style={{
@ -457,7 +457,7 @@ export function TextEditor({
</div>
{/* Toolbar */}
<div className="p-1 flex items-center gap-2 bg-white/5 backdrop-blur-sm rounded-b-md">
<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
@ -489,7 +489,7 @@ export function TextEditor({
/>
</div>
<div className="w-px h-6 bg-white/30 mx-2" />
<div className="w-px h-6 bg-foreground/30 mx-2" />
<div className="flex items-center gap-1">
{/* Block formatting */}

View file

@ -1,70 +1,72 @@
import { useAuth } from "@lib/auth-context";
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 { 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";
} 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 { 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);
const autumn = useCustomer()
const { user } = useAuth()
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
analytics.billingViewed();
}, []);
analytics.billingViewed()
}, [])
const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any);
const memoriesUsed = memoriesCheck?.usage ?? 0;
const memoriesLimit = memoriesCheck?.included_usage ?? 0;
const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any);
const connectionsUsed = connectionsCheck?.usage ?? 0;
// Fetch subscription status with React Query
const {
data: status = {
consumer_pro: null,
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn as any);
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
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);
analytics.upgradeInitiated()
setIsLoading(true)
try {
await autumn.attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/",
});
analytics.upgradeCompleted();
window.location.reload();
})
analytics.upgradeCompleted()
window.location.reload()
} catch (error) {
console.error(error);
setIsLoading(false);
console.error(error)
setIsLoading(false)
}
};
}
// Handle manage billing
const handleManageBilling = async () => {
analytics.billingPortalOpened();
analytics.billingPortalOpened()
await autumn.openBillingPortal({
returnUrl: "https://app.supermemory.ai",
});
};
})
}
const isPro = status.consumer_pro;
const isPro = status.consumer_pro
if (user?.isAnonymous) {
return (
@ -74,18 +76,20 @@ export function BillingView() {
initial={{ opacity: 0, scale: 0.9 }}
transition={{ type: "spring", damping: 20 }}
>
<p className="text-white/70 mb-4">Sign in to unlock premium features</p>
<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-white/10 hover:bg-white/20 text-white border-white/20"
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 (isPro) {
@ -96,28 +100,28 @@ export function BillingView() {
initial={{ opacity: 0, y: 10 }}
>
<div className="space-y-3">
<HeadingH3Bold className="text-white flex items-center gap-2">
<HeadingH3Bold className="text-foreground flex items-center gap-2">
Pro Plan
<span className="text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full">
<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-white/70">
<p className="text-sm text-muted-foreground">
You're enjoying expanded memory capacity with supermemory Pro!
</p>
</div>
{/* Current Usage */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-white/90">Current Usage</h4>
<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-white/70">Memories</span>
<span className="text-sm text-white/90">
<span className="text-sm text-muted-foreground">Memories</span>
<span className="text-sm text-foreground">
{memoriesUsed} / {memoriesLimit}
</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<div className="w-full bg-muted-foreground/50 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all"
style={{
@ -128,26 +132,19 @@ export function BillingView() {
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-white/70">Connections</span>
<span className="text-sm text-white/90">
<span className="text-sm text-muted-foreground">Connections</span>
<span className="text-sm text-foreground">
{connectionsUsed} / 10
</span>
</div>
</div>
</div>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
onClick={handleManageBilling}
size="sm"
variant="outline"
>
Manage Billing
</Button>
</motion.div>
<Button onClick={handleManageBilling} size="sm" variant="default">
Manage Billing
</Button>
</motion.div>
);
)
}
return (
@ -158,17 +155,19 @@ export function BillingView() {
>
{/* Current Usage - Free Plan */}
<div className="space-y-3">
<HeadingH3Bold className="text-white">Current Plan: Free</HeadingH3Bold>
<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-white/70">Memories</span>
<span className="text-sm text-muted-foreground">Memories</span>
<span
className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`}
className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-500" : "text-foreground"}`}
>
{memoriesUsed} / {memoriesLimit}
</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<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"
@ -183,23 +182,25 @@ export function BillingView() {
{/* Comparison */}
<div className="space-y-4">
<HeadingH3Bold className="text-white">Upgrade to Pro</HeadingH3Bold>
<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-white/5 rounded-lg border border-white/10">
<h4 className="font-medium text-white/90 mb-3">Free Plan</h4>
<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-white/70">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-white/70">
<X className="h-4 w-4 text-red-400" />
<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-white/70">
<CheckCircle className="h-4 w-4 text-green-400" />
<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>
@ -207,9 +208,9 @@ export function BillingView() {
{/* 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-white mb-3 flex items-center gap-2">
<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-400 px-2 py-0.5 rounded-full">
<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>
@ -218,44 +219,41 @@ export function BillingView() {
<CheckCircle className="h-4 w-4 text-green-400" />
Unlimited memories
</li>
<li className="flex items-center gap-2 text-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
className="bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white border-0 w-full"
disabled={isLoading || isCheckingStatus}
onClick={handleUpgrade}
size="sm"
>
{isLoading || isCheckingStatus ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
Upgrading...
</>
) : (
<div>Upgrade to Pro - $15/month (only for first 100 users)</div>
)}
</Button>
</motion.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 - $15/month (only for first 100 users)</div>
)}
</Button>
<p className="text-xs text-white/50 text-center">
<p className="text-xs text-muted-foreground text-center">
Cancel anytime. No questions asked.
</p>
</div>
</motion.div>
);
)
}

View file

@ -1,49 +1,49 @@
"use client";
"use client"
import { useChat, useCompletion } from "@ai-sdk/react";
import { cn } from "@lib/utils";
import { Button } from "@ui/components/button";
import { Input } from "@ui/components/input";
import { DefaultChatTransport } from "ai";
import { useChat, useCompletion } 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,
Plus,
RotateCcw,
X,
} from "lucide-react";
import { 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 { Spinner } from "../../spinner";
} 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 { Spinner } from "../../spinner"
interface MemoryResult {
documentId?: string;
title?: string;
content?: string;
url?: string;
score?: number;
documentId?: string
title?: string
content?: string
url?: string
score?: number
}
interface ExpandableMemoriesProps {
foundCount: number;
results: MemoryResult[];
foundCount: number
results: MemoryResult[]
}
function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
const [isExpanded, setIsExpanded] = useState(false);
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 (
@ -58,17 +58,16 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
) : (
<ChevronRight className="size-4" />
)}
<Check className="size-4" />
Found {foundCount} {foundCount === 1 ? "memory" : "memories"}
Related memories
</button>
{isExpanded && results.length > 0 && (
<div className="mt-2 ml-6 space-y-2 max-h-48 overflow-y-auto">
<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://"));
result.url.startsWith("https://"))
const content = (
<>
@ -83,7 +82,7 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
</div>
)}
{result.url && (
<div className="text-xs text-blue-400 mt-1 truncate">
<div className="text-xs text-blue-600 dark:text-blue-400 mt-1 truncate">
{result.url}
</div>
)}
@ -93,12 +92,12 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
</div>
)}
</>
);
)
if (isClickable) {
return (
<a
className="block p-2 bg-white/5 rounded-md border border-white/10 hover:bg-white/10 hover:border-white/20 transition-colors cursor-pointer"
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"
@ -106,93 +105,93 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) {
>
{content}
</a>
);
)
}
return (
<div
className="p-2 bg-white/5 rounded-md border border-white/10"
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 scrollContainerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const [isAutoScroll, setIsAutoScroll] = useState(true)
const [isFarFromBottom, setIsFarFromBottom] = useState(false)
const scrollToBottom = (behavior: ScrollBehavior = "auto") => {
const node = bottomRef.current;
if (node) node.scrollIntoView({ behavior, block: "end" });
};
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 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);
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();
}, []);
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [])
useEffect(
function observeContentResize() {
const container = scrollContainerRef.current;
if (!container) return;
const container = scrollContainerRef.current
if (!container) return
const resizeObserver = new ResizeObserver(() => {
if (isAutoScroll) scrollToBottom("auto");
if (isAutoScroll) scrollToBottom("auto")
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
setIsFarFromBottom(distanceFromBottom > 100);
});
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
container.scrollHeight - container.scrollTop - container.clientHeight
setIsFarFromBottom(distanceFromBottom > 100)
})
resizeObserver.observe(container)
return () => resizeObserver.disconnect()
},
[isAutoScroll],
);
[isAutoScroll, scrollToBottom],
)
function enableAutoScroll() {
setIsAutoScroll(true);
setIsAutoScroll(true)
}
useEffect(
function autoScrollOnNewContent() {
if (isAutoScroll) scrollToBottom("auto");
if (isAutoScroll) scrollToBottom("auto")
},
[isAutoScroll, scrollToBottom, ...triggerKeys],
);
)
const recomputeDistanceFromBottom = () => {
const container = scrollContainerRef.current;
if (!container) return;
const recomputeDistanceFromBottom = useCallback(() => {
const container = scrollContainerRef.current
if (!container) return
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
setIsFarFromBottom(distanceFromBottom > 100);
};
container.scrollHeight - container.scrollTop - container.clientHeight
setIsFarFromBottom(distanceFromBottom > 100)
}, [])
useEffect(() => {
recomputeDistanceFromBottom();
}, [recomputeDistanceFromBottom, ...triggerKeys]);
recomputeDistanceFromBottom()
}, [recomputeDistanceFromBottom, ...triggerKeys])
function onScroll() {
recomputeDistanceFromBottom();
recomputeDistanceFromBottom()
}
return {
@ -203,11 +202,11 @@ function useStickyAutoScroll(triggerKeys: ReadonlyArray<unknown>) {
onScroll,
enableAutoScroll,
scrollToBottom,
} as const;
} as const
}
export function ChatMessages() {
const { selectedProject } = useProject();
const { selectedProject } = useProject()
const {
currentChatId,
setCurrentChatId,
@ -215,12 +214,14 @@ export function ChatMessages() {
getCurrentConversation,
setConversationTitle,
getCurrentChat,
} = usePersistentChat();
} = usePersistentChat()
const activeChatIdRef = useRef<string | null>(null);
const shouldGenerateTitleRef = useRef<boolean>(false);
const [input, setInput] = useState("")
const activeChatIdRef = useRef<string | null>(null)
const shouldGenerateTitleRef = useRef<boolean>(false)
const hasRunInitialMessageRef = useRef<boolean>(false)
const { setDocumentIds } = useGraphHighlights();
const { setDocumentIds } = useGraphHighlights()
const { messages, sendMessage, status, stop, setMessages, id, regenerate } =
useChat({
@ -232,91 +233,118 @@ export function ChatMessages() {
}),
maxSteps: 2,
onFinish: (result) => {
const activeId = activeChatIdRef.current;
if (!activeId) return;
if (result.message.role !== "assistant") return;
const activeId = activeChatIdRef.current
if (!activeId) return
if (result.message.role !== "assistant") return
if (shouldGenerateTitleRef.current) {
const textPart = result.message.parts.find(
(p: any) => p?.type === "text",
) as any;
const text = textPart?.text?.trim();
) as any
const text = textPart?.text?.trim()
if (text) {
shouldGenerateTitleRef.current = false;
complete(text);
shouldGenerateTitleRef.current = false
complete(text)
}
}
},
});
})
useEffect(() => {
activeChatIdRef.current = currentChatId ?? id ?? null;
}, [currentChatId, id]);
activeChatIdRef.current = currentChatId ?? id ?? null
}, [currentChatId, id])
useEffect(() => {
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])
useEffect(() => {
if (id && id !== currentChatId) {
setCurrentChatId(id);
setCurrentChatId(id)
}
}, [id, currentChatId, setCurrentChatId]);
}, [id])
useEffect(() => {
const msgs = getCurrentConversation();
setMessages(msgs ?? []);
setInput("");
}, [currentChatId]);
const msgs = getCurrentConversation()
if (msgs && msgs.length > 0) {
setMessages(msgs)
} else if (!currentChatId) {
setMessages([])
}
setInput("")
}, [currentChatId])
useEffect(() => {
const activeId = currentChatId ?? id;
const activeId = currentChatId ?? id
if (activeId && messages.length > 0) {
setConversation(activeId, messages);
setConversation(activeId, messages)
}
}, [messages, currentChatId, id, setConversation]);
}, [messages, currentChatId, id])
const [input, setInput] = useState("");
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());
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");
if (!lastAssistant) return;
.find((m) => m.role === "assistant")
if (!lastAssistant) return
const lastSearchPart = [...(lastAssistant.parts as any[])]
.reverse()
.find(
(p) =>
p?.type === "tool-searchMemories" &&
p?.state === "output-available",
);
if (!lastSearchPart) return;
const output = (lastSearchPart as any).output;
)
if (!lastSearchPart) return
const output = (lastSearchPart as any).output
const ids = Array.isArray(output?.results)
? ((output.results as any[])
.map((r) => r?.documentId)
.filter(Boolean) as string[])
: [];
: []
if (ids.length > 0) {
setDocumentIds(ids);
setDocumentIds(ids)
}
} catch {}
}, [messages, setDocumentIds]);
}, [messages])
useEffect(() => {
const currentSummary = getCurrentChat();
const currentSummary = getCurrentChat()
const hasTitle = Boolean(
currentSummary?.title && currentSummary.title.trim().length > 0,
);
shouldGenerateTitleRef.current = !hasTitle;
}, [getCurrentChat]);
)
shouldGenerateTitleRef.current = !hasTitle
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
sendMessage({ text: input })
setInput("")
}
}
const {
scrollContainerRef,
bottomRef,
@ -324,39 +352,48 @@ export function ChatMessages() {
onScroll,
enableAutoScroll,
scrollToBottom,
} = useStickyAutoScroll([messages, status]);
} = useStickyAutoScroll([messages, status])
return (
<>
<div className="relative grow">
<div className="h-full flex flex-col w-full">
<div className="flex-1 relative">
<div
className="flex flex-col gap-2 absolute inset-0 overflow-y-auto px-4 pt-4 pb-7 scroll-pb-7"
className="flex flex-col gap-2 absolute inset-0 overflow-y-auto px-4 pt-4 pb-7 scroll-pb-7 custom-scrollbar"
onScroll={onScroll}
ref={scrollContainerRef}
>
{messages.map((message) => (
<div
className={cn(
"flex flex-col",
message.role === "user" ? "items-end" : "items-start",
"flex my-2",
message.role === "user"
? "items-center flex-row-reverse gap-2"
: "flex-col",
)}
key={message.id}
>
<div className="flex flex-col gap-2 max-w-4/5 bg-white/10 py-3 px-4 rounded-lg">
<div
className={cn(
"flex flex-col gap-2 max-w-4/5",
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) => {
.map((part, index) => {
switch (part.type) {
case "text":
return (
<div key={message.id + part.type}>
<div key={`${message.id}-${part.type}-${index}`}>
<Streamdown>{part.text}</Streamdown>
</div>
);
)
case "tool-searchMemories": {
switch (part.state) {
case "input-available":
@ -364,46 +401,46 @@ export function ChatMessages() {
return (
<div
className="text-sm flex items-center gap-2 text-muted-foreground"
key={message.id + part.type}
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}
key={`${message.id}-${part.type}-${index}`}
>
<X className="size-4" /> Error recalling
memories
</div>
);
)
case "output-available": {
const output = part.output;
const output = part.output
const foundCount =
typeof output === "object" &&
output !== null &&
"count" in output
? Number(output.count) || 0
: 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}
key={`${message.id}-${part.type}-${index}`}
results={results}
/>
);
)
}
default:
return null;
return null
}
}
case "tool-addMemory": {
@ -412,44 +449,44 @@ export function ChatMessages() {
return (
<div
className="text-sm flex items-center gap-2 text-muted-foreground"
key={message.id + part.type}
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}
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}
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}
key={`${message.id}-${part.type}-${index}`}
>
<Spinner className="size-4" /> Adding memory...
</div>
);
)
default:
return null;
return null
}
}
default:
return null;
return null
}
})}
</div>
@ -463,8 +500,8 @@ export function ChatMessages() {
.filter((p) => p.type === "text")
?.map((p) => (p as any).text)
.join("\n") ?? "",
);
toast.success("Copied to clipboard");
)
toast.success("Copied to clipboard")
}}
size="icon"
variant="ghost"
@ -503,8 +540,8 @@ export function ChatMessages() {
: "opacity-0 scale-95 pointer-events-none",
)}
onClick={() => {
enableAutoScroll();
scrollToBottom("smooth");
enableAutoScroll()
scrollToBottom("smooth")
}}
size="sm"
type="button"
@ -514,41 +551,44 @@ export function ChatMessages() {
</Button>
</div>
<form
className="flex gap-2 px-4 pb-4 pt-1 relative"
onSubmit={(e) => {
e.preventDefault();
if (status === "submitted") return;
if (status === "streaming") {
stop();
return;
}
if (input.trim()) {
enableAutoScroll();
scrollToBottom("auto");
sendMessage({ text: input });
setInput("");
}
}}
>
<div className="absolute top-0 left-0 -mt-7 w-full h-7 bg-gradient-to-t from-background to-transparent" />
<Input
className="w-full"
disabled={status === "submitted"}
onChange={(e) => setInput(e.target.value)}
placeholder="Say something..."
value={input}
/>
<Button disabled={status === "submitted"} type="submit">
{status === "ready" ? (
<ArrowUp className="size-4" />
) : status === "submitted" ? (
<Spinner className="size-4" />
) : (
<X className="size-4" />
)}
</Button>
</form>
</>
);
<div className="px-4 pb-4 pt-1 relative flex-shrink-0">
<form
className="flex flex-col items-end gap-3 bg-card border border-border rounded-[22px] p-3 relative shadow-lg dark:shadow-2xl"
onSubmit={(e) => {
e.preventDefault()
if (status === "submitted") return
if (status === "streaming") {
stop()
return
}
if (input.trim()) {
enableAutoScroll()
scrollToBottom("auto")
sendMessage({ text: input })
setInput("")
}
}}
>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
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">
<Button
type="submit"
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>
)
}

View file

@ -183,16 +183,16 @@ export function ConnectionsTabContent() {
return (
<div className="space-y-4">
<div className="mb-4">
<p className="text-sm text-white/70">
<p className="text-sm text-foreground/70">
Connect your favorite services to import documents
</p>
{isProUser && !autumn.isLoading && (
<p className="text-xs text-white/50 mt-1">
<p className="text-xs text-foreground/50 mt-1">
{connectionsUsed} of {connectionsLimit} connections used
</p>
)}
{!isProUser && !autumn.isLoading && (
<p className="text-xs text-white/50 mt-1">
<p className="text-xs text-foreground/50 mt-1">
Connections require a Pro subscription
</p>
)}
@ -208,7 +208,7 @@ export function ConnectionsTabContent() {
<p className="text-sm text-yellow-400 mb-2">
🔌 Connections are a Pro feature
</p>
<p className="text-xs text-white/60 mb-3">
<p className="text-xs text-foreground/60 mb-3">
Connect Google Drive, Notion, OneDrive and more to automatically
sync your documents.
</p>
@ -228,12 +228,12 @@ export function ConnectionsTabContent() {
{[...Array(2)].map((_, i) => (
<motion.div
animate={{ opacity: 1 }}
className="p-4 bg-white/5 rounded-lg"
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-white/10" />
<Skeleton className="h-12 w-full bg-foreground/10" />
</motion.div>
))}
</div>
@ -244,8 +244,8 @@ export function ConnectionsTabContent() {
initial={{ opacity: 0, scale: 0.9 }}
transition={{ type: "spring", damping: 20 }}
>
<p className="text-white/50 mb-2">No connections yet</p>
<p className="text-xs text-white/40">
<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>
@ -255,7 +255,7 @@ export function ConnectionsTabContent() {
{connections.map((connection, index) => (
<motion.div
animate={{ opacity: 1, x: 0 }}
className="flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
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}
@ -263,19 +263,13 @@ export function ConnectionsTabContent() {
transition={{ delay: index * 0.05 }}
>
<div className="flex items-center gap-3">
<motion.div
animate={{ rotate: 0, opacity: 1 }}
initial={{ rotate: -180, opacity: 0 }}
transition={{ delay: index * 0.05 + 0.2 }}
>
{getProviderIcon(connection.provider)}
</motion.div>
{getProviderIcon(connection.provider)}
<div>
<p className="font-medium text-white capitalize">
<p className="font-medium text-foreground capitalize">
{connection.provider.replace("-", " ")}
</p>
{connection.email && (
<p className="text-sm text-white/60">
<p className="text-sm text-foreground/60">
{connection.email}
</p>
)}
@ -286,7 +280,7 @@ export function ConnectionsTabContent() {
whileTap={{ scale: 0.9 }}
>
<Button
className="text-white/50 hover:text-red-400"
className="text-foreground/50 hover:text-red-400"
disabled={deleteConnectionMutation.isPending}
onClick={() =>
deleteConnectionMutation.mutate(connection.id)
@ -305,9 +299,7 @@ export function ConnectionsTabContent() {
{/* Available Connections Section */}
<div className="mt-6">
<h3 className="text-lg font-medium text-white mb-4">
Available Connections
</h3>
<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
@ -317,11 +309,9 @@ export function ConnectionsTabContent() {
initial={{ opacity: 0, y: 20 }}
key={provider}
transition={{ delay: index * 0.05 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Button
className="justify-start h-auto p-4 bg-white/5 hover:bg-white/10 border-white/10 text-white w-full"
className="justify-start h-auto p-4 bg-foreground/5 hover:bg-foreground/10 border-foreground/10 w-full"
disabled={addConnectionMutation.isPending}
onClick={() => {
addConnectionMutation.mutate(provider as ConnectorProvider)
@ -331,7 +321,7 @@ export function ConnectionsTabContent() {
<Icon className="h-8 w-8 mr-3" />
<div className="text-left">
<div className="font-medium">{config.title}</div>
<div className="text-sm text-white/60 mt-0.5">
<div className="text-sm text-foreground/60 mt-0.5">
{config.description}
</div>
</div>

View file

@ -118,7 +118,10 @@ export function IntegrationsView() {
}
}, [autumn.isLoading, autumn.customer])
const { data: connectionsCheck } = fetchConnectionsFeature(autumn)
const { data: connectionsCheck } = fetchConnectionsFeature(
autumn,
!autumn.isLoading,
)
const connectionsUsed = connectionsCheck?.balance ?? 0
const connectionsLimit = connectionsCheck?.included_usage ?? 0
@ -281,25 +284,25 @@ export function IntegrationsView() {
return (
<div className="space-y-4 sm:space-y-4 custom-scrollbar">
{/* iOS Shortcuts */}
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
<div className="bg-card rounded-xl border border-border overflow-hidden shadow-sm">
<div className="p-4 sm:p-5">
<div className="flex items-start gap-3 mb-3">
<div className="p-2 bg-blue-500/20 rounded-lg flex-shrink-0">
<Smartphone className="h-5 w-5 text-blue-400" />
<div className="p-2 bg-primary/10 rounded-lg flex-shrink-0">
<Smartphone className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold text-base mb-1">
<h3 className="text-card-foreground font-semibold text-base mb-1">
Apple shortcuts
</h3>
<p className="text-white/70 text-sm leading-relaxed">
<p className="text-muted-foreground text-sm leading-relaxed">
Add memories directly from iPhone, iPad or Mac.
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<Button
variant="ghost"
className="flex-1 text-white hover:bg-blue-500/10 bg-[#171F59]/75 "
variant="secondary"
className="flex-1"
onClick={() => handleShortcutClick("add")}
disabled={createApiKeyMutation.isPending}
>
@ -314,8 +317,8 @@ export function IntegrationsView() {
: "Add Memory Shortcut"}
</Button>
<Button
variant="ghost"
className="flex-1 text-white hover:bg-blue-500/10 bg-[#171F59]/75"
variant="secondary"
className="flex-1"
onClick={() => handleShortcutClick("search")}
disabled={createApiKeyMutation.isPending}
>
@ -334,19 +337,19 @@ export function IntegrationsView() {
</div>
{/* Chrome Extension */}
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
<div className="bg-card rounded-xl border border-border overflow-hidden shadow-sm">
<div className="p-4 sm:p-5">
<div className="flex items-start gap-3">
<div className="p-2 bg-orange-500/20 rounded-lg flex-shrink-0">
<ChromeIcon className="h-5 w-5 text-orange-400" />
<div className="p-2 bg-primary/10 rounded-lg flex-shrink-0">
<ChromeIcon className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0 mb-3">
<div className="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-2 mb-1">
<h3 className="text-white font-semibold text-base">
<h3 className="text-card-foreground font-semibold text-base">
Chrome Extension
</h3>
<Button
className="text-white bg-secondary w-fit"
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 w-fit"
onClick={() => {
window.open(
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
@ -366,20 +369,20 @@ export function IntegrationsView() {
</div>
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-1.5 h-1.5 bg-orange-400 rounded-full flex-shrink-0" />
<p className="text-white/80 text-sm">
<div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />
<p className="text-muted-foreground text-sm">
Save any webpage to supermemory
</p>
</div>
<div className="flex items-center gap-3">
<div className="w-1.5 h-1.5 bg-orange-400 rounded-full flex-shrink-0" />
<p className="text-white/80 text-sm">
<div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />
<p className="text-muted-foreground text-sm">
Import All your Twitter Bookmarks
</p>
</div>
<div className="flex items-center gap-3">
<div className="w-1.5 h-1.5 bg-orange-400 rounded-full flex-shrink-0" />
<p className="text-white/80 text-sm">
<div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />
<p className="text-muted-foreground text-sm">
Bring all your chatGPT memories to Supermemory
</p>
</div>
@ -388,12 +391,12 @@ export function IntegrationsView() {
</div>
{/* Connections Section */}
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
<div className="bg-card rounded-xl border border-border overflow-hidden shadow-sm">
<div className="p-4 sm:p-5">
<div className="flex items-start gap-3 mb-3">
<div className="p-2 bg-green-500/20 rounded-lg flex-shrink-0">
<div className="p-2 bg-primary/10 rounded-lg flex-shrink-0">
<svg
className="h-5 w-5 text-green-400"
className="h-5 w-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -408,14 +411,14 @@ export function IntegrationsView() {
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold text-base mb-1">
<h3 className="text-card-foreground font-semibold text-base mb-1">
Connections
</h3>
<p className="text-white/70 text-sm leading-relaxed mb-2">
<p className="text-muted-foreground text-sm leading-relaxed mb-2">
Connect your accounts to sync document.
</p>
{!isProUser && (
<p className="text-xs text-white/50">
<p className="text-xs text-muted-foreground/70">
Connections require a Pro subscription
</p>
)}
@ -426,21 +429,21 @@ export function IntegrationsView() {
{!autumn.isLoading && !isProUser && (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg mb-3"
className="p-4 bg-accent border border-border rounded-lg mb-3"
initial={{ opacity: 0, y: -10 }}
>
<p className="text-sm text-yellow-400 mb-2">
<p className="text-sm text-accent-foreground mb-2 font-medium">
🔌 Connections are a Pro feature
</p>
<p className="text-xs text-white/60 mb-3">
<p className="text-xs text-muted-foreground mb-3">
Connect Google Drive, Notion, OneDrive and more to automatically
sync your documents.
</p>
<Button
className="bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border-yellow-500/30 w-full sm:w-auto"
className="w-full sm:w-auto"
onClick={handleUpgrade}
size="sm"
variant="secondary"
variant="default"
>
Upgrade to Pro
</Button>
@ -453,12 +456,12 @@ export function IntegrationsView() {
{Object.keys(CONNECTORS).map((_, i) => (
<motion.div
animate={{ opacity: 1 }}
className="p-3 bg-white/5 rounded-lg"
className="p-3 bg-accent rounded-lg"
initial={{ opacity: 0 }}
key={`skeleton-${Date.now()}-${i}`}
transition={{ delay: i * 0.1 }}
>
<Skeleton className="h-12 w-full bg-white/10" />
<Skeleton className="h-12 w-full bg-muted" />
</motion.div>
))}
</div>
@ -474,46 +477,39 @@ export function IntegrationsView() {
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-accent rounded-lg hover:bg-accent/80 transition-colors border border-border/50"
initial={{ opacity: 0, y: 20 }}
key={provider}
transition={{ delay: index * 0.05 }}
>
<div className="flex items-center gap-3 flex-1">
<motion.div
animate={{ rotate: 0, opacity: 1 }}
className="flex-shrink-0"
initial={{ rotate: -180, opacity: 0 }}
transition={{ delay: index * 0.05 + 0.2 }}
>
<Icon className="h-8 w-8" />
</motion.div>
<Icon className="h-8 w-8" />
<div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
<p className="font-medium text-white text-sm">
<p className="font-medium text-card-foreground text-sm">
{config.title}
</p>
{isConnected ? (
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-400 rounded-full" />
<span className="text-xs text-green-400 font-medium">
<div className="w-2 h-2 bg-chart-2 rounded-full" />
<span className="text-xs text-chart-2 font-medium">
Connected
</span>
</div>
) : (
<div className="hidden sm:flex items-center gap-1">
<div className="w-2 h-2 bg-gray-400 rounded-full" />
<span className="text-xs text-gray-400 font-medium">
<div className="w-2 h-2 bg-muted-foreground rounded-full" />
<span className="text-xs text-muted-foreground font-medium">
Disconnected
</span>
</div>
)}
</div>
<p className="text-xs text-white/60 mt-0.5">
<p className="text-xs text-muted-foreground mt-0.5">
{config.description}
</p>
{connection?.email && (
<p className="text-xs text-white/50 mt-1">
<p className="text-xs text-muted-foreground/70 mt-1">
{connection.email}
</p>
)}
@ -527,7 +523,7 @@ export function IntegrationsView() {
whileTap={{ scale: 0.95 }}
>
<Button
className="text-white/70 hover:text-red-400 hover:bg-red-500/10 w-full sm:w-auto"
className="text-destructive hover:bg-destructive/10 w-full sm:w-auto"
disabled={deleteConnectionMutation.isPending}
onClick={() =>
deleteConnectionMutation.mutate(connection.id)
@ -542,8 +538,8 @@ export function IntegrationsView() {
) : (
<div className="flex items-center justify-between gap-2 w-full sm:w-auto">
<div className="sm:hidden flex items-center gap-1">
<div className="w-2 h-2 bg-gray-400 rounded-full" />
<span className="text-xs text-gray-400 font-medium">
<div className="w-2 h-2 bg-muted-foreground rounded-full" />
<span className="text-xs text-muted-foreground font-medium">
Disconnected
</span>
</div>
@ -553,7 +549,7 @@ export function IntegrationsView() {
className="flex-shrink-0"
>
<Button
className="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border-blue-600/30 min-w-[80px] disabled:cursor-not-allowed"
className="min-w-[80px] disabled:cursor-not-allowed"
disabled={
addConnectionMutation.isPending || !isProUser
}
@ -563,7 +559,7 @@ export function IntegrationsView() {
)
}}
size="sm"
variant="outline"
variant="default"
>
{addConnectionMutation.isPending &&
addConnectionMutation.variables === provider
@ -583,14 +579,14 @@ export function IntegrationsView() {
</div>
<div className="p-3">
<p className="text-white/70 text-sm leading-relaxed text-center">
<p className="text-muted-foreground text-sm leading-relaxed text-center">
More integrations are coming soon! Have a suggestion? Share it with us
on{" "}
<a
href="https://x.com/supermemoryai"
target="_blank"
rel="noopener noreferrer"
className="text-orange-500 hover:text-orange-400 underline"
className="text-primary hover:text-primary/80 underline"
>
X
</a>
@ -601,9 +597,9 @@ export function IntegrationsView() {
{/* API Key Modal */}
<Dialog open={showApiKeyModal} onOpenChange={handleDialogClose}>
<DialogPortal>
<DialogContent className="bg-[#0f1419] border-white/10 text-white md:max-w-md z-[100]">
<DialogContent className="bg-card border-border text-card-foreground md:max-w-md z-[100]">
<DialogHeader>
<DialogTitle className="text-white text-lg font-semibold">
<DialogTitle className="text-card-foreground text-lg font-semibold">
Setup{" "}
{selectedShortcutType === "add"
? "Add Memory"
@ -619,7 +615,7 @@ export function IntegrationsView() {
<div className="space-y-2">
<label
htmlFor={apiKeyId}
className="text-sm font-medium text-white/80"
className="text-sm font-medium text-muted-foreground"
>
Your API Key
</label>
@ -629,16 +625,16 @@ export function IntegrationsView() {
type="text"
value={apiKey}
readOnly
className="flex-1 bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-sm text-white font-mono"
className="flex-1 bg-input border border-border rounded-lg px-3 py-2 text-sm text-foreground font-mono"
/>
<Button
size="sm"
variant="ghost"
onClick={handleCopyApiKey}
className="text-white/70 hover:text-white hover:bg-white/10"
className="hover:bg-accent"
>
{copied ? (
<Check className="h-4 w-4 text-green-400" />
<Check className="h-4 w-4 text-chart-2" />
) : (
<Copy className="h-4 w-4" />
)}
@ -648,31 +644,31 @@ export function IntegrationsView() {
{/* Steps */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-white/80">
<h4 className="text-sm font-medium text-muted-foreground">
Follow these steps:
</h4>
<div className="space-y-2">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-400 rounded-full flex items-center justify-center text-xs font-medium">
<div className="flex-shrink-0 w-6 h-6 bg-primary/20 text-primary rounded-full flex items-center justify-center text-xs font-medium">
1
</div>
<p className="text-sm text-white/70">
<p className="text-sm text-muted-foreground">
Click "Add to Shortcuts" below to open the shortcut
</p>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-400 rounded-full flex items-center justify-center text-xs font-medium">
<div className="flex-shrink-0 w-6 h-6 bg-primary/20 text-primary rounded-full flex items-center justify-center text-xs font-medium">
2
</div>
<p className="text-sm text-white/70">
<p className="text-sm text-muted-foreground">
Paste your API key when prompted
</p>
</div>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-400 rounded-full flex items-center justify-center text-xs font-medium">
<div className="flex-shrink-0 w-6 h-6 bg-primary/20 text-primary rounded-full flex items-center justify-center text-xs font-medium">
3
</div>
<p className="text-sm text-white/70">
<p className="text-sm text-muted-foreground">
Start using your shortcut!
</p>
</div>
@ -682,8 +678,9 @@ export function IntegrationsView() {
<div className="flex gap-2 pt-2">
<Button
onClick={handleOpenShortcut}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
className="flex-1"
disabled={!selectedShortcutType}
variant="default"
>
<Image
src="/images/ios-shortcuts.png"

View file

@ -1,115 +1,61 @@
"use client"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import {
fetchConnectionsFeature,
fetchMemoriesFeature,
fetchSubscriptionStatus,
} from "@lib/queries"
import { Button } from "@repo/ui/components/button"
import { Skeleton } from "@repo/ui/components/skeleton"
import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"
import { useCustomer } from "autumn-js/react"
import {
CheckCircle,
CreditCard,
LoaderIcon,
LogOut,
User,
X,
} from "lucide-react"
import { CheckCircle, CreditCard, LoaderIcon, User, X } from "lucide-react"
import { motion } from "motion/react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import { analytics } from "@/lib/analytics"
import { $fetch } from "@lib/api"
import { useState } from "react"
export function ProfileView() {
const router = useRouter()
const { user: session } = useAuth()
const {
customer,
isLoading: isCustomerLoading,
openBillingPortal,
attach,
} = useCustomer()
const { user: session, org } = useAuth()
const organizations = org
const autumn = useCustomer()
const [isLoading, setIsLoading] = useState(false)
const [billingData, setBillingData] = useState<{
isPro: boolean
memoriesUsed: number
memoriesLimit: number
connectionsUsed: number
connectionsLimit: number
}>({
isPro: false,
memoriesUsed: 0,
memoriesLimit: 0,
connectionsUsed: 0,
connectionsLimit: 0,
})
useEffect(() => {
if (!isCustomerLoading) {
const memoriesFeature = customer?.features?.memories ?? {
usage: 0,
included_usage: 0,
}
const connectionsFeature = customer?.features?.connections ?? {
usage: 0,
included_usage: 0,
}
const {
data: status = {
consumer_pro: null,
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
setBillingData({
isPro:
customer?.products?.some(
(product) => product.id === "consumer_pro",
) ?? false,
memoriesUsed: memoriesFeature?.usage ?? 0,
memoriesLimit: memoriesFeature?.included_usage ?? 0,
connectionsUsed: connectionsFeature?.usage ?? 0,
connectionsLimit: connectionsFeature?.included_usage ?? 0,
})
}
}, [isCustomerLoading, customer])
const isPro = status.consumer_pro
const handleLogout = () => {
analytics.userSignedOut()
authClient.signOut()
router.push("/login")
}
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 {
const upgradeResult = await attach({
await autumn.attach({
productId: "consumer_pro",
successUrl: "https://app.supermemory.ai/",
})
if (
upgradeResult.statusCode === 200 &&
upgradeResult.data &&
"code" in upgradeResult.data
) {
const isProPlanActivated =
upgradeResult.data.code === "new_product_attached"
if (isProPlanActivated && session?.name && session?.email) {
try {
await $fetch("@post/emails/welcome/pro", {
body: {
email: session?.email,
firstName: session?.name,
},
})
} catch (error) {
console.error(error)
}
}
}
window.location.reload()
} catch (error) {
console.error(error)
setIsLoading(false)
}
}
// Handle manage billing
const handleManageBilling = async () => {
await openBillingPortal({
await autumn.openBillingPortal({
returnUrl: "https://app.supermemory.ai",
})
}
@ -123,13 +69,13 @@ export function ProfileView() {
initial={{ opacity: 0, scale: 0.9 }}
transition={{ type: "spring", damping: 20 }}
>
<p className="text-white/70 mb-4">
<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-white/10 hover:bg-white/20 text-white border-white/20"
className="bg-muted hover:bg-muted/80 text-foreground border-border"
size="sm"
>
<Link href="/login">Sign in</Link>
@ -143,148 +89,193 @@ export function ProfileView() {
return (
<div className="space-y-4">
{/* Profile Section */}
<div className="bg-white/5 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/10 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-white/80" />
<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">
<p className="text-white font-medium text-sm">{session?.email}</p>
<p className="text-white/60 text-xs">Logged in</p>
<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>
{isCustomerLoading ? (
<div className="bg-white/5 rounded-lg p-4 space-y-3 flex items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
Loading...
{/* 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>
) : (
<>
{/* Billing Section */}
<div className="bg-white/5 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-white/10 rounded-full flex items-center justify-center">
<CreditCard className="w-5 h-5 text-white/80" />
</div>
<div className="flex-1">
<HeadingH3Bold className="text-white text-sm">
{billingData.isPro ? "Pro Plan" : "Free Plan"}
{billingData.isPro && (
<span className="ml-2 text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full">
Active
</span>
)}
</HeadingH3Bold>
<p className="text-white/60 text-xs">
{billingData.isPro
? "Expanded memory capacity"
: "Basic plan"}
</p>
</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>
{/* Usage Stats */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-white/70">Memories</span>
<span
className={`text-sm ${billingData.memoriesUsed >= billingData.memoriesLimit ? "text-red-400" : "text-white/90"}`}
>
{billingData.memoriesUsed} / {billingData.memoriesLimit}
</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
billingData.memoriesUsed >= billingData.memoriesLimit
? "bg-red-500"
: billingData.isPro
? "bg-green-500"
: "bg-blue-500"
}`}
style={{
width: `${Math.min((billingData.memoriesUsed / billingData.memoriesLimit) * 100, 100)}%`,
}}
/>
</div>
</div>
{billingData.isPro && (
<div className="flex justify-between items-center">
<span className="text-sm text-white/70">Connections</span>
<span className="text-sm text-white/90">
{billingData.connectionsUsed} / 10
</span>
</div>
)}
{/* Billing Actions */}
<div className="pt-2">
{billingData.isPro ? (
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Button
className="w-full bg-white/10 hover:bg-white/20 text-white border-white/20"
onClick={handleManageBilling}
size="sm"
variant="outline"
>
Manage Billing
</Button>
</motion.div>
) : (
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Button
className="w-full bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white border-0"
disabled={isLoading || isCustomerLoading}
onClick={handleUpgrade}
size="sm"
>
{isLoading || isCustomerLoading ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin mr-2" />
Upgrading...
</>
) : (
"Upgrade to Pro - $15/month"
)}
</Button>
</motion.div>
)}
<div className="flex-1">
<HeadingH3Bold className="text-foreground text-sm">
{isPro ? "Pro Plan" : "Free Plan"}
{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>
)}
</HeadingH3Bold>
<p className="text-muted-foreground text-xs">
{isPro ? "Expanded memory capacity" : "Basic plan"}
</p>
</div>
</div>
{/* Plan Comparison - Only show for free users */}
{!billingData.isPro && (
<div className="bg-white/5 rounded-lg p-4 space-y-4">
<HeadingH3Bold className="text-white text-sm">
Upgrade to Pro
</HeadingH3Bold>
{/* Usage Stats */}
<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"
: isPro
? "bg-green-500"
: "bg-blue-500"
}`}
style={{
width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`,
}}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{isPro && (
<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">
{isPro ? (
<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 - $15/month
</span>
<span className="sm:hidden">Upgrade to Pro</span>
</>
)}
</Button>
)}
</div>
{!isPro && (
<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-white/5 rounded-lg border border-white/10">
<h4 className="font-medium text-white/90 mb-3 text-sm">
<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-sm text-white/70">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-sm text-white/70">
<X className="h-4 w-4 text-red-400" />
<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-sm text-white/70">
<CheckCircle className="h-4 w-4 text-green-400" />
<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>
@ -292,50 +283,43 @@ export function ProfileView() {
{/* 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-white mb-3 flex items-center gap-2 text-sm">
Pro Plan
<span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded-full">
Recommended
</span>
<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-sm text-white/90">
<li className="flex items-center gap-2 text-sm text-black/90">
<CheckCircle className="h-4 w-4 text-green-400" />
Unlimited memories
</li>
<li className="flex items-center gap-2 text-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-sm text-white/90">
<CheckCircle className="h-4 w-4 text-green-400" />
<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-white/50 text-center">
<p className="text-xs text-muted-foreground text-center leading-relaxed">
$15/month (only for first 100 users) Cancel anytime. No
questions asked.
</p>
</div>
)}
</>
</div>
)}
<Button
className="w-full bg-red-500/20 hover:bg-red-500/30 text-red-200 border-red-500/30"
onClick={handleLogout}
variant="destructive"
>
<LogOut className="w-4 h-4 mr-2" />
Sign Out
</Button>
</div>
)
}
}

View file

@ -1,2 +1,11 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
.sm-tweet-theme .react-tweet-theme {
--tweet-container-margin: 0px;
}
.sm-tweet-theme .tweet-header-module__A9EVQG__authorFollow {
display: none;
}

View file

@ -2,9 +2,6 @@ import posthog from "posthog-js"
export const analytics = {
userSignedOut: () => posthog.capture("user_signed_out"),
tourStarted: () => posthog.capture("tour_started"),
tourCompleted: () => posthog.capture("tour_completed"),
tourSkipped: () => posthog.capture("tour_skipped"),
memoryAdded: (props: {
type: "note" | "link" | "file"

View file

@ -1,3 +1,4 @@
import { MCPIcon } from '@/components/menu';
import { colors } from '@repo/ui/memory-graph/constants';
import {
GoogleDocs,
@ -14,12 +15,16 @@ import {
} from '@ui/assets/icons';
import { FileText } from 'lucide-react';
export const getDocumentIcon = (type: string, className: string) => {
export const getDocumentIcon = (type: string, className: string, source?: string) => {
const iconProps = {
className,
style: { color: colors.text.muted },
};
if(source === "mcp") {
return <MCPIcon {...iconProps} />;
}
switch (type) {
case 'google_doc':
return <GoogleDocs {...iconProps} />;

View file

@ -0,0 +1,8 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View file

@ -1,23 +0,0 @@
// Tour step IDs - these should match the IDs added to elements in your app
export const TOUR_STEP_IDS = {
LOGO: "tour-logo",
MENU_BUTTON: "tour-menu-button",
VIEW_TOGGLE: "tour-view-toggle",
MEMORY_GRAPH: "tour-memory-graph",
MEMORY_LIST: "tour-memory-list",
FLOATING_CHAT: "tour-floating-chat",
ADD_MEMORY: "tour-add-memory",
SPACES_DROPDOWN: "tour-spaces-dropdown",
SETTINGS: "tour-settings",
MENU_CONNECTIONS: "tour-connections",
// Menu items
MENU_ADD_MEMORY: "tour-menu-add-memory",
MENU_PROJECTS: "tour-menu-projects",
MENU_MCP: "tour-menu-mcp",
MENU_BILLING: "tour-menu-billing",
// Legend
LEGEND: "tour-legend",
} as const;
// Tour storage key for localStorage
export const TOUR_STORAGE_KEY = "supermemory-tour-completed";

View file

@ -49,27 +49,32 @@
"@tanstack/react-query-devtools": "^5.84.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@types/dompurify": "^3.2.0",
"ai": "5.0.0-beta.24",
"autumn-js": "0.0.116",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.2.7",
"dotenv": "^16.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12",
"is-hotkey": "^0.2.0",
"isbot": "^5.1.28",
"lucide-react": "^0.525.0",
"masonic": "^4.1.0",
"motion": "^12.19.2",
"next": "15.3.0",
"next-themes": "^0.4.6",
"nuqs": "^2.4.3",
"nuqs": "^2.5.2",
"posthog-js": "^1.257.0",
"random-word-slugs": "^0.1.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-error-boundary": "^6.0.0",
"react-tweet": "^3.2.2",
"recharts": "2",
"shadcn-dropzone": "^0.2.1",
"slate": "^0.118.0",

View file

@ -0,0 +1,8 @@
<svg viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg">
<path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da"/>
<path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47"/>
<path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#ea4335"/>
<path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d"/>
<path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc"/>
<path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#ffba00"/>
</svg>

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -0,0 +1,4 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.017 4.313l55.333 -4.087c6.797 -0.583 8.543 -0.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277 -1.553 6.807 -6.99 7.193L24.467 99.967c-4.08 0.193 -6.023 -0.39 -8.16 -3.113L3.3 79.94c-2.333 -3.113 -3.3 -5.443 -3.3 -8.167V11.113c0 -3.497 1.553 -6.413 6.017 -6.8z" fill="#fff"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.35 0.227l-55.333 4.087C1.553 4.7 0 7.617 0 11.113v60.66c0 2.723 0.967 5.053 3.3 8.167l13.007 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257 -3.89c5.433 -0.387 6.99 -2.917 6.99 -7.193V20.64c0 -2.21 -0.873 -2.847 -3.443 -4.733L74.167 3.143c-4.273 -3.107 -6.02 -3.5 -12.817 -2.917zM25.92 19.523c-5.247 0.353 -6.437 0.433 -9.417 -1.99L8.927 11.507c-0.77 -0.78 -0.383 -1.753 1.557 -1.947l53.193 -3.887c4.467 -0.39 6.793 1.167 8.54 2.527l9.123 6.61c0.39 0.197 1.36 1.36 0.193 1.36l-54.933 3.307 -0.68 0.047zM19.803 88.3V30.367c0 -2.53 0.777 -3.697 3.103 -3.893L86 22.78c2.14 -0.193 3.107 1.167 3.107 3.693v57.547c0 2.53 -0.39 4.67 -3.883 4.863l-60.377 3.5c-3.493 0.193 -5.043 -0.97 -5.043 -4.083zm59.6 -54.827c0.387 1.75 0 3.5 -1.75 3.7l-2.91 0.577v42.773c-2.527 1.36 -4.853 2.137 -6.797 2.137 -3.107 0 -3.883 -0.973 -6.21 -3.887l-19.03 -29.94v28.967l6.02 1.363s0 3.5 -4.857 3.5l-13.39 0.777c-0.39 -0.78 0 -2.723 1.357 -3.11l3.497 -0.97v-38.3L30.48 40.667c-0.39 -1.75 0.58 -4.277 3.3 -4.473l14.367 -0.967 19.8 30.327v-26.83l-5.047 -0.58c-0.39 -2.143 1.163 -3.7 3.103 -3.89l13.4 -0.78z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 5.5 32 20.5"><title>OfficeCore10_32x_24x_20x_16x_01-22-2019</title><g id="STYLE_COLOR"><path d="M12.20245,11.19292l.00031-.0011,6.71765,4.02379,4.00293-1.68451.00018.00068A6.4768,6.4768,0,0,1,25.5,13c.14764,0,.29358.0067.43878.01639a10.00075,10.00075,0,0,0-18.041-3.01381C7.932,10.00215,7.9657,10,8,10A7.96073,7.96073,0,0,1,12.20245,11.19292Z" fill="#0364b8"/><path d="M12.20276,11.19182l-.00031.0011A7.96073,7.96073,0,0,0,8,10c-.0343,0-.06805.00215-.10223.00258A7.99676,7.99676,0,0,0,1.43732,22.57277l5.924-2.49292,2.63342-1.10819,5.86353-2.46746,3.06213-1.28859Z" fill="#0078d4"/><path d="M25.93878,13.01639C25.79358,13.0067,25.64764,13,25.5,13a6.4768,6.4768,0,0,0-2.57648.53178l-.00018-.00068-4.00293,1.68451,1.16077.69528L23.88611,18.19l1.66009.99438,5.67633,3.40007a6.5002,6.5002,0,0,0-5.28375-9.56805Z" fill="#1490df"/><path d="M25.5462,19.18437,23.88611,18.19l-3.80493-2.2791-1.16077-.69528L15.85828,16.5042,9.99475,18.97166,7.36133,20.07985l-5.924,2.49292A7.98889,7.98889,0,0,0,8,26H25.5a6.49837,6.49837,0,0,0,5.72253-3.41556Z" fill="#28a8ea"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 MiB

View file

@ -1,6 +1,7 @@
import type { UIMessage } from "@ai-sdk/react"
import { create } from "zustand"
import { persist } from "zustand/middleware"
import { useCallback } from "react"
/**
* Deep equality check for UIMessage arrays to prevent unnecessary state updates
@ -206,39 +207,48 @@ export function usePersistentChat() {
const currentChatId = projectState?.currentChatId ?? null
function setCurrentChatId(chatId: string | null): void {
setCurrentChatIdRaw(projectId, chatId)
}
const setCurrentChatId = useCallback(
(chatId: string | null): void => {
setCurrentChatIdRaw(projectId, chatId)
},
[projectId, setCurrentChatIdRaw],
)
function setConversation(chatId: string, messages: UIMessage[]): void {
setConversationRaw(projectId, chatId, messages)
}
const setConversation = useCallback(
(chatId: string, messages: UIMessage[]): void => {
setConversationRaw(projectId, chatId, messages)
},
[projectId, setConversationRaw],
)
function deleteConversation(chatId: string): void {
deleteConversationRaw(projectId, chatId)
}
const deleteConversation = useCallback(
(chatId: string): void => {
deleteConversationRaw(projectId, chatId)
},
[projectId, deleteConversationRaw],
)
function setConversationTitle(
chatId: string,
title: string | undefined,
): void {
setConversationTitleRaw(projectId, chatId, title)
}
const setConversationTitle = useCallback(
(chatId: string, title: string | undefined): void => {
setConversationTitleRaw(projectId, chatId, title)
},
[projectId, setConversationTitleRaw],
)
function getCurrentConversation(): UIMessage[] | undefined {
const getCurrentConversation = useCallback((): UIMessage[] | undefined => {
const convs = projectState?.conversations ?? {}
const id = currentChatId
if (!id) return undefined
return convs[id]?.messages
}
}, [projectState?.conversations, currentChatId])
function getCurrentChat(): ConversationSummary | undefined {
const getCurrentChat = useCallback((): ConversationSummary | undefined => {
const id = currentChatId
if (!id) return undefined
const rec = projectState?.conversations?.[id]
if (!rec) return undefined
return { id, title: rec.title, lastUpdated: rec.lastUpdated }
}
}, [currentChatId, projectState?.conversations])
return {
conversations,

View file

@ -1,9 +1,9 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface ProjectState {
selectedProject: string;
setSelectedProject: (projectId: string) => void;
selectedProject: string
setSelectedProject: (projectId: string) => void
}
export const useProjectStore = create<ProjectState>()(
@ -16,14 +16,14 @@ export const useProjectStore = create<ProjectState>()(
name: "selectedProject",
},
),
);
)
interface MemoryGraphState {
positionX: number;
positionY: number;
setPositionX: (x: number) => void;
setPositionY: (y: number) => void;
setPosition: (x: number, y: number) => void;
positionX: number
positionY: number
setPositionX: (x: number) => void
setPositionY: (y: number) => void
setPosition: (x: number, y: number) => void
}
export const useMemoryGraphStore = create<MemoryGraphState>()((set) => ({
@ -32,34 +32,34 @@ export const useMemoryGraphStore = create<MemoryGraphState>()((set) => ({
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;
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 }),
}));
}))
export function useProject() {
const selectedProject = useProjectStore((state) => state.selectedProject);
const selectedProject = useProjectStore((state) => state.selectedProject)
const setSelectedProject = useProjectStore(
(state) => state.setSelectedProject,
);
return { selectedProject, 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);
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,
@ -67,14 +67,33 @@ export function useMemoryGraphPosition() {
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 };
const isOpen = useChatStore((state) => state.isOpen)
const setIsOpen = useChatStore((state) => state.setIsOpen)
const toggleChat = useChatStore((state) => state.toggleChat)
return { isOpen, setIsOpen, toggleChat }
}
export { usePersistentChat, usePersistentChatStore } from "./chat";
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"

View file

@ -3,8 +3,8 @@
"assist": {
"actions": {
"source": {
"organizeImports": "on",
"useSortedAttributes": "on",
"organizeImports": "off",
"useSortedAttributes": "off",
"useSortedKeys": "off"
}
},

6787
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,33 @@
"use client"
import { useCallback, useEffect, useState } from "react"
const ONBOARDING_STORAGE_KEY = "supermemory_onboarding_completed"
export function useOnboardingStorage() {
const [isOnboardingCompleted, setIsOnboardingCompleted] = useState<
boolean | null
>(null)
useEffect(() => {
const completed = localStorage.getItem(ONBOARDING_STORAGE_KEY)
setIsOnboardingCompleted(completed === "true")
}, [])
const markOnboardingCompleted = useCallback(() => {
localStorage.setItem(ONBOARDING_STORAGE_KEY, "true")
setIsOnboardingCompleted(true)
}, [])
const shouldShowOnboarding = useCallback(() => {
if (isOnboardingCompleted === null) return null // Still loading
return !isOnboardingCompleted
}, [isOnboardingCompleted])
return {
isOnboardingCompleted,
markOnboardingCompleted,
shouldShowOnboarding,
isLoading: isOnboardingCompleted === null,
}
}

View file

@ -187,6 +187,11 @@ export const apiSchema = createSchema({
}),
},
// MCP operations
"@get/mcp/has-login": {
output: z.object({ previousLogin: z.boolean() }),
},
// Waitlist operations
"@get/waitlist/status": {
output: WaitlistStatusResponseSchema,

View file

@ -10,13 +10,11 @@ type DocumentWithMemories = DocumentsResponse["documents"][0]
export const fetchSubscriptionStatus = (
autumn: ReturnType<typeof useCustomer>,
isEnabled: boolean,
) =>
useQuery({
queryFn: async () => {
const allPlans = [
"pro",
"memory_starter",
"memory_growth",
"consumer_pro",
]
const statusMap: Record<string, boolean | null> = {}
@ -24,7 +22,7 @@ export const fetchSubscriptionStatus = (
await Promise.all(
allPlans.map(async (plan) => {
try {
const res = await autumn.check({
const res = autumn.check({
productId: plan,
})
statusMap[plan] = res.data?.allowed ?? false
@ -40,31 +38,35 @@ export const fetchSubscriptionStatus = (
queryKey: ["subscription-status"],
refetchInterval: 5000, // Refetch every 5 seconds
staleTime: 4000, // Consider data stale after 4 seconds
enabled: isEnabled,
})
// Feature checks
export const fetchMemoriesFeature = (autumn: ReturnType<typeof useCustomer>) =>
export const fetchMemoriesFeature = (autumn: ReturnType<typeof useCustomer>, isEnabled: boolean) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ featureId: "memories" })
const res = autumn.check({ featureId: "memories" })
return res.data
},
queryKey: ["autumn-feature", "memories"],
staleTime: 30 * 1000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
enabled: isEnabled,
})
export const fetchConnectionsFeature = (
autumn: ReturnType<typeof useCustomer>,
isEnabled: boolean,
) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ featureId: "connections" })
const res = autumn.check({ featureId: "connections" })
return res.data
},
queryKey: ["autumn-feature", "connections"],
staleTime: 30 * 1000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
enabled: isEnabled,
})
// Product checks
@ -73,7 +75,7 @@ export const fetchConsumerProProduct = (
) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ productId: "consumer_pro" })
const res = autumn.check({ productId: "consumer_pro" })
return res.data
},
queryKey: ["autumn-product", "consumer_pro"],
@ -84,7 +86,7 @@ export const fetchConsumerProProduct = (
export const fetchProProduct = (autumn: ReturnType<typeof useCustomer>) =>
useQuery({
queryFn: async () => {
const res = await autumn.check({ productId: "pro" })
const res = autumn.check({ productId: "pro" })
return res.data
},
queryKey: ["autumn-product", "pro"],

File diff suppressed because one or more lines are too long

View file

@ -11,7 +11,7 @@ function Avatar({
return (
<AvatarPrimitive.Root
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
"relative flex size-8 shrink-0 overflow-hidden rounded-full hover:cursor-pointer",
className,
)}
data-slot="avatar"

View file

@ -19,12 +19,14 @@ const buttonVariants = cva(
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
settingsNav: "cursor-pointer rounded-sm bg-transparent",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
settingsNav: "h-8 gap-0 px-0 py-0",
},
},
defaultVariants: {

View file

@ -18,7 +18,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
className,
)}
data-slot="card-header"

View file

@ -181,8 +181,8 @@ function CarouselPrevious({
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
? "top-1/2 left-2 -translate-y-1/2"
: "top-2 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
data-slot="carousel-previous"
@ -211,8 +211,8 @@ function CarouselNext({
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
? "top-1/2 right-2 -translate-y-1/2"
: "bottom-2 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
data-slot="carousel-next"

View file

@ -0,0 +1,45 @@
interface PlusPatternBackgroundProps {
plusSize?: number;
plusColor?: string;
backgroundColor?: string;
className?: string;
style?: React.CSSProperties;
fade?: boolean;
[key: string]: any;
}
export const BackgroundPlus: React.FC<PlusPatternBackgroundProps> = ({
plusColor = "#6b6b6b",
backgroundColor = "transparent",
plusSize = 60,
className,
fade = true,
style,
...props
}) => {
const encodedPlusColor = encodeURIComponent(plusColor);
const maskStyle: React.CSSProperties = fade
? {
maskImage: "radial-gradient(circle, white 10%, transparent 90%)",
WebkitMaskImage: "radial-gradient(circle, white 10%, transparent 90%)",
}
: {};
const backgroundStyle: React.CSSProperties = {
backgroundColor,
backgroundImage: `url("data:image/svg+xml,%3Csvg width='${plusSize}' height='${plusSize}' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='${encodedPlusColor}' fill-opacity='0.2'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
...maskStyle,
...style,
};
return (
<div
className={`absolute inset-0 h-full w-full ${className}`}
style={backgroundStyle}
{...props}
/>
);
};
export default BackgroundPlus;

View file

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View file

@ -40,7 +40,7 @@ function TabsTrigger({
return (
<TabsPrimitive.Trigger
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-2 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-[state=active]:bg-accent dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-2 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-md [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
data-slot="tabs-trigger"

View file

@ -4,160 +4,167 @@
@source "../../apps/**/*.tsx";
@source "../../packages/**/*.tsx";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--color-sm-shark-alt);
--color-background: var(--color-sm-shark-alt);
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans:
"Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-sans:
--font-serif:
"Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Design System */
--color-sm-alto: #dedede;
--color-sm-black: #000000;
--color-sm-boulder: #777777;
--color-sm-bunker: #090b0e;
--color-sm-charcoal: #2e2e2e;
--color-sm-dodger-blue: #267ffa;
--color-sm-chelsea-gem: #aa5d00;
--color-sm-dove-gray: #696969;
--color-sm-emperor: #545454;
--color-sm-gallery: #efefef;
--color-sm-gray: #888888;
--color-sm-heliotrope: #c472fb;
--color-sm-japanese-laurel: #008000;
--color-sm-mercury: #e1e1e1;
--color-sm-mine-shaft: #383838;
--color-sm-niagara: #0aa49f;
--color-sm-picton-blue: #3f96e6;
--color-sm-red-orange: #fb2c36;
--color-sm-seance: #7928a1;
--color-sm-selective-yellow: #ffb900;
--color-sm-shark: #1c2026;
--color-sm-shark-alt: #1c2026;
--color-sm-light-shark: #2b2e33;
--color-sm-silver-chalice: #b0b0b0;
--color-sm-tundora: #4d4d4d;
--color-sm-white: #ffffff;
--color-sm-white-alt: #fefefe;
--color-sm-wild-watermelon: #ff4d8d;
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--radius: 0.75rem;
--background: oklch(0.9846 0.0017 247.8389);
--foreground: oklch(0.2744 0.0073 285.9081);
--card: oklch(0.9851 0 0);
--card-foreground: oklch(0.2744 0.0073 285.9081);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--popover-foreground: oklch(0.2744 0.0073 285.9081);
--primary: oklch(0.614 0.2014 258.1073);
--primary-foreground: oklch(0.9851 0 0);
--secondary: oklch(0.9378 0.0296 262.5395);
--secondary-foreground: oklch(0.614 0.2014 258.1073);
--muted: oklch(0.9846 0.0017 247.8389);
--muted-foreground: oklch(0.5693 0 0);
--accent: oklch(0.92 0.0054 286.2936);
--accent-foreground: oklch(0.2744 0.0073 285.9081);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.9366 0.0017 247.8401);
--input: oklch(0.9702 0 0);
--ring: oklch(1 0 0);
--chart-1: oklch(0.7197 0.1448 266.6983);
--chart-2: oklch(0.8101 0.1488 148.2032);
--chart-3: oklch(0.7507 0.1353 58.4182);
--chart-4: oklch(0.7253 0.1752 349.7607);
--chart-5: oklch(0.7535 0.139 232.6615);
--sidebar: oklch(0.9851 0 0);
--sidebar-foreground: oklch(0.2744 0.0073 285.9081);
--sidebar-primary: oklch(0.9851 0 0);
--sidebar-primary-foreground: oklch(0.2744 0.0073 285.9081);
--sidebar-accent: oklch(0.92 0.0054 286.2936);
--sidebar-accent-foreground: oklch(0.2744 0.0073 285.9081);
--sidebar-border: oklch(0.9366 0.0017 247.8401);
--sidebar-ring: oklch(0.9889 0.0053 17.2475);
--destructive-foreground: oklch(0.967 0.0029 264.5419);
--font-sans: Space Grotesk, ui-sans-serif, sans-serif, system-ui;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, ui-monospace, monospace;
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.04;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-offset-x: 0;
--shadow-offset-y: 1px;
--letter-spacing: -0.01em;
--spacing: 0.24rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.02);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.02);
--shadow-sm:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow-md:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 2px 4px -1px hsl(0 0% 0% / 0.04);
--shadow-lg:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 4px 6px -1px hsl(0 0% 0% / 0.04);
--shadow-xl:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 8px 10px -1px hsl(0 0% 0% / 0.04);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1);
--tracking-normal: -0.01em;
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--background: oklch(0.1487 0.0073 258.0408);
--foreground: oklch(0.967 0.0029 264.5419);
--card: oklch(0.1487 0.0073 258.0408);
--card-foreground: oklch(0.967 0.0029 264.5419);
--popover: oklch(0.2264 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: var(--color-sm-black);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
--primary: oklch(0.614 0.2014 258.1073);
--primary-foreground: oklch(0.967 0.0029 264.5419);
--secondary: oklch(0.967 0.0029 264.5419);
--secondary-foreground: oklch(0.1487 0.0073 258.0408);
--muted: oklch(0.1482 0.0055 247.8035);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.3012 0 0);
--accent-foreground: oklch(0.967 0.0029 264.5419);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.3715 0 0);
--input: oklch(0.262 0.0074 285.8781);
--ring: oklch(0 0 0);
--chart-1: oklch(0.7197 0.1448 266.6983);
--chart-2: oklch(0.8101 0.1488 148.2032);
--chart-3: oklch(0.7507 0.1353 58.4182);
--chart-4: oklch(0.7253 0.1752 349.7607);
--chart-5: oklch(0.7535 0.139 232.6615);
--sidebar: oklch(0.2264 0 0);
--sidebar-foreground: oklch(0.967 0.0029 264.5419);
--sidebar-primary: oklch(0.2264 0 0);
--sidebar-primary-foreground: oklch(0.967 0.0029 264.5419);
--sidebar-accent: oklch(0.3012 0 0);
--sidebar-accent-foreground: oklch(0.967 0.0029 264.5419);
--sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0 0 0);
--destructive-foreground: oklch(0.967 0.0029 264.5419);
--radius: 0.75rem;
--font-sans: Space Grotesk, ui-sans-serif, sans-serif, system-ui;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, ui-monospace, monospace;
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.04;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-offset-x: 0;
--shadow-offset-y: 1px;
--letter-spacing: -0.01em;
--spacing: 0.24rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.02);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.02);
--shadow-sm:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow-md:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 2px 4px -1px hsl(0 0% 0% / 0.04);
--shadow-lg:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 4px 6px -1px hsl(0 0% 0% / 0.04);
--shadow-xl:
0 1px 3px 0px hsl(0 0% 0% / 0.04), 0 8px 10px -1px hsl(0 0% 0% / 0.04);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1);
}
/* Custom Scrollbar Styles */
@ -223,3 +230,31 @@
.memory-sheet-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
}
/* Memory Dialog Scrollbar Styles */
.memory-dialog-scroll::-webkit-scrollbar {
width: 8px;
}
.memory-dialog-scroll::-webkit-scrollbar-track {
background: hsl(var(--muted) / 0.05);
}
.memory-dialog-scroll::-webkit-scrollbar-thumb {
background: hsl(var(--border) / 0.5);
border-radius: 4px;
}
.memory-dialog-scroll::-webkit-scrollbar-thumb:hover {
background: hsl(var(--border) / 0.7);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}

View file

@ -129,17 +129,19 @@ export const MemoryGraph = ({
containerSize.width > 0 &&
containerSize.height > 0
) {
// For consumer variant, auto-fit to show all content
if (variant === "consumer") {
// Auto-fit to show all content for both variants
// Add a small delay to ensure the canvas is fully initialized
const timer = setTimeout(() => {
autoFitToViewport(nodes, containerSize.width, containerSize.height);
hasAutoFittedRef.current = true;
}
}, 100);
return () => clearTimeout(timer);
}
}, [
nodes,
containerSize.width,
containerSize.height,
variant,
autoFitToViewport,
]);
@ -175,16 +177,36 @@ export const MemoryGraph = ({
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
setContainerSize({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight,
const newWidth = containerRef.current.clientWidth;
const newHeight = containerRef.current.clientHeight;
// Only update if size actually changed and is valid
setContainerSize((prev) => {
if (prev.width !== newWidth || prev.height !== newHeight) {
return { width: newWidth, height: newHeight };
}
return prev;
});
}
};
updateSize();
// Use a slight delay to ensure DOM is fully rendered
const timer = setTimeout(updateSize, 0);
updateSize(); // Also call immediately
window.addEventListener("resize", updateSize);
return () => window.removeEventListener("resize", updateSize);
// Use ResizeObserver for more accurate container size detection
const resizeObserver = new ResizeObserver(updateSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
clearTimeout(timer);
window.removeEventListener("resize", updateSize);
resizeObserver.disconnect();
};
}, []);
// Enhanced node drag start that includes nodes data
@ -390,7 +412,7 @@ export const MemoryGraph = ({
WebkitUserSelect: "none",
}}
>
{containerSize.width > 0 && (
{(containerSize.width > 0 && containerSize.height > 0) && (
<GraphCanvas
draggingNodeId={draggingNodeId}
edges={edges}

View file

@ -12,6 +12,7 @@
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",