diff options
| author | MaheshtheDev <[email protected]> | 2025-10-01 21:59:54 +0000 |
|---|---|---|
| committer | MaheshtheDev <[email protected]> | 2025-10-01 21:59:54 +0000 |
| commit | b148296f500f4274a783983c3e274bff2f0d22fd (patch) | |
| tree | 10772701467b91c47843c19b47f03ece73654e83 /apps | |
| parent | UI: onboarding improvements (#435) (diff) | |
| download | supermemory-09-25-feat_layout_design_with_theme_improvements.tar.xz supermemory-09-25-feat_layout_design_with_theme_improvements.zip | |
feat: layout design with theme improvements (#443)09-25-feat_layout_design_with_theme_improvements
feat: layout design with theme improvements
new improvements on light mode layout
chore: settings page with graph header and memories list
Diffstat (limited to 'apps')
53 files changed, 3795 insertions, 3273 deletions
diff --git a/apps/web/app/(navigation)/chat/[id]/page.tsx b/apps/web/app/(navigation)/chat/[id]/page.tsx new file mode 100644 index 00000000..9bb2038b --- /dev/null +++ b/apps/web/app/(navigation)/chat/[id]/page.tsx @@ -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> + ) +} diff --git a/apps/web/app/(navigation)/layout.tsx b/apps/web/app/(navigation)/layout.tsx new file mode 100644 index 00000000..da5a87fb --- /dev/null +++ b/apps/web/app/(navigation)/layout.tsx @@ -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> + ) +} diff --git a/apps/web/app/(navigation)/page.tsx b/apps/web/app/(navigation)/page.tsx new file mode 100644 index 00000000..b8c81985 --- /dev/null +++ b/apps/web/app/(navigation)/page.tsx @@ -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> + ) +} diff --git a/apps/web/app/(navigation)/settings/billing/page.tsx b/apps/web/app/(navigation)/settings/billing/page.tsx new file mode 100644 index 00000000..2b8e6ba0 --- /dev/null +++ b/apps/web/app/(navigation)/settings/billing/page.tsx @@ -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> + ) +}
\ No newline at end of file diff --git a/apps/web/app/(navigation)/settings/integrations/page.tsx b/apps/web/app/(navigation)/settings/integrations/page.tsx new file mode 100644 index 00000000..7fedd143 --- /dev/null +++ b/apps/web/app/(navigation)/settings/integrations/page.tsx @@ -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> + ) +}
\ No newline at end of file diff --git a/apps/web/app/(navigation)/settings/layout.tsx b/apps/web/app/(navigation)/settings/layout.tsx new file mode 100644 index 00000000..342e640c --- /dev/null +++ b/apps/web/app/(navigation)/settings/layout.tsx @@ -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> + ) +} diff --git a/apps/web/app/(navigation)/settings/page.tsx b/apps/web/app/(navigation)/settings/page.tsx new file mode 100644 index 00000000..c9046fba --- /dev/null +++ b/apps/web/app/(navigation)/settings/page.tsx @@ -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> + ) +}
\ No newline at end of file diff --git a/apps/web/app/(navigation)/settings/support/page.tsx b/apps/web/app/(navigation)/settings/support/page.tsx new file mode 100644 index 00000000..de4b3c78 --- /dev/null +++ b/apps/web/app/(navigation)/settings/support/page.tsx @@ -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:[email protected]", "_blank") + } + variant="outline" + > + <Mail className="w-4 h-4 mr-2" /> + Email us at [email protected] + <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> + ) +}
\ No newline at end of file diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 70d4916f..622eb2c1 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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> ) diff --git a/apps/web/app/onboarding/animated-text.tsx b/apps/web/app/onboarding/animated-text.tsx index c3616482..c32abb10 100644 --- a/apps/web/app/onboarding/animated-text.tsx +++ b/apps/web/app/onboarding/animated-text.tsx @@ -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> + ) } diff --git a/apps/web/app/onboarding/connections-form.tsx b/apps/web/app/onboarding/connections-form.tsx deleted file mode 100644 index f71f17ce..00000000 --- a/apps/web/app/onboarding/connections-form.tsx +++ /dev/null @@ -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> - ); -}
\ No newline at end of file diff --git a/apps/web/app/onboarding/extension-form.tsx b/apps/web/app/onboarding/extension-form.tsx index c94512e0..63e7dff1 100644 --- a/apps/web/app/onboarding/extension-form.tsx +++ b/apps/web/app/onboarding/extension-form.tsx @@ -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 diff --git a/apps/web/app/onboarding/floating-orbs.tsx b/apps/web/app/onboarding/floating-orbs.tsx index 96a8f061..6b1f9f8d 100644 --- a/apps/web/app/onboarding/floating-orbs.tsx +++ b/apps/web/app/onboarding/floating-orbs.tsx @@ -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) { diff --git a/apps/web/app/onboarding/intro.tsx b/apps/web/app/onboarding/intro.tsx index 5c8504a5..cc242df3 100644 --- a/apps/web/app/onboarding/intro.tsx +++ b/apps/web/app/onboarding/intro.tsx @@ -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> - ) -}
\ No newline at end of file + 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> + ) +} diff --git a/apps/web/app/onboarding/name-form.tsx b/apps/web/app/onboarding/name-form.tsx index 2b9520c9..df734601 100644 --- a/apps/web/app/onboarding/name-form.tsx +++ b/apps/web/app/onboarding/name-form.tsx @@ -63,7 +63,6 @@ export function NameForm() { <div className="relative flex flex-col"> <input type="text" - autoFocus name="name" autoComplete="name" autoCorrect="off" diff --git a/apps/web/app/onboarding/onboarding-context.tsx b/apps/web/app/onboarding/onboarding-context.tsx index 260fb51e..0b0eb346 100644 --- a/apps/web/app/onboarding/onboarding-context.tsx +++ b/apps/web/app/onboarding/onboarding-context.tsx @@ -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 diff --git a/apps/web/app/onboarding/welcome.tsx b/apps/web/app/onboarding/welcome.tsx index 3f73e43a..93313f7a 100644 --- a/apps/web/app/onboarding/welcome.tsx +++ b/apps/web/app/onboarding/welcome.tsx @@ -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> diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx deleted file mode 100644 index 8886067d..00000000 --- a/apps/web/app/page.tsx +++ /dev/null @@ -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:[email protected]" - > - <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 /> - </> - ) -} diff --git a/apps/web/button.tsx b/apps/web/button.tsx deleted file mode 100644 index 3a671e03..00000000 --- a/apps/web/button.tsx +++ /dev/null @@ -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 }; diff --git a/apps/web/components/chat-input.tsx b/apps/web/components/chat-input.tsx new file mode 100644 index 00000000..cf7409fc --- /dev/null +++ b/apps/web/components/chat-input.tsx @@ -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> + ) +} diff --git a/apps/web/components/connect-ai-modal.tsx b/apps/web/components/connect-ai-modal.tsx index b9759d4e..b576934f 100644 --- a/apps/web/components/connect-ai-modal.tsx +++ b/apps/web/components/connect-ai-modal.tsx @@ -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 diff --git a/apps/web/components/content-cards/google-docs.tsx b/apps/web/components/content-cards/google-docs.tsx new file mode 100644 index 00000000..22f06f77 --- /dev/null +++ b/apps/web/components/content-cards/google-docs.tsx @@ -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" diff --git a/apps/web/components/content-cards/note.tsx b/apps/web/components/content-cards/note.tsx new file mode 100644 index 00000000..e7703d9b --- /dev/null +++ b/apps/web/components/content-cards/note.tsx @@ -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" diff --git a/apps/web/components/content-cards/tweet.tsx b/apps/web/components/content-cards/tweet.tsx new file mode 100644 index 00000000..3f46d6cc --- /dev/null +++ b/apps/web/components/content-cards/tweet.tsx @@ -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" diff --git a/apps/web/components/content-cards/website.tsx b/apps/web/components/content-cards/website.tsx new file mode 100644 index 00000000..f36cd247 --- /dev/null +++ b/apps/web/components/content-cards/website.tsx @@ -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" diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx new file mode 100644 index 00000000..b51b4b84 --- /dev/null +++ b/apps/web/components/header.tsx @@ -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> + ) +} diff --git a/apps/web/components/masonry-memory-list.tsx b/apps/web/components/masonry-memory-list.tsx new file mode 100644 index 00000000..2f634f74 --- /dev/null +++ b/apps/web/components/masonry-memory-list.tsx @@ -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} + /> + </> + ) +} diff --git a/apps/web/components/memories-utils/html-content-renderer.tsx b/apps/web/components/memories-utils/html-content-renderer.tsx new file mode 100644 index 00000000..6231f5eb --- /dev/null +++ b/apps/web/components/memories-utils/html-content-renderer.tsx @@ -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" diff --git a/apps/web/components/memories-utils/index.tsx b/apps/web/components/memories-utils/index.tsx new file mode 100644 index 00000000..3052f3c7 --- /dev/null +++ b/apps/web/components/memories-utils/index.tsx @@ -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)" +} diff --git a/apps/web/components/memories-utils/memory-detail.tsx b/apps/web/components/memories-utils/memory-detail.tsx new file mode 100644 index 00000000..8f238731 --- /dev/null +++ b/apps/web/components/memories-utils/memory-detail.tsx @@ -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> + ) + }, +) diff --git a/apps/web/components/memories.tsx b/apps/web/components/memories.tsx new file mode 100644 index 00000000..568e9eb5 --- /dev/null +++ b/apps/web/components/memories.tsx @@ -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> + </> + ) +} diff --git a/apps/web/components/memories/index.tsx b/apps/web/components/memories/index.tsx deleted file mode 100644 index 97ef57bd..00000000 --- a/apps/web/components/memories/index.tsx +++ /dev/null @@ -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; -};
\ No newline at end of file diff --git a/apps/web/components/memories/memory-detail.tsx b/apps/web/components/memories/memory-detail.tsx deleted file mode 100644 index dad2a8a3..00000000 --- a/apps/web/components/memories/memory-detail.tsx +++ /dev/null @@ -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> - ); - } -); diff --git a/apps/web/components/memory-list-view.tsx b/apps/web/components/memory-list-view.tsx index b91e2562..c2b4b0c8 100644 --- a/apps/web/components/memory-list-view.tsx +++ b/apps/web/components/memory-list-view.tsx @@ -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`, diff --git a/apps/web/components/menu.tsx b/apps/web/components/menu.tsx index f32b56d1..e59f3839 100644 --- a/apps/web/components/menu.tsx +++ b/apps/web/components/menu.tsx @@ -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 @@ -104,12 +104,6 @@ function Menu({ id }: { id?: string }) { disabled: false, }, { - icon: MessageSquareMore, - text: "Chat", - key: "chat" as const, - disabled: false, - }, - { icon: Puzzle, text: "Integrations", key: "integrations" as const, @@ -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, diff --git a/apps/web/components/project-selector.tsx b/apps/web/components/project-selector.tsx index a592c474..0682a641 100644 --- a/apps/web/components/project-selector.tsx +++ b/apps/web/components/project-selector.tsx @@ -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> - ); + ) } diff --git a/apps/web/components/referral-upgrade-modal.tsx b/apps/web/components/referral-upgrade-modal.tsx deleted file mode 100644 index 029bd2ae..00000000 --- a/apps/web/components/referral-upgrade-modal.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/web/components/text-shimmer.tsx b/apps/web/components/text-shimmer.tsx index 1825d08c..815200fe 100644 --- a/apps/web/components/text-shimmer.tsx +++ b/apps/web/components/text-shimmer.tsx @@ -38,7 +38,7 @@ function TextShimmerComponent({ initial={{ backgroundPosition: "100% center" }} animate={{ backgroundPosition: "0% center" }} transition={{ - repeat: Infinity, + repeat: Number.POSITIVE_INFINITY, duration, ease: "linear", }} diff --git a/apps/web/components/views/add-memory/action-buttons.tsx b/apps/web/components/views/add-memory/action-buttons.tsx index 6dc49304..fc901ba9 100644 --- a/apps/web/components/views/add-memory/action-buttons.tsx +++ b/apps/web/components/views/add-memory/action-buttons.tsx @@ -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} diff --git a/apps/web/components/views/add-memory/index.tsx b/apps/web/components/views/add-memory/index.tsx index 8bd6a0f0..a78e7629 100644 --- a/apps/web/components/views/add-memory/index.tsx +++ b/apps/web/components/views/add-memory/index.tsx @@ -1,8 +1,6 @@ +"use client" import { $fetch } from "@lib/api" -import { - fetchConsumerProProduct, - fetchMemoriesFeature, -} from "@repo/lib/queries" +import { fetchMemoriesFeature } from "@repo/lib/queries" import { Button } from "@repo/ui/components/button" import { Dialog, @@ -12,6 +10,12 @@ import { DialogHeader, DialogTitle, } from "@repo/ui/components/dialog" +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, +} from "@repo/ui/components/tabs" import { Input } from "@repo/ui/components/input" import { Label } from "@repo/ui/components/label" import { Textarea } from "@repo/ui/components/textarea" @@ -43,13 +47,12 @@ import { ConnectionsTabContent } from "../connections-tab-content" import { ActionButtons } from "./action-buttons" import { MemoryUsageRing } from "./memory-usage-ring" import { ProjectSelection } from "./project-selection" -import { TabButton } from "./tab-button" const TextEditor = dynamic( () => import("./text-editor").then((mod) => ({ default: mod.TextEditor })), { loading: () => ( - <div className="bg-white/5 border border-white/10 rounded-md"> + <div className="bg-foreground/5 border border-foreground/10 rounded-md"> <div className="flex-1 min-h-48 max-h-64 overflow-y-auto flex items-center justify-center text-white/70"> Loading editor... </div> @@ -66,28 +69,6 @@ const TextEditor = dynamic( }, ) -// // Processing status component -// function ProcessingStatus({ status }: { status: string }) { -// const statusConfig = { -// queued: { color: "text-yellow-400", label: "Queued", icon: "⏳" }, -// extracting: { color: "text-blue-400", label: "Extracting", icon: "📤" }, -// chunking: { color: "text-indigo-400", label: "Chunking", icon: "✂️" }, -// embedding: { color: "text-purple-400", label: "Embedding", icon: "🧠" }, -// indexing: { color: "text-pink-400", label: "Indexing", icon: "📝" }, -// unknown: { color: "text-gray-400", label: "Processing", icon: "⚙️" }, -// } - -// const config = -// statusConfig[status as keyof typeof statusConfig] || statusConfig.unknown - -// return ( -// <div className={`flex items-center gap-1 text-xs ${config.color}`}> -// <span>{config.icon}</span> -// <span>{config.label}</span> -// </div> -// ) -// } - export function AddMemoryView({ onClose, initialTab = "note", @@ -107,7 +88,7 @@ export function AddMemoryView({ const [newProjectName, setNewProjectName] = useState("") // Check memory limits - const { data: memoriesCheck } = fetchMemoriesFeature(autumn) + const { data: memoriesCheck } = fetchMemoriesFeature(autumn, !autumn.isLoading) const memoriesUsed = memoriesCheck?.usage ?? 0 const memoriesLimit = memoriesCheck?.included_usage ?? 0 @@ -560,460 +541,452 @@ export function AddMemoryView({ open={showAddDialog} > <DialogContent - className="w-[95vw] max-w-3xl sm:max-w-3xl bg-[#0f1419] backdrop-blur-xl border-white/10 text-white z-[80] max-h-[90vh] overflow-y-auto" + className="w-[100vw] max-w-4xl sm:max-w-4xl backdrop-blur-xl border-white/10 z-[80] h-[52vh] overflow-y-auto p-4" showCloseButton={false} > - <motion.div - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.95 }} - initial={{ opacity: 0, scale: 0.95 }} + <Tabs + value={activeTab} + onValueChange={(value) => setActiveTab(value as typeof activeTab)} + className="flex flex-row gap-4" + orientation="vertical" > - <DialogHeader> - <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> - <div className="flex-1"> - <DialogTitle className="text-base"> - Add to Memory - </DialogTitle> - <DialogDescription className="text-white/50"> - Save any webpage, article, or file to your memory - </DialogDescription> + <TabsList className="flex flex-col gap-2 max-w-48 h-fit bg-transparent p-0"> + <TabsTrigger + value="note" + className="flex flex-col gap-1 justify-start items-start h-auto w-full" + > + <div className="flex gap-1 items-center"> + <Brain className="h-4 w-4" /> + Note </div> - <div className="sm:ml-4 order-first sm:order-last"> - <div className="bg-white/5 p-1 h-10 sm:h-8 rounded-md flex overflow-x-auto"> - <TabButton - icon={Brain} - isActive={activeTab === "note"} - label="Note" - onClick={() => setActiveTab("note")} - /> - <TabButton - icon={LinkIcon} - isActive={activeTab === "link"} - label="Link" - onClick={() => setActiveTab("link")} - /> - <TabButton - icon={FileIcon} - isActive={activeTab === "file"} - label="File" - onClick={() => setActiveTab("file")} - /> - <TabButton - icon={PlugIcon} - isActive={activeTab === "connect"} - label="Connect" - onClick={() => setActiveTab("connect")} + <span className="text-xs text-muted-foreground text-wrap text-left"> + Write down your thoughts + </span> + </TabsTrigger> + <TabsTrigger + value="link" + className="flex flex-col gap-1 justify-start items-start h-auto w-full" + > + <div className="flex gap-1 items-center"> + <LinkIcon className="h-4 w-4" /> + Link + </div> + <span className="text-xs text-muted-foreground text-wrap text-left"> + Save any webpage + </span> + </TabsTrigger> + <TabsTrigger + value="file" + className="flex flex-col gap-1 justify-start items-start h-auto w-full" + > + <div className="flex gap-1 items-center"> + <FileIcon className="h-4 w-4" /> + File + </div> + <span className="text-xs text-muted-foreground text-wrap text-left"> + Upload any file + </span> + </TabsTrigger> + <TabsTrigger + value="connect" + className="flex flex-col gap-1 justify-start items-start h-auto w-full" + > + <div className="flex gap-1 items-center"> + <PlugIcon className="h-4 w-4" /> + Connect + </div> + <span className="text-xs text-muted-foreground text-wrap text-left"> + Connect to your favorite apps + </span> + </TabsTrigger> + </TabsList> + + <TabsContent value="note" className="space-y-4"> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + addContentForm.handleSubmit() + }} + className="h-full flex flex-col" + > + <div className="grid gap-4"> + {/* Note Input */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <addContentForm.Field + name="content" + validators={{ + onChange: ({ value }) => { + if (!value || value.trim() === "") { + return "Note is required" + } + return undefined + }, + }} + > + {({ state, handleChange, handleBlur }) => ( + <> + <div + className={`bg-black/5 border border-black/10 rounded-md ${ + addContentMutation.isPending ? "opacity-50" : "" + }`} + > + <TextEditor + disabled={addContentMutation.isPending} + onBlur={handleBlur} + onChange={handleChange} + placeholder="Write your note here..." + value={state.value} + /> + </div> + {state.meta.errors.length > 0 && ( + <motion.p + animate={{ opacity: 1, height: "auto" }} + className="text-sm text-red-400 mt-1" + exit={{ opacity: 0, height: 0 }} + initial={{ opacity: 0, height: 0 }} + > + {state.meta.errors + .map((error) => + typeof error === "string" + ? error + : (error?.message ?? + `Error: ${JSON.stringify(error)}`), + ) + .join(", ")} + </motion.p> + )} + </> + )} + </addContentForm.Field> + </motion.div> + </div> + <div className="flex flex-col sm:flex-row sm:justify-between sm:items-end w-full gap-4 mt-auto"> + <div className="flex flex-col sm:flex-row sm:items-end gap-4 order-2 sm:order-1"> + {/* Project Selection */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className={`flex flex-col gap-2 flex-1 sm:flex-initial ${ + addContentMutation.isPending ? "opacity-50" : "" + }`} + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <addContentForm.Field name="project"> + {({ state, handleChange }) => ( + <ProjectSelection + disabled={addContentMutation.isPending} + id="note-project" + isLoading={isLoadingProjects} + onCreateProject={() => + setShowCreateProjectDialog(true) + } + onProjectChange={handleChange} + projects={projects} + selectedProject={state.value} + /> + )} + </addContentForm.Field> + </motion.div> + + <MemoryUsageRing + memoriesLimit={memoriesLimit} + memoriesUsed={memoriesUsed} /> </div> - </div> - </div> - </DialogHeader> - <div className="mt-4"> - {activeTab === "note" && ( - <div className="space-y-4"> - <form - onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - addContentForm.handleSubmit() + <ActionButtons + isSubmitDisabled={!addContentForm.state.canSubmit} + isSubmitting={addContentMutation.isPending} + onCancel={() => { + setShowAddDialog(false) + onClose?.() + addContentForm.reset() }} + submitIcon={Plus} + submitText="Add Note" + /> + </div> + </form> + </TabsContent> + + <TabsContent value="link" className="space-y-4"> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + addContentForm.handleSubmit() + }} + className="h-full flex flex-col" + > + <div className="grid gap-4"> + {/* Link Input */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} > - <div className="grid gap-4"> - {/* Note Input */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.1 }} - > - <addContentForm.Field - name="content" - validators={{ - onChange: ({ value }) => { - if (!value || value.trim() === "") { - return "Note is required" - } - return undefined - }, - }} - > - {({ state, handleChange, handleBlur }) => ( - <> - <div - className={`bg-white/5 border border-white/10 rounded-md ${ - addContentMutation.isPending - ? "opacity-50" - : "" - }`} - > - <TextEditor - className="text-white" - disabled={addContentMutation.isPending} - onBlur={handleBlur} - onChange={handleChange} - placeholder="Write your note here..." - value={state.value} - /> - </div> - {state.meta.errors.length > 0 && ( - <motion.p - animate={{ opacity: 1, height: "auto" }} - className="text-sm text-red-400 mt-1" - exit={{ opacity: 0, height: 0 }} - initial={{ opacity: 0, height: 0 }} - > - {state.meta.errors - .map((error) => - typeof error === "string" - ? error - : (error?.message ?? - `Error: ${JSON.stringify(error)}`), - ) - .join(", ")} - </motion.p> - )} - </> + <label + className="text-sm font-medium" + htmlFor="link-content" + > + Link + </label> + <addContentForm.Field + name="content" + validators={{ + onChange: ({ value }) => { + if (!value || value.trim() === "") { + return "Link is required" + } + try { + new URL(value) + return undefined + } catch { + return "Please enter a valid link" + } + }, + }} + > + {({ state, handleChange, handleBlur }) => ( + <> + <Input + className={`bg-black/5 border-black/10 text-black ${ + addContentMutation.isPending ? "opacity-50" : "" + }`} + disabled={addContentMutation.isPending} + id="link-content" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="https://example.com/article" + value={state.value} + /> + {state.meta.errors.length > 0 && ( + <motion.p + animate={{ opacity: 1, height: "auto" }} + className="text-sm text-red-400 mt-1" + exit={{ opacity: 0, height: 0 }} + initial={{ opacity: 0, height: 0 }} + > + {state.meta.errors + .map((error) => + typeof error === "string" + ? error + : (error?.message ?? + `Error: ${JSON.stringify(error)}`), + ) + .join(", ")} + </motion.p> )} - </addContentForm.Field> - </motion.div> - </div> - <div className="mt-6 flex flex-col sm:flex-row sm:justify-between sm:items-end w-full gap-4"> - <div className="flex flex-col sm:flex-row sm:items-end gap-4 order-2 sm:order-1"> - {/* Project Selection */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className={`flex flex-col gap-2 flex-1 sm:flex-initial ${ - addContentMutation.isPending ? "opacity-50" : "" - }`} - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.15 }} - > - <addContentForm.Field name="project"> - {({ state, handleChange }) => ( - <ProjectSelection - disabled={addContentMutation.isPending} - id="note-project" - isLoading={isLoadingProjects} - onCreateProject={() => - setShowCreateProjectDialog(true) - } - onProjectChange={handleChange} - projects={projects} - selectedProject={state.value} - /> - )} - </addContentForm.Field> - </motion.div> - - <MemoryUsageRing - memoriesLimit={memoriesLimit} - memoriesUsed={memoriesUsed} - /> - </div> - - <ActionButtons - isSubmitDisabled={!addContentForm.state.canSubmit} - isSubmitting={addContentMutation.isPending} - onCancel={() => { - setShowAddDialog(false) - onClose?.() - addContentForm.reset() - }} - submitIcon={Plus} - submitText="Add Note" - /> - </div> - </form> + </> + )} + </addContentForm.Field> + </motion.div> </div> - )} - - {activeTab === "link" && ( - <div className="space-y-4"> - <form - onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - addContentForm.handleSubmit() + <div className="mt-auto flex justify-between items-end w-full"> + <div className="flex items-end gap-4"> + {/* Left side - Project Selection */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className={`flex flex-col gap-2 ${ + addContentMutation.isPending ? "opacity-50" : "" + }`} + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <addContentForm.Field name="project"> + {({ state, handleChange }) => ( + <ProjectSelection + disabled={addContentMutation.isPending} + id="link-project-2" + isLoading={isLoadingProjects} + onCreateProject={() => + setShowCreateProjectDialog(true) + } + onProjectChange={handleChange} + projects={projects} + selectedProject={state.value} + /> + )} + </addContentForm.Field> + </motion.div> + + <MemoryUsageRing + memoriesLimit={memoriesLimit} + memoriesUsed={memoriesUsed} + /> + </div> + + <ActionButtons + isSubmitDisabled={!addContentForm.state.canSubmit} + isSubmitting={addContentMutation.isPending} + onCancel={() => { + setShowAddDialog(false) + onClose?.() + addContentForm.reset() }} + submitIcon={Plus} + submitText="Add Link" + /> + </div> + </form> + </TabsContent> + + <TabsContent value="file" className="space-y-4"> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + fileUploadForm.handleSubmit() + }} + > + <div className="grid gap-4"> + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} > - <div className="grid gap-4"> - {/* Link Input */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.1 }} - > - <label - className="text-sm font-medium" - htmlFor="link-content" - > - Link - </label> - <addContentForm.Field - name="content" - validators={{ - onChange: ({ value }) => { - if (!value || value.trim() === "") { - return "Link is required" - } - try { - new URL(value) - return undefined - } catch { - return "Please enter a valid link" - } - }, - }} - > - {({ state, handleChange, handleBlur }) => ( - <> - <Input - className={`bg-white/5 border-white/10 text-white ${ - addContentMutation.isPending - ? "opacity-50" - : "" - }`} - disabled={addContentMutation.isPending} - id="link-content" - onBlur={handleBlur} - onChange={(e) => handleChange(e.target.value)} - placeholder="https://example.com/article" - value={state.value} - /> - {state.meta.errors.length > 0 && ( - <motion.p - animate={{ opacity: 1, height: "auto" }} - className="text-sm text-red-400 mt-1" - exit={{ opacity: 0, height: 0 }} - initial={{ opacity: 0, height: 0 }} - > - {state.meta.errors - .map((error) => - typeof error === "string" - ? error - : (error?.message ?? - `Error: ${JSON.stringify(error)}`), - ) - .join(", ")} - </motion.p> - )} - </> - )} - </addContentForm.Field> - </motion.div> - </div> - <div className="mt-6 flex justify-between items-end w-full"> - <div className="flex items-end gap-4"> - {/* Left side - Project Selection */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className={`flex flex-col gap-2 ${ - addContentMutation.isPending ? "opacity-50" : "" - }`} - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.15 }} - > - <addContentForm.Field name="project"> - {({ state, handleChange }) => ( - <ProjectSelection - disabled={addContentMutation.isPending} - id="link-project-2" - isLoading={isLoadingProjects} - onCreateProject={() => - setShowCreateProjectDialog(true) - } - onProjectChange={handleChange} - projects={projects} - selectedProject={state.value} - /> - )} - </addContentForm.Field> - </motion.div> - - <MemoryUsageRing - memoriesLimit={memoriesLimit} - memoriesUsed={memoriesUsed} + <label className="text-sm font-medium" htmlFor="file"> + File + </label> + <Dropzone + accept={{ + "application/pdf": [".pdf"], + "application/msword": [".doc"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + [".docx"], + "text/plain": [".txt"], + "text/markdown": [".md"], + "text/csv": [".csv"], + "application/json": [".json"], + "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"], + }} + className="bg-black/5 border-black/10 hover:bg-black/10 min-h-40" + maxFiles={10} + maxSize={10 * 1024 * 1024} // 10MB + onDrop={(acceptedFiles) => + setSelectedFiles(acceptedFiles) + } + src={selectedFiles} + > + <DropzoneEmptyState /> + <DropzoneContent className="overflow-auto" /> + </Dropzone> + </motion.div> + + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <label + className="text-sm font-medium" + htmlFor="file-title" + > + Title (optional) + </label> + <fileUploadForm.Field name="title"> + {({ state, handleChange, handleBlur }) => ( + <Input + className="bg-black/5 border-black/10" + id="file-title" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="Give this file a title" + value={state.value} /> - </div> - - <ActionButtons - isSubmitDisabled={!addContentForm.state.canSubmit} - isSubmitting={addContentMutation.isPending} - onCancel={() => { - setShowAddDialog(false) - onClose?.() - addContentForm.reset() - }} - submitIcon={Plus} - submitText="Add Link" - /> - </div> - </form> - </div> - )} - - {activeTab === "file" && ( - <div className="space-y-4"> - <form - onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - fileUploadForm.handleSubmit() - }} + )} + </fileUploadForm.Field> + </motion.div> + + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.2 }} > - <div className="grid gap-4"> - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.1 }} - > - <label className="text-sm font-medium" htmlFor="file"> - File - </label> - <Dropzone - accept={{ - "application/pdf": [".pdf"], - "application/msword": [".doc"], - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - [".docx"], - "text/plain": [".txt"], - "text/markdown": [".md"], - "text/csv": [".csv"], - "application/json": [".json"], - "image/*": [ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ], - }} - className="bg-white/5 border-white/10 hover:bg-white/10 min-h-40" - maxFiles={10} - maxSize={10 * 1024 * 1024} // 10MB - onDrop={(acceptedFiles) => - setSelectedFiles(acceptedFiles) - } - src={selectedFiles} - > - <DropzoneEmptyState /> - <DropzoneContent className="overflow-auto" /> - </Dropzone> - </motion.div> - - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.15 }} - > - <label - className="text-sm font-medium" - htmlFor="file-title" - > - Title (optional) - </label> - <fileUploadForm.Field name="title"> - {({ state, handleChange, handleBlur }) => ( - <Input - className="bg-white/5 border-white/10 text-white" - id="file-title" - onBlur={handleBlur} - onChange={(e) => handleChange(e.target.value)} - placeholder="Give this file a title" - value={state.value} - /> - )} - </fileUploadForm.Field> - </motion.div> - - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.2 }} - > - <label - className="text-sm font-medium" - htmlFor="file-description" - > - Description (optional) - </label> - <fileUploadForm.Field name="description"> - {({ state, handleChange, handleBlur }) => ( - <Textarea - className="bg-white/5 border-white/10 text-white min-h-20 max-h-40 overflow-y-auto resize-none" - id="file-description" - onBlur={handleBlur} - onChange={(e) => handleChange(e.target.value)} - placeholder="Add notes or context about this file" - value={state.value} - /> - )} - </fileUploadForm.Field> - </motion.div> - </div> - <div className="mt-6 flex flex-col sm:flex-row sm:justify-between sm:items-end w-full gap-4"> - <div className="flex items-end gap-4"> - {/* Left side - Project Selection */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className={`flex flex-col gap-2 flex-1 sm:flex-initial ${ - fileUploadMutation.isPending ? "opacity-50" : "" - }`} - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.25 }} - > - <fileUploadForm.Field name="project"> - {({ state, handleChange }) => ( - <ProjectSelection - disabled={fileUploadMutation.isPending} - id="file-project" - isLoading={isLoadingProjects} - onCreateProject={() => - setShowCreateProjectDialog(true) - } - onProjectChange={handleChange} - projects={projects} - selectedProject={state.value} - /> - )} - </fileUploadForm.Field> - </motion.div> - - <MemoryUsageRing - memoriesLimit={memoriesLimit} - memoriesUsed={memoriesUsed} + <label + className="text-sm font-medium" + htmlFor="file-description" + > + Description (optional) + </label> + <fileUploadForm.Field name="description"> + {({ state, handleChange, handleBlur }) => ( + <Textarea + className="bg-black/5 border-black/10 min-h-20 max-h-40 overflow-y-auto resize-none" + id="file-description" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="Add notes or context about this file" + value={state.value} /> - </div> - - <ActionButtons - isSubmitDisabled={selectedFiles.length === 0} - isSubmitting={fileUploadMutation.isPending} - onCancel={() => { - setShowAddDialog(false) - onClose?.() - fileUploadForm.reset() - setSelectedFiles([]) - }} - submitIcon={UploadIcon} - submitText="Upload File" - /> - </div> - </form> + )} + </fileUploadForm.Field> + </motion.div> </div> - )} + <div className="mt-6 flex flex-col sm:flex-row sm:justify-between sm:items-end w-full gap-4"> + <div className="flex items-end gap-4"> + {/* Left side - Project Selection */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className={`flex flex-col gap-2 flex-1 sm:flex-initial ${ + fileUploadMutation.isPending ? "opacity-50" : "" + }`} + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.25 }} + > + <fileUploadForm.Field name="project"> + {({ state, handleChange }) => ( + <ProjectSelection + disabled={fileUploadMutation.isPending} + id="file-project" + isLoading={isLoadingProjects} + onCreateProject={() => + setShowCreateProjectDialog(true) + } + onProjectChange={handleChange} + projects={projects} + selectedProject={state.value} + /> + )} + </fileUploadForm.Field> + </motion.div> + + <MemoryUsageRing + memoriesLimit={memoriesLimit} + memoriesUsed={memoriesUsed} + /> + </div> - {activeTab === "connect" && ( - <div className="space-y-4"> - <ConnectionsTabContent /> + <ActionButtons + isSubmitDisabled={selectedFiles.length === 0} + isSubmitting={fileUploadMutation.isPending} + onCancel={() => { + setShowAddDialog(false) + onClose?.() + fileUploadForm.reset() + setSelectedFiles([]) + }} + submitIcon={UploadIcon} + submitText="Upload File" + /> </div> - )} - </div> - </motion.div> + </form> + </TabsContent> + + <TabsContent value="connect" className="space-y-4"> + <ConnectionsTabContent /> + </TabsContent> + </Tabs> </DialogContent> </Dialog> )} @@ -1025,7 +998,7 @@ export function AddMemoryView({ onOpenChange={setShowCreateProjectDialog} open={showCreateProjectDialog} > - <DialogContent className="w-[95vw] max-w-2xl sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white z-[80] max-h-[90vh] overflow-y-auto"> + <DialogContent className="w-[95vw] max-w-2xl sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 z-[80] max-h-[90vh] overflow-y-auto"> <motion.div animate={{ opacity: 1, scale: 1 }} initial={{ opacity: 0, scale: 0.95 }} @@ -1045,7 +1018,7 @@ export function AddMemoryView({ > <Label htmlFor="projectName">Project Name</Label> <Input - className="bg-white/5 border-white/10 text-white" + className="bg-white/5 border-white/10" id="projectName" onChange={(e) => setNewProjectName(e.target.value)} placeholder="My Awesome Project" @@ -1063,7 +1036,7 @@ export function AddMemoryView({ whileTap={{ scale: 0.95 }} > <Button - className="bg-white/5 hover:bg-white/10 border-white/10 text-white w-full sm:w-auto" + className="bg-white/5 hover:bg-white/10 border-white/10 w-full sm:w-auto" onClick={() => { setShowCreateProjectDialog(false) setNewProjectName("") @@ -1080,7 +1053,7 @@ export function AddMemoryView({ whileTap={{ scale: 0.95 }} > <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20 w-full sm:w-auto" + className="bg-white/10 hover:bg-white/20 border-white/20 w-full sm:w-auto" disabled={ createProjectMutation.isPending || !newProjectName.trim() } @@ -1131,7 +1104,7 @@ export function AddMemoryExpandedView() { <div className="flex flex-wrap gap-2"> <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" + className="bg-white/10 hover:bg-white/20 border-white/20" onClick={() => handleOpenDialog("note")} size="sm" variant="outline" @@ -1143,7 +1116,7 @@ export function AddMemoryExpandedView() { <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" + className="bg-white/10 hover:bg-white/20 border-white/20" onClick={() => handleOpenDialog("link")} size="sm" variant="outline" @@ -1155,7 +1128,7 @@ export function AddMemoryExpandedView() { <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" + className="bg-white/10 hover:bg-white/20 border-white/20" onClick={() => handleOpenDialog("file")} size="sm" variant="outline" @@ -1167,7 +1140,7 @@ export function AddMemoryExpandedView() { <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" + className="bg-white/10 hover:bg-white/20 border-white/20" onClick={() => handleOpenDialog("connect")} size="sm" variant="outline" diff --git a/apps/web/components/views/add-memory/project-selection.tsx b/apps/web/components/views/add-memory/project-selection.tsx index f23768a3..3c166303 100644 --- a/apps/web/components/views/add-memory/project-selection.tsx +++ b/apps/web/components/views/add-memory/project-selection.tsx @@ -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" > diff --git a/apps/web/components/views/add-memory/text-editor.tsx b/apps/web/components/views/add-memory/text-editor.tsx index 5a07b8f7..f6cf9425 100644 --- a/apps/web/components/views/add-memory/text-editor.tsx +++ b/apps/web/components/views/add-memory/text-editor.tsx @@ -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 */} diff --git a/apps/web/components/views/billing.tsx b/apps/web/components/views/billing.tsx index 8b79eb23..679b648e 100644 --- a/apps/web/components/views/billing.tsx +++ b/apps/web/components/views/billing.tsx @@ -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> - ); + ) } diff --git a/apps/web/components/views/chat/chat-messages.tsx b/apps/web/components/views/chat/chat-messages.tsx index 85f91565..db0ae209 100644 --- a/apps/web/components/views/chat/chat-messages.tsx +++ b/apps/web/components/views/chat/chat-messages.tsx @@ -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> + ) } diff --git a/apps/web/components/views/connections-tab-content.tsx b/apps/web/components/views/connections-tab-content.tsx index 14a63e8f..3aa06776 100644 --- a/apps/web/components/views/connections-tab-content.tsx +++ b/apps/web/components/views/connections-tab-content.tsx @@ -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> diff --git a/apps/web/components/views/integrations.tsx b/apps/web/components/views/integrations.tsx index b3e3c92d..dadd7dc6 100644 --- a/apps/web/components/views/integrations.tsx +++ b/apps/web/components/views/integrations.tsx @@ -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" diff --git a/apps/web/components/views/profile.tsx b/apps/web/components/views/profile.tsx index 3d49d395..93330da4 100644 --- a/apps/web/components/views/profile.tsx +++ b/apps/web/components/views/profile.tsx @@ -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 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> - {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> - )} + {/* 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> - {/* 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> + {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> + )} - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* 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> ) -} +}
\ No newline at end of file diff --git a/apps/web/globals.css b/apps/web/globals.css index 3571db7d..2c150cf0 100644 --- a/apps/web/globals.css +++ b/apps/web/globals.css @@ -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; +} diff --git a/apps/web/lib/document-icon.tsx b/apps/web/lib/document-icon.tsx index 3a80b2e0..a0f56e63 100644 --- a/apps/web/lib/document-icon.tsx +++ b/apps/web/lib/document-icon.tsx @@ -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} />; diff --git a/apps/web/lib/theme-provider.tsx b/apps/web/lib/theme-provider.tsx new file mode 100644 index 00000000..7e3a551f --- /dev/null +++ b/apps/web/lib/theme-provider.tsx @@ -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> +} diff --git a/apps/web/package.json b/apps/web/package.json index 057532f4..61b99bcb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/stores/chat.ts b/apps/web/stores/chat.ts index fe27275e..ac204442 100644 --- a/apps/web/stores/chat.ts +++ b/apps/web/stores/chat.ts @@ -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, diff --git a/apps/web/stores/index.ts b/apps/web/stores/index.ts index fb367cf7..30c33c0f 100644 --- a/apps/web/stores/index.ts +++ b/apps/web/stores/index.ts @@ -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" |