diff --git a/apps/web/app/new/settings/page.tsx b/apps/web/app/new/settings/page.tsx new file mode 100644 index 00000000..fbb0bc08 --- /dev/null +++ b/apps/web/app/new/settings/page.tsx @@ -0,0 +1,243 @@ +"use client" +import { Logo } from "@ui/assets/Logo" +import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar" +import { useAuth } from "@lib/auth-context" +import { motion } from "motion/react" +import NovaOrb from "@/components/nova/nova-orb" +import { useState, useEffect, useRef } from "react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/utils/fonts" +import Account from "@/components/new/settings/account" +import Integrations from "@/components/new/settings/integrations" +import ConnectionsMCP from "@/components/new/settings/connections-mcp" +import Support from "@/components/new/settings/support" +import { useRouter } from "next/navigation" + +const TABS = ["account", "integrations", "connections", "support"] as const +type SettingsTab = (typeof TABS)[number] + +type NavItem = { + id: SettingsTab + label: string + description: string + icon: React.ReactNode +} + +const NAV_ITEMS: NavItem[] = [ + { + id: "account", + label: "Account & Billing", + description: "Manage your profile, plan, usage and payments", + icon: ( + + ), + }, + { + id: "integrations", + label: "Integrations", + description: "Save, sync and search memories across tools", + icon: ( + + ), + }, + { + id: "connections", + label: "Connections & MCP", + description: "Sync with Google Drive, Notion, OneDrive and MCP client", + icon: ( + + ), + }, + { + id: "support", + label: "Support & Help", + description: "Find answers or share feedback. We're here to help.", + icon: ( + + ), + }, +] + +function parseHashToTab(hash: string): SettingsTab { + const cleaned = hash.replace("#", "").toLowerCase() + return TABS.includes(cleaned as SettingsTab) + ? (cleaned as SettingsTab) + : "account" +} + +export function UserSupermemory({ name }: { name: string }) { + return ( + + +
+

+ {name.split(" ")[0]}'s +

+

+ supermemory +

+
+
+ ) +} + +export default function SettingsPage() { + const { user } = useAuth() + const [activeTab, setActiveTab] = useState("account") + const hasInitialized = useRef(false) + const router = useRouter() + + useEffect(() => { + if (hasInitialized.current) return + hasInitialized.current = true + + const hash = window.location.hash + const tab = parseHashToTab(hash) + setActiveTab(tab) + + // If no hash or invalid hash, push #account + if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) { + window.history.pushState(null, "", "#account") + } + }, []) + + useEffect(() => { + const handleHashChange = () => { + const tab = parseHashToTab(window.location.hash) + setActiveTab(tab) + } + + window.addEventListener("hashchange", handleHashChange) + return () => window.removeEventListener("hashchange", handleHashChange) + }, []) + return ( +
+
+ +
+ {user && ( + + + {user?.name?.charAt(0)} + + )} +
+
+
+
+ + + + + +
+
+ {activeTab === "account" && } + {activeTab === "integrations" && } + {activeTab === "connections" && } + {activeTab === "support" && } +
+
+
+ ) +} diff --git a/apps/web/components/connect-ai-modal.tsx b/apps/web/components/connect-ai-modal.tsx index be261cf9..942af105 100644 --- a/apps/web/components/connect-ai-modal.tsx +++ b/apps/web/components/connect-ai-modal.tsx @@ -31,6 +31,7 @@ import { toast } from "sonner" import { z } from "zod/v4" import { analytics } from "@/lib/analytics" import { cn } from "@lib/utils" +import type { Project } from "@repo/lib/types" import { motion, AnimatePresence } from "framer-motion" const clients = { @@ -56,15 +57,6 @@ const mcpMigrationSchema = z.object({ ), }) -interface Project { - id: string - name: string - containerTag: string - createdAt: string - updatedAt: string - isExperimental?: boolean -} - interface ConnectAIModalProps { children: React.ReactNode open?: boolean diff --git a/apps/web/components/new/add-document/connections.tsx b/apps/web/components/new/add-document/connections.tsx index 1f2f53db..2ba6513f 100644 --- a/apps/web/components/new/add-document/connections.tsx +++ b/apps/web/components/new/add-document/connections.tsx @@ -1,10 +1,187 @@ +"use client" + +import { $fetch } from "@lib/api" +import { fetchConnectionsFeature } from "@repo/lib/queries" +import type { ConnectionResponseSchema } from "@repo/validation/api" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" +import { useCustomer } from "autumn-js/react" +import { Check, Loader, Trash2, Zap } from "lucide-react" +import { useEffect, useState } from "react" +import { toast } from "sonner" +import type { z } from "zod" import { dmSansClassName } from "@/utils/fonts" import { cn } from "@lib/utils" -import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" -import { Check, Zap } from "lucide-react" +import { Button } from "@ui/components/button" + +type Connection = z.infer + +type ConnectorProvider = "google-drive" | "notion" | "onedrive" + +const CONNECTORS: Record< + ConnectorProvider, + { + title: string + description: string + icon: React.ComponentType<{ className?: string }> + } +> = { + "google-drive": { + title: "Google Drive", + description: "Connect your Google docs, sheets and slides", + icon: GoogleDrive, + }, + notion: { + title: "Notion", + description: "Import your Notion pages and databases", + icon: Notion, + }, + onedrive: { + title: "OneDrive", + description: "Access your Microsoft Office documents", + icon: OneDrive, + }, +} as const + +interface ConnectContentProps { + selectedProject: string +} + +export function ConnectContent({ selectedProject }: ConnectContentProps) { + const queryClient = useQueryClient() + const autumn = useCustomer() + const [isProUser, setIsProUser] = useState(false) + const [connectingProvider, setConnectingProvider] = + useState(null) + + // Check Pro status + useEffect(() => { + if (!autumn.isLoading) { + setIsProUser( + autumn.customer?.products.some( + (product) => product.id === "consumer_pro", + ) ?? false, + ) + } + }, [autumn.isLoading, autumn.customer]) + + // Check connections feature limits + const { data: connectionsCheck } = fetchConnectionsFeature( + autumn, + !autumn.isLoading, + ) + const connectionsUsed = connectionsCheck?.balance ?? 0 + const connectionsLimit = connectionsCheck?.included_usage ?? 0 + const canAddConnection = connectionsUsed < connectionsLimit + + // Fetch connections + const { data: connections = [], error: connectionsError } = 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, + refetchIntervalInBackground: true, + }) + + // Handle connection errors + useEffect(() => { + if (connectionsError) { + toast.error("Failed to load connections", { + description: + connectionsError instanceof Error + ? connectionsError.message + : "Unknown error", + }) + } + }, [connectionsError]) + + // Connect mutation + const addConnectionMutation = useMutation({ + mutationFn: async (provider: ConnectorProvider) => { + if (!canAddConnection && !isProUser) { + throw new Error( + "Free plan doesn't include connections. Upgrade to Pro for unlimited connections.", + ) + } + + 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) => { + if (data?.authLink) { + window.location.href = data.authLink + } + }, + onError: (error) => { + setConnectingProvider(null) + toast.error("Failed to connect", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + // Disconnect mutation + const deleteConnectionMutation = useMutation({ + mutationFn: async (connectionId: string) => { + await $fetch(`@delete/connections/${connectionId}`) + }, + onSuccess: () => { + toast.success( + "Connection removal has started. supermemory will permanently delete all documents related to the connection in the next few minutes.", + ) + queryClient.invalidateQueries({ queryKey: ["connections"] }) + }, + onError: (error) => { + toast.error("Failed to remove connection", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + const handleConnect = (provider: ConnectorProvider) => { + setConnectingProvider(provider) + addConnectionMutation.mutate(provider) + } + + const handleDisconnect = (connectionId: string) => { + deleteConnectionMutation.mutate(connectionId) + } + + const hasConnections = connections.length > 0 + + // Helper function to format connection subtext safely + const getConnectionSubtext = (connection: Connection): string => { + if (connection.email) { + return connection.email + } + + return "Connected" + } -export function ConnectContent() { - const isProUser = true return (
@@ -13,86 +190,219 @@ export function ConnectContent() { PRO
-
-
- -
-

Google Drive

-

- Connect your Google docs, sheets and slides -

-
-
-
- -
-

Notion

-

- Import your Notion pages and databases -

-
-
-
- -
-

OneDrive

-

- Access your Microsoft Office documents -

-
-
-
- + ) : ( +
+ {Object.entries(CONNECTORS).map(([provider, config]) => { + const Icon = config.icon + const connection = connections.find( + (conn) => conn.provider === provider, + ) + const isConnected = !!connection + const isConnecting = + connectingProvider === provider || + (addConnectionMutation.isPending && + addConnectionMutation.variables === provider) + + return ( +
+
+ +
+
+

{config.title}

+ {isConnected && ( + + {connection.metadata?.syncInProgress + ? "Syncing..." + : "Connected"} + + )} +
+

+ {config.description} +

+
+
+
+ {isConnected ? ( + + ) : ( + + )} +
+
+ ) + })} +
+ )} + + {/* Connected list panel - only when hasConnections */} + {hasConnections && ( +
+
+

+ Connected to Supermemory

-
-
- - Unlimited memories -
-
- - 10 connections -
-
- - Advanced search -
-
- - Priority support -
-
- - ) : ( -
0 && ( +

+ {connections.length}/{connectionsLimit} connections used +

)} - > -

No connections yet

-

- Choose a service above to import your knowledge -

- )} -
+
+ {connections.map((connection) => { + const config = + CONNECTORS[connection.provider as ConnectorProvider] + if (!config) return null + + const Icon = config.icon + const subtext = getConnectionSubtext(connection) + + return ( +
+
+ +
+

+ {config.title} +

+

+ {subtext} +

+
+
+ +
+ ) + })} +
+
+ )} + + {/* Empty state panel - only when !hasConnections */} + {!hasConnections && ( +
+ + {!isProUser ? ( + <> +

+ + Upgrade to Pro + {" "} + to get +
+ Supermemory Connections +

+
+
+ + Unlimited memories +
+
+ + 10 connections +
+
+ + Advanced search +
+
+ + Priority support +
+
+ + ) : ( +
+

No connections yet

+

+ Choose a service above to import your knowledge +

+
+ )} +
+ )}
) } diff --git a/apps/web/components/new/add-document/file.tsx b/apps/web/components/new/add-document/file.tsx index ff4578c2..8e7dc4c4 100644 --- a/apps/web/components/new/add-document/file.tsx +++ b/apps/web/components/new/add-document/file.tsx @@ -1,11 +1,71 @@ -import { useState } from "react" +"use client" + +import { useState, useEffect } from "react" import { cn } from "@lib/utils" import { dmSansClassName } from "@/utils/fonts" import { FileIcon } from "lucide-react" +import { useHotkeys } from "react-hotkeys-hook" -export function FileContent() { +export interface FileData { + file: File | null + title: string + description: string +} + +interface FileContentProps { + onSubmit?: (data: { file: File; title: string; description: string }) => void + onDataChange?: (data: FileData) => void + isSubmitting?: boolean + isOpen?: boolean +} + +export function FileContent({ onSubmit, onDataChange, isSubmitting, isOpen }: FileContentProps) { const [isDragging, setIsDragging] = useState(false) const [selectedFile, setSelectedFile] = useState(null) + const [title, setTitle] = useState("") + const [description, setDescription] = useState("") + + const canSubmit = selectedFile !== null && !isSubmitting + + const handleSubmit = () => { + if (canSubmit && onSubmit && selectedFile) { + onSubmit({ file: selectedFile, title, description }) + } + } + + const updateData = (newFile: File | null, newTitle: string, newDescription: string) => { + onDataChange?.({ file: newFile, title: newTitle, description: newDescription }) + } + + const handleFileChange = (file: File | null) => { + setSelectedFile(file) + updateData(file, title, description) + } + + const handleTitleChange = (newTitle: string) => { + setTitle(newTitle) + updateData(selectedFile, newTitle, description) + } + + const handleDescriptionChange = (newDescription: string) => { + setDescription(newDescription) + updateData(selectedFile, title, newDescription) + } + + useHotkeys("mod+enter", handleSubmit, { + enabled: isOpen && canSubmit, + enableOnFormTags: ["INPUT", "TEXTAREA"], + }) + + // Reset content when modal closes + useEffect(() => { + if (!isOpen) { + setSelectedFile(null) + setTitle("") + setDescription("") + onDataChange?.({ file: null, title: "", description: "" }) + } + }, [isOpen, onDataChange]) const handleDragOver = (e: React.DragEvent) => { e.preventDefault() @@ -22,14 +82,14 @@ export function FileContent() { setIsDragging(false) const file = e.dataTransfer.files[0] if (file) { - setSelectedFile(file) + handleFileChange(file) } } const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { - setSelectedFile(file) + handleFileChange(file) } } @@ -48,12 +108,14 @@ export function FileContent() { isDragging ? "border-[#4BA0FA] bg-[#4BA0FA]/10" : "border-[#737373]/30 hover:border-[#737373]/50", + isSubmitting && "opacity-50 pointer-events-none", )} >
@@ -80,15 +142,21 @@ export function FileContent() {

Title (optional)

handleTitleChange(e.target.value)} placeholder="Give this file a title" - className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out" + disabled={isSubmitting} + className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50" />

Description (optional)