mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat: layout design with theme improvements (#443)
feat: layout design with theme improvements new improvements on light mode layout chore: settings page with graph header and memories list
This commit is contained in:
parent
60794f63e2
commit
b148296f50
64 changed files with 4065 additions and 10228 deletions
39
apps/web/app/(navigation)/chat/[id]/page.tsx
Normal file
39
apps/web/app/(navigation)/chat/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
apps/web/app/(navigation)/layout.tsx
Normal file
57
apps/web/app/(navigation)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
apps/web/app/(navigation)/page.tsx
Normal file
81
apps/web/app/(navigation)/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
apps/web/app/(navigation)/settings/billing/page.tsx
Normal file
12
apps/web/app/(navigation)/settings/billing/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
apps/web/app/(navigation)/settings/integrations/page.tsx
Normal file
10
apps/web/app/(navigation)/settings/integrations/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
apps/web/app/(navigation)/settings/layout.tsx
Normal file
52
apps/web/app/(navigation)/settings/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
apps/web/app/(navigation)/settings/page.tsx
Normal file
12
apps/web/app/(navigation)/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
apps/web/app/(navigation)/settings/support/page.tsx
Normal file
125
apps/web/app/(navigation)/settings/support/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Metadata } from "next"
|
||||
import { Inter, JetBrains_Mono, Instrument_Serif } from "next/font/google"
|
||||
import { Space_Grotesk } from "next/font/google"
|
||||
import "../globals.css"
|
||||
import "@ui/globals.css"
|
||||
import { AuthProvider } from "@lib/auth-context"
|
||||
|
|
@ -11,25 +11,15 @@ 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"
|
||||
|
||||
const sans = Inter({
|
||||
const font = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
})
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
})
|
||||
|
||||
const serif = Instrument_Serif({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-serif",
|
||||
weight: ["400"],
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://app.supermemory.ai"),
|
||||
description: "Your memories, wherever you are",
|
||||
|
|
@ -42,33 +32,39 @@ export default function RootLayout({
|
|||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html className="dark bg-sm-black" lang="en">
|
||||
<body
|
||||
className={`${sans.variable} ${mono.variable} ${serif.variable} antialiased bg-[#0f1419] overflow-x-hidden`}
|
||||
>
|
||||
<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>
|
||||
<NuqsAdapter>
|
||||
<Suspense>{children}</Suspense>
|
||||
<Toaster richColors theme="dark" />
|
||||
</NuqsAdapter>
|
||||
</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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,53 +1,62 @@
|
|||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import { TextEffect } from '@/components/text-effect';
|
||||
"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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
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'
|
||||
per='char'
|
||||
variants={blurSlideVariants}
|
||||
trigger={trigger}
|
||||
delay={delay}
|
||||
>
|
||||
{children}
|
||||
</TextEffect>
|
||||
);
|
||||
return (
|
||||
<TextEffect
|
||||
className="inline-flex font-medium"
|
||||
style={{ letterSpacing: "-3px" }}
|
||||
per="char"
|
||||
variants={blurSlideVariants}
|
||||
trigger={trigger}
|
||||
delay={delay}
|
||||
>
|
||||
{children}
|
||||
</TextEffect>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,213 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { motion, type Transition } from "framer-motion";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { useOnboarding } from "./onboarding-context";
|
||||
import { $fetch } from "@lib/api";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ConnectionResponseSchema } from "@repo/validation/api";
|
||||
import type { z } from "zod";
|
||||
import { Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { useProject } from "@/stores";
|
||||
import { NavMenu } from "./nav-menu";
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>;
|
||||
|
||||
const CONNECTORS = {
|
||||
"google-drive": {
|
||||
title: "Google Drive",
|
||||
description: "Supermemory can use the documents and files in your Google Drive to better understand and assist you.",
|
||||
iconSrc: "/images/gdrive.svg",
|
||||
},
|
||||
notion: {
|
||||
title: "Notion",
|
||||
description: "Help Supermemory understand how you organize your life and what you have going on by connecting your Notion account.",
|
||||
iconSrc: "/images/notion.svg",
|
||||
},
|
||||
onedrive: {
|
||||
title: "OneDrive",
|
||||
description: "By integrating with OneDrive, Supermemory can better understand both your previous and your current work.",
|
||||
iconSrc: "/images/onedrive.svg",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ConnectorProvider = keyof typeof CONNECTORS;
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.15, delayChildren: 0.1 } satisfies Transition,
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { type: "spring", stiffness: 500, damping: 35, mass: 0.8 } satisfies Transition,
|
||||
},
|
||||
};
|
||||
|
||||
function ConnectionCard({
|
||||
title,
|
||||
description,
|
||||
iconSrc,
|
||||
isConnected = false,
|
||||
onConnect,
|
||||
isConnecting = false
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
iconSrc: string;
|
||||
isConnected?: boolean;
|
||||
onConnect?: () => void;
|
||||
isConnecting?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center max-sm:items-start max-sm:gap-3 max-sm:flex-col shadow-lg bg-white rounded-xl py-4 px-6 gap-6 border border-zinc-200">
|
||||
<img src={iconSrc} alt={title} className="size-10 max-sm:hidden" />
|
||||
<div className="flex flex-col gap-0.5 max-sm:gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={iconSrc} alt={title} className="size-5 sm:hidden" />
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
</div>
|
||||
<p className="text-zinc-600 text-sm">{description}</p>
|
||||
</div>
|
||||
<div className="sm:ml-auto max-sm:w-full">
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="max-sm:w-full border border-green-200 bg-green-50 text-green-700 hover:bg-green-100 cursor-default"
|
||||
disabled
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Connected
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="max-sm:w-full border border-zinc-200! hover:text-zinc-900 text-zinc-900 cursor-pointer"
|
||||
onClick={onConnect}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionsForm() {
|
||||
const { totalSteps, nextStep, getStepNumberFor } = useOnboarding();
|
||||
const { selectedProject } = useProject();
|
||||
|
||||
const { data: connections = [] } = useQuery({
|
||||
queryKey: ["connections"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/connections/list", {
|
||||
body: {
|
||||
containerTags: [],
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to load connections",
|
||||
);
|
||||
}
|
||||
|
||||
return response.data as Connection[];
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
|
||||
const addConnectionMutation = useMutation({
|
||||
mutationFn: async (provider: ConnectorProvider) => {
|
||||
const response = await $fetch("@post/connections/:provider", {
|
||||
params: { provider },
|
||||
body: {
|
||||
redirectUrl: window.location.href,
|
||||
containerTags: [selectedProject],
|
||||
},
|
||||
});
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: its fine
|
||||
if ("data" in response && !("error" in response.data!)) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw new Error(response.error?.message || "Failed to connect");
|
||||
},
|
||||
onSuccess: (data, provider) => {
|
||||
analytics.connectionAdded(provider);
|
||||
analytics.connectionAuthStarted();
|
||||
if (data?.authLink) {
|
||||
window.location.href = data.authLink;
|
||||
}
|
||||
},
|
||||
onError: (error, provider) => {
|
||||
analytics.connectionAuthFailed();
|
||||
toast.error(`Failed to connect ${provider}`, {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function isConnectorConnected(provider: ConnectorProvider): boolean {
|
||||
return connections.some(connection => connection.provider === provider);
|
||||
}
|
||||
|
||||
function handleConnect(provider: ConnectorProvider) {
|
||||
addConnectionMutation.mutate(provider);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="space-y-4">
|
||||
<NavMenu>
|
||||
<p className="text-base text-zinc-600">
|
||||
Step {getStepNumberFor("connections")} of {totalSteps}
|
||||
</p>
|
||||
</NavMenu>
|
||||
<h1 className="max-sm:text-4xl">Connect your accounts</h1>
|
||||
<p className="text-zinc-600 text-2xl max-sm:text-lg">
|
||||
Help Supermemory get to know you and your documents better
|
||||
{/* The more context you provide, the better Supermemory becomes */}
|
||||
{/* Supermemory understands your needs and goals better with more context */}
|
||||
{/* Supermemory understands you better when it integrates with your apps */}
|
||||
</p>
|
||||
</div>
|
||||
<motion.div className="font-sans text-base mt-8 font-normal tracking-normal flex flex-col gap-4 max-w-prose" initial="hidden" animate="visible" variants={containerVariants}>
|
||||
{Object.entries(CONNECTORS).map(([provider, config]) => {
|
||||
const providerKey = provider as ConnectorProvider;
|
||||
const isConnected = isConnectorConnected(providerKey);
|
||||
const isConnecting = addConnectionMutation.isPending && addConnectionMutation.variables === providerKey;
|
||||
|
||||
return (
|
||||
<motion.div key={provider} variants={itemVariants}>
|
||||
<ConnectionCard
|
||||
title={config.title}
|
||||
description={config.description}
|
||||
iconSrc={config.iconSrc}
|
||||
isConnected={isConnected}
|
||||
onConnect={() => handleConnect(providerKey)}
|
||||
isConnecting={isConnecting}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={nextStep}>
|
||||
Skip For Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -636,7 +636,9 @@ export function ExtensionForm() {
|
|||
Step {getStepNumberFor("extension")} of {totalSteps}
|
||||
</p>
|
||||
</NavMenu>
|
||||
<h1 className="text-white">Install the Chrome extension</h1>
|
||||
<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
|
||||
|
|
@ -706,7 +708,7 @@ export function ExtensionForm() {
|
|||
<Button
|
||||
variant="link"
|
||||
size="lg"
|
||||
className="text-white/80 font-medium! text-lg underline w-fit px-0! cursor-pointer"
|
||||
className="text-black/40 hover:text-black font-medium! text-lg underline w-fit px-0! cursor-pointer"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Continue
|
||||
|
|
|
|||
|
|
@ -67,19 +67,19 @@ function FloatingOrb({ size, initialX, initialY, duration, delay, revealDelay, s
|
|||
return {
|
||||
x: {
|
||||
duration: shouldReveal ? duration : 0,
|
||||
repeat: shouldReveal ? Infinity : 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 ? Infinity : 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 ? Infinity : 0,
|
||||
repeat: shouldReveal ? Number.POSITIVE_INFINITY : 0,
|
||||
ease: shouldReveal ? [0.42, 0, 0.58, 1] : [0, 0, 0.58, 1],
|
||||
delay: shouldReveal ? delay + revealDelay : revealDelay,
|
||||
},
|
||||
|
|
@ -163,7 +163,8 @@ export function FloatingOrbs() {
|
|||
|
||||
// Generate orb configurations positioned along edges
|
||||
const newOrbs = Array.from({ length: 8 }, (_, i) => {
|
||||
let x, y;
|
||||
let x: number;
|
||||
let y: number;
|
||||
const zone = i % 4; // Rotate through 4 zones: top, right, bottom, left
|
||||
|
||||
switch (zone) {
|
||||
|
|
|
|||
|
|
@ -1,114 +1,85 @@
|
|||
"use client";
|
||||
"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 { ArrowRightIcon } from "lucide-react";
|
||||
import { useOnboarding } from "./onboarding-context";
|
||||
import { useIsMobile } from "@hooks/use-mobile";
|
||||
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();
|
||||
const isMobile = useIsMobile();
|
||||
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"
|
||||
layout
|
||||
transition={{
|
||||
layout: { duration: 0.8, ease: "anticipate" }
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{triggers.first && (
|
||||
<motion.div
|
||||
key="first"
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
opacity: { duration: 0.3 },
|
||||
layout: { duration: 0.8, ease: "easeInOut" }
|
||||
}}
|
||||
>
|
||||
{isMobile ? (
|
||||
<div className="flex flex-col">
|
||||
<AnimatedText trigger={triggers.first} delay={0}>
|
||||
Still looking for your
|
||||
</AnimatedText>
|
||||
<AnimatedText trigger={triggers.first} delay={0.1}>
|
||||
other half?
|
||||
</AnimatedText>
|
||||
</div>
|
||||
) : (
|
||||
<AnimatedText trigger={triggers.first} delay={0}>
|
||||
Still looking for your other half?
|
||||
</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}>
|
||||
Don't worry.
|
||||
</AnimatedText>
|
||||
</motion.div>
|
||||
)}
|
||||
{triggers.third && (
|
||||
<motion.div
|
||||
key="third"
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
opacity: { duration: 0.3 },
|
||||
layout: { duration: 0.8, ease: "easeInOut" }
|
||||
}}
|
||||
>
|
||||
<AnimatedText trigger={triggers.third} delay={0.4}>
|
||||
It's right here.
|
||||
{/* It's right in front of you. */}
|
||||
{/* You're looking at it. */}
|
||||
</AnimatedText>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
key="fourth"
|
||||
className="absolute -bottom-16 left-0"
|
||||
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={"link"}
|
||||
size={"lg"}
|
||||
className={cn("text-white/60 flex items-center gap-2 hover:text-white/90 text-2xl underline group font-normal w-fit px-0! cursor-pointer", !triggers.fourth && "pointer-events-none")}
|
||||
style={{
|
||||
transform: triggers.fourth ? "scale(1)" : "scale(0.95)"
|
||||
}}
|
||||
onClick={nextStep}
|
||||
>
|
||||
Meet Supermemory
|
||||
<ArrowRightIcon className="w-4 h-4 group-hover:translate-x-0.5 transition-transform duration-200" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ export function NameForm() {
|
|||
<div className="relative flex flex-col">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
autoCorrect="off"
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export function OnboardingProvider({
|
|||
}, 400),
|
||||
]
|
||||
|
||||
return () => cleanups.forEach((cleanup) => clearTimeout(cleanup))
|
||||
return () => cleanups.forEach(clearTimeout)
|
||||
}, [currentStep])
|
||||
|
||||
// Set orbs as revealed once the fourth trigger is activated OR if we're on any non-intro step
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function Welcome() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center text-center">
|
||||
<h1 className="text-white">Welcome to Supermemory</h1>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,622 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
|
||||
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 { useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import type { z } from "zod"
|
||||
import { ConnectAIModal } from "@/components/connect-ai-modal"
|
||||
import { InstallPrompt } from "@/components/install-prompt"
|
||||
import { MemoryListView } from "@/components/memory-list-view"
|
||||
import Menu from "@/components/menu"
|
||||
import { ProjectSelector } from "@/components/project-selector"
|
||||
import { ReferralUpgradeModal } from "@/components/referral-upgrade-modal"
|
||||
import { AddMemoryView } from "@/components/views/add-memory"
|
||||
import { ChatRewrite } from "@/components/views/chat"
|
||||
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 } = useViewMode()
|
||||
const { selectedProject } = useProject()
|
||||
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
|
||||
|
||||
// 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(() => {
|
||||
if (allDocuments.length === 0) {
|
||||
setShowConnectAIModal(true)
|
||||
}
|
||||
}, [allDocuments.length])
|
||||
|
||||
// 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"
|
||||
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 }}
|
||||
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={undefined}
|
||||
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 }}
|
||||
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" />
|
||||
<Logo className="h-8 md:hidden" />
|
||||
</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"
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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()
|
||||
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 (
|
||||
<>
|
||||
<MemoryGraphPage />
|
||||
<InstallPrompt />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 };
|
||||
80
apps/web/components/chat-input.tsx
Normal file
80
apps/web/components/chat-input.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"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"
|
||||
|
||||
export function ChatInput() {
|
||||
const [message, setMessage] = useState("")
|
||||
const router = useRouter()
|
||||
const { setCurrentChatId } = usePersistentChat()
|
||||
|
||||
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">
|
||||
Good evening, <span className="text-primary">Mahesh</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
164
apps/web/components/content-cards/google-docs.tsx
Normal file
164
apps/web/components/content-cards/google-docs.tsx
Normal 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"
|
||||
133
apps/web/components/content-cards/note.tsx
Normal file
133
apps/web/components/content-cards/note.tsx
Normal 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"
|
||||
105
apps/web/components/content-cards/tweet.tsx
Normal file
105
apps/web/components/content-cards/tweet.tsx
Normal 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"
|
||||
103
apps/web/components/content-cards/website.tsx
Normal file
103
apps/web/components/content-cards/website.tsx
Normal 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"
|
||||
175
apps/web/components/header.tsx
Normal file
175
apps/web/components/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
269
apps/web/components/masonry-memory-list.tsx
Normal file
269
apps/web/components/masonry-memory-list.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
62
apps/web/components/memories-utils/html-content-renderer.tsx
Normal file
62
apps/web/components/memories-utils/html-content-renderer.tsx
Normal 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"
|
||||
109
apps/web/components/memories-utils/index.tsx
Normal file
109
apps/web/components/memories-utils/index.tsx
Normal 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)"
|
||||
}
|
||||
385
apps/web/components/memories-utils/memory-detail.tsx
Normal file
385
apps/web/components/memories-utils/memory-detail.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
)
|
||||
276
apps/web/components/memories.tsx
Normal file
276
apps/web/components/memories.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ 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"
|
||||
|
|
@ -22,7 +22,7 @@ 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}
|
||||
|
|
@ -66,7 +66,7 @@ function Menu({ id }: { id?: string }) {
|
|||
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
|
||||
|
|
@ -103,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",
|
||||
|
|
@ -248,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
|
||||
|
|
@ -304,7 +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" : ""}`}
|
||||
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)}
|
||||
|
|
@ -326,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)",
|
||||
|
|
@ -353,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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ function TextShimmerComponent({
|
|||
initial={{ backgroundPosition: "100% center" }}
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
8
apps/web/lib/theme-provider.tsx
Normal file
8
apps/web/lib/theme-provider.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -49,11 +49,13 @@
|
|||
"@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",
|
||||
|
|
@ -61,6 +63,7 @@
|
|||
"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",
|
||||
|
|
@ -71,6 +74,7 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on",
|
||||
"useSortedAttributes": "on",
|
||||
"organizeImports": "off",
|
||||
"useSortedAttributes": "off",
|
||||
"useSortedKeys": "off"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
45
packages/ui/components/grid-plus.tsx
Normal file
45
packages/ui/components/grid-plus.tsx
Normal 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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue