diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 96091f181..9f2ec346a 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -6,9 +6,9 @@ import { Header } from "@/components/new/header" import { ChatSidebar } from "@/components/new/chat" import { MemoriesGrid } from "@/components/new/memories-grid" import { GraphLayoutView } from "@/components/new/graph-layout-view" +import { IntegrationsView } from "@/components/new/integrations-view" import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background" import { AddDocumentModal } from "@/components/new/add-document" -import { MCPModal } from "@/components/new/mcp-modal" import { DocumentModal } from "@/components/new/document-modal" import { DocumentsCommandPalette } from "@/components/new/documents-command-palette" import { FullscreenNoteModal } from "@/components/new/fullscreen-note-modal" @@ -17,6 +17,7 @@ import { HotkeysProvider } from "react-hotkeys-hook" import { useHotkeys } from "react-hotkeys-hook" import { AnimatePresence } from "motion/react" import { useIsMobile } from "@hooks/use-mobile" +import { useAuth } from "@lib/auth-context" import { useProject } from "@/stores" import { useQuickNoteDraftReset, @@ -31,7 +32,6 @@ import { useViewMode } from "@/lib/view-mode-context" import { cn } from "@lib/utils" import { addDocumentParam, - mcpParam, searchParam, qParam, docParam, @@ -44,13 +44,29 @@ type DocumentWithMemories = DocumentsResponse["documents"][0] export default function NewPage() { const isMobile = useIsMobile() + const { user, session } = useAuth() const { selectedProject } = useProject() - const { viewMode } = useViewMode() + const { viewMode, setViewMode } = useViewMode() const queryClient = useQueryClient() + // Chrome extension auth: send session token via postMessage so the content script can store it + useEffect(() => { + const url = new URL(window.location.href) + if (!url.searchParams.get("extension-auth-success")) return + const sessionToken = session?.token + const userData = { email: user?.email, name: user?.name, userId: user?.id } + if (sessionToken && userData.email) { + window.postMessage( + { token: encodeURIComponent(sessionToken), userData }, + window.location.origin, + ) + url.searchParams.delete("extension-auth-success") + window.history.replaceState({}, "", url.toString()) + } + }, [user, session]) + // URL-driven modal states const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam) - const [isMCPOpen, setIsMCPOpen] = useQueryState("mcp", mcpParam) const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam) const [searchPrefill, setSearchPrefill] = useQueryState("q", qParam) const [docId, setDocId] = useQueryState("doc", docParam) @@ -284,10 +300,6 @@ export default function NewPage() { analytics.addDocumentModalOpened() setAddDoc("note") }} - onOpenMCP={() => { - analytics.mcpModalOpened() - setIsMCPOpen(true) - }} onOpenChat={() => setIsChatOpen(true)} onOpenSearch={() => { analytics.searchOpened({ source: "header" }) @@ -302,7 +314,11 @@ export default function NewPage() { )} >
- {viewMode === "graph" && !isMobile ? ( + {viewMode === "integrations" ? ( +
+ +
+ ) : viewMode === "graph" && !isMobile ? (
@@ -354,10 +370,6 @@ export default function NewPage() { onClose={() => setAddDoc(null)} defaultTab={addDoc ?? undefined} /> - setIsMCPOpen(false)} - /> { @@ -370,10 +382,7 @@ export default function NewPage() { analytics.addDocumentModalOpened() setAddDoc("note") }} - onOpenMCP={() => { - analytics.mcpModalOpened() - setIsMCPOpen(true) - }} + onOpenIntegrations={() => setViewMode("integrations")} initialSearch={searchPrefill} /> { setLocalSelectedProject(globalSelectedProject) @@ -246,37 +251,96 @@ export function AddDocument({ {!isMobile && (
-
- - Memories - - - {isLoadingMemories - ? "…" - : hasProProduct - ? "Unlimited" - : `${memoriesUsed}/${memoriesLimit}`} - +
+
+ + Credits + + + {isLoadingUsage ? "…" : `${tokensToCredits(tokensUsed)} / ${tokensToCredits(tokensLimit)}`} + +
+
+
80 + ? "#ef4444" + : hasPaidPlan + ? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)" + : "#0054AD", + }} + /> +
- {!hasProProduct && ( -
+ +
+
+ + Search Queries + + + {isLoadingUsage ? "…" : `${formatUsageNumber(searchesUsed)} / ${formatUsageNumber(searchesLimit)}`} + +
+
80 + ? "#ef4444" + : hasPaidPlan + ? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)" + : "#0054AD", + }} />
+
+ + {!hasPaidPlan && ( + )}
)} diff --git a/apps/web/components/new/documents-command-palette.tsx b/apps/web/components/new/documents-command-palette.tsx index ee4a34099..bff1ebcf4 100644 --- a/apps/web/components/new/documents-command-palette.tsx +++ b/apps/web/components/new/documents-command-palette.tsx @@ -38,7 +38,7 @@ interface DocumentsCommandPaletteProps { projectId: string onOpenDocument: (document: DocumentWithMemories) => void onAddMemory?: () => void - onOpenMCP?: () => void + onOpenIntegrations?: () => void initialSearch?: string } @@ -48,7 +48,7 @@ export function DocumentsCommandPalette({ projectId, onOpenDocument, onAddMemory, - onOpenMCP, + onOpenIntegrations, initialSearch = "", }: DocumentsCommandPaletteProps) { const isMobile = useIsMobile() @@ -95,13 +95,13 @@ export function DocumentsCommandPalette({ action: () => { close(); onAddMemory() }, }] : []), - ...(onOpenMCP + ...(onOpenIntegrations ? [{ kind: "action" as const, - id: "mcp", - label: "Open MCP", + id: "integrations", + label: "Open Integrations", icon: , - action: () => { close(); onOpenMCP() }, + action: () => { close(); onOpenIntegrations() }, }] : []), ] diff --git a/apps/web/components/new/header.tsx b/apps/web/components/new/header.tsx index c66220e57..be6716b87 100644 --- a/apps/web/components/new/header.tsx +++ b/apps/web/components/new/header.tsx @@ -11,6 +11,7 @@ import { Settings, Home, Code2, + Cable, ExternalLink, HelpCircle, MenuIcon, @@ -43,14 +44,12 @@ import { feedbackParam } from "@/lib/search-params" interface HeaderProps { onAddMemory?: () => void - onOpenMCP?: () => void onOpenChat?: () => void onOpenSearch?: () => void } export function Header({ onAddMemory, - onOpenMCP, onOpenChat, onOpenSearch, }: HeaderProps) { @@ -155,8 +154,8 @@ export function Header({
{!isMobile && ( setViewMode(v === "grid" ? "list" : "graph")} + value={viewMode === "list" ? "grid" : viewMode} + onValueChange={(v) => setViewMode(v === "grid" ? "list" : v as "graph" | "integrations")} > Graph + + + Integrations + )} @@ -220,11 +229,11 @@ export function Header({ Add memory setViewMode("integrations")} className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2" > - - MCP + + Integrations - + {children} +
+
+ ) +} + +export function IntegrationsView() { + const [selectedCard, setSelectedCard] = useState(null) + + if (selectedCard === "mcp") { + return setSelectedCard(null)} /> + } + if (selectedCard === "import") { + return setSelectedCard(null)} /> + } + if (selectedCard === "chrome") { + return ( + setSelectedCard(null)}> + + + ) + } + if (selectedCard === "shortcuts") { + return ( + setSelectedCard(null)}> + + + ) + } + if (selectedCard === "raycast") { + return ( + setSelectedCard(null)}> + + + ) + } + if (selectedCard === "connections") { + return ( + setSelectedCard(null)}> + + + ) + } + if (selectedCard === "plugins") { + return ( + setSelectedCard(null)}> + + + ) + } + + return ( +
+
+
+
+ +

Integrations

+
+

+ Connect supermemory to your tools and workflows +

+
+ +
+ {cards.map((card) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/web/components/new/integrations/chrome-detail.tsx b/apps/web/components/new/integrations/chrome-detail.tsx new file mode 100644 index 000000000..951a0ca44 --- /dev/null +++ b/apps/web/components/new/integrations/chrome-detail.tsx @@ -0,0 +1,104 @@ +"use client" + +import { cn } from "@lib/utils" +import { dmSans125ClassName } from "@/lib/fonts" +import { ChromeIcon } from "@/components/new/integration-icons" +import { Check, Download } from "lucide-react" +import { analytics } from "@/lib/analytics" + +function PillButton({ + children, + onClick, +}: { + children: React.ReactNode + onClick?: () => void +}) { + return ( + + ) +} + +export function ChromeDetail() { + const handleInstall = () => { + window.open( + "https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc", + "_blank", + "noopener,noreferrer", + ) + analytics.onboardingChromeExtensionClicked({ source: "integrations" }) + } + + return ( +
+
+
+ +
+

+ Chrome Extension +

+

+ Save any webpage directly from your browser +

+
+
+ + + + + Add to Chrome + + + +
+ {[ + "Import all Twitter bookmarks", + "Sync ChatGPT memories", + "Save any webpage", + "One time setup", + ].map((text) => ( +
+ + + {text} + +
+ ))} +
+
+
+ ) +} diff --git a/apps/web/components/new/integrations/connections-detail.tsx b/apps/web/components/new/integrations/connections-detail.tsx new file mode 100644 index 000000000..69dbcb060 --- /dev/null +++ b/apps/web/components/new/integrations/connections-detail.tsx @@ -0,0 +1,271 @@ +"use client" + +import { dmSans125ClassName } from "@/lib/fonts" +import { cn } from "@lib/utils" +import { $fetch } from "@lib/api" +import { fetchSubscriptionStatus } from "@lib/queries" +import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" +import { useCustomer } from "autumn-js/react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Check, Plus, Trash2, Zap } from "lucide-react" +import { useEffect, useState } from "react" +import { toast } from "sonner" +import type { ConnectionResponseSchema } from "@repo/validation/api" +import type { z } from "zod" +import { analytics } from "@/lib/analytics" +import { AddDocumentModal } from "@/components/new/add-document" +import { DEFAULT_PROJECT_ID } from "@repo/lib/constants" +import type { Project } from "@repo/lib/types" + +type Connection = z.infer + +const CONNECTORS = { + "google-drive": { title: "Google Drive", icon: GoogleDrive, documentLabel: "documents" }, + notion: { title: "Notion", icon: Notion, documentLabel: "pages" }, + onedrive: { title: "OneDrive", icon: OneDrive, documentLabel: "documents" }, +} as const + +type ConnectorProvider = keyof typeof CONNECTORS + +function ConnectionRow({ + connection, + onDelete, + isDeleting, + disabled, + projects, +}: { + connection: Connection + onDelete: () => void + isDeleting: boolean + disabled?: boolean + projects: Project[] +}) { + const config = CONNECTORS[connection.provider as ConnectorProvider] + if (!config) return null + + const Icon = config.icon + const isConnected = !connection.expiresAt || new Date(connection.expiresAt) > new Date() + + const formatRelativeTime = (date: string | null | undefined) => { + if (!date) return "Never" + const d = new Date(date) + const diffMs = Date.now() - d.getTime() + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffDays = Math.floor(diffHours / 24) + if (diffHours < 1) return "Just now" + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays === 1) return "Yesterday" + if (diffDays < 7) return `${diffDays} days ago` + return d.toLocaleDateString() + } + + const getProjectName = (tag: string): string => { + if (tag === DEFAULT_PROJECT_ID) return "Default Project" + return projects.find((p) => p.containerTag === tag)?.name ?? tag.replace(/^sm_project_/, "") + } + + const documentCount = (connection.metadata?.documentCount as number) ?? 0 + const containerTags = (connection as Connection & { containerTags?: string[] }).containerTags + const projectName = containerTags?.[0] ? getProjectName(containerTags[0]) : null + + return ( +
+
+
+ +
+
+ {config.title} +
+
+ + {isConnected ? "Connected" : "Disconnected"} + +
+
+ {connection.email || "Unknown"} +
+ +
+
+ {projectName && ( + <> + Project: {projectName} +
+ + )} + Added: {formatRelativeTime(connection.createdAt)} +
+ {documentCount} {config.documentLabel} connected +
+
+
+ ) +} + +export function ConnectionsDetail() { + const queryClient = useQueryClient() + const autumn = useCustomer() + const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false) + + const projects = (queryClient.getQueryData(["projects"]) || []) as Project[] + + const { data: status = { api_pro: { allowed: false, status: null } }, isLoading: isCheckingStatus } = + fetchSubscriptionStatus(autumn, !autumn.isLoading) + + const hasProProduct = status.api_pro?.status !== null + + const connectionsFeature = autumn.customer?.features?.connections + const connectionsUsed = connectionsFeature?.usage ?? 0 + const connectionsLimit = connectionsFeature?.included_usage ?? 10 + const canAddConnection = connectionsUsed < connectionsLimit + + const { data: connections = [], isLoading: isLoadingConnections, 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, + enabled: hasProProduct, + }) + + useEffect(() => { + if (connectionsError) { + toast.error("Failed to load connections", { + description: connectionsError instanceof Error ? connectionsError.message : "Unknown error", + }) + } + }, [connectionsError]) + + const deleteConnectionMutation = useMutation({ + mutationFn: async (connectionId: string) => { + await $fetch(`@delete/connections/${connectionId}`) + }, + onSuccess: () => { + analytics.connectionDeleted() + toast.success("Connection removal has started. Documents will be permanently deleted 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 handleUpgrade = async () => { + try { + await autumn.attach({ productId: "api_pro", successUrl: "https://app.supermemory.ai/?view=integrations" }) + window.location.reload() + } catch (error) { + console.error(error) + } + } + + const isLoading = autumn.isLoading || isCheckingStatus + + return ( + <> +
+ {!hasProProduct && !isLoading && ( + <> +
+
+
+ +

+ Connect Google Drive, Notion, and OneDrive to import your knowledge +

+
+ {["Unlimited memories", "10 connections", "Advanced search", "Priority support"].map((text) => ( +
+ + {text} +
+ ))} +
+ +
+
+ + )} + +
+
+ + Connected to Supermemory + + + {connections.length}/{connectionsLimit} connections used + +
+ +
+ {isLoadingConnections ? ( +
+
+
+ ) : connections.length > 0 ? ( + connections.map((connection) => ( + deleteConnectionMutation.mutate(connection.id)} + isDeleting={deleteConnectionMutation.isPending} + disabled={!hasProProduct} + projects={projects} + /> + )) + ) : ( +
+ +

No connections yet

+

Connect a service below to import your knowledge

+
+ )} +
+ + +
+
+ + setIsAddDocumentOpen(false)} defaultTab="connect" /> + + ) +} diff --git a/apps/web/components/new/integrations/plugins-detail.tsx b/apps/web/components/new/integrations/plugins-detail.tsx new file mode 100644 index 000000000..b01753a48 --- /dev/null +++ b/apps/web/components/new/integrations/plugins-detail.tsx @@ -0,0 +1,489 @@ +"use client" + +import { cn } from "@lib/utils" +import { dmSans125ClassName } from "@/lib/fonts" +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { fetchSubscriptionStatus } from "@lib/queries" +import { useCustomer } from "autumn-js/react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + ArrowRight, + BookOpen, + Brain, + Check, + CheckCircle, + Copy, + ExternalLink, + Key, + Loader, + Plug, + Trash2, + Zap, +} from "lucide-react" +import Image from "next/image" +import { useMemo, useState } from "react" +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogPortal, +} from "@ui/components/dialog" +import { analytics } from "@/lib/analytics" + +interface PluginInfo { + id: string + name: string + description: string + features: string[] + icon: string + docsUrl?: string + repoUrl?: string +} + +const PLUGIN_CATALOG: Record = { + claude_code: { + id: "claude_code", + name: "Claude Code", + description: "Persistent memory for Claude Code. Remembers your coding context, patterns, and decisions across sessions.", + features: [ + "Auto-recalls relevant context at session start", + "Captures important observations from tool usage", + "Builds persistent user profile from interactions", + ], + icon: "/images/plugins/claude-code.svg", + docsUrl: "https://docs.supermemory.ai/integrations/claude-code", + repoUrl: "https://github.com/supermemoryai/claude-supermemory", + }, + opencode: { + id: "opencode", + name: "OpenCode", + description: "Memory layer for OpenCode. Enhances your coding assistant with long-term memory capabilities.", + features: [ + "Semantic search across previous sessions", + "Auto-capture of coding decisions", + "Context injection before each prompt", + ], + icon: "/images/plugins/opencode.svg", + docsUrl: "https://docs.supermemory.ai/integrations/opencode", + }, + clawdbot: { + id: "clawdbot", + name: "ClawdBot", + description: "Multi-platform memory for OpenClaw. Works across Telegram, WhatsApp, Discord, Slack and more.", + features: [ + "Cross-channel memory persistence", + "Automatic conversation capture", + "User profile building across platforms", + ], + icon: "/images/plugins/clawdbot.svg", + docsUrl: "https://docs.supermemory.ai/integrations/openclaw", + repoUrl: "https://github.com/supermemoryai/openclaw-supermemory", + }, +} + +interface ConnectedPlugin { + id: string + keyId: string + pluginId: string + createdAt: string + lastUsed?: string | null + keyStart?: string | null +} + +export function PluginsDetail() { + const { org } = useAuth() + const autumn = useCustomer() + const queryClient = useQueryClient() + const [connectingPlugin, setConnectingPlugin] = useState(null) + const [newKey, setNewKey] = useState<{ open: boolean; key: string }>({ open: false, key: "" }) + const [keyCopied, setKeyCopied] = useState(false) + + const { data: status = { api_pro: { allowed: false, status: null } }, isLoading: isCheckingStatus } = + fetchSubscriptionStatus(autumn, !autumn.isLoading) + + const hasProProduct = status.api_pro?.status !== null + + const { data: pluginsData } = useQuery({ + queryFn: async () => { + const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + const res = await fetch(`${API_URL}/v3/auth/plugins`, { credentials: "include" }) + if (!res.ok) throw new Error("Failed to fetch plugins") + return (await res.json()) as { plugins: string[] } + }, + queryKey: ["plugins"], + }) + + const { data: apiKeys = [], refetch: refetchKeys } = useQuery({ + enabled: !!org?.id, + queryFn: async () => { + if (!org?.id) return [] + const data = await authClient.apiKey.list({ + fetchOptions: { query: { metadata: { organizationId: org.id } } }, + }) + return data.filter((key) => key.metadata?.organizationId === org.id) + }, + queryKey: ["api-keys", org?.id], + }) + + const connectedPlugins = useMemo(() => { + const plugins: ConnectedPlugin[] = [] + for (const key of apiKeys) { + if (!key.metadata) continue + try { + const metadata = typeof key.metadata === "string" + ? (JSON.parse(key.metadata) as { sm_type?: string; sm_client?: string }) + : (key.metadata as { sm_type?: string; sm_client?: string }) + + if (metadata.sm_type === "plugin_auth" && metadata.sm_client) { + plugins.push({ + id: key.id, + keyId: key.id, + pluginId: metadata.sm_client, + createdAt: key.createdAt.toISOString(), + lastUsed: key.lastRequest?.toISOString() ?? null, + keyStart: key.start ?? null, + }) + } + } catch {} + } + return plugins + }, [apiKeys]) + + const connectedPluginIds = useMemo(() => connectedPlugins.map((p) => p.pluginId), [connectedPlugins]) + + const createPluginKeyMutation = useMutation({ + mutationFn: async (pluginId: string) => { + const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + const params = new URLSearchParams({ client: pluginId }) + const res = await fetch(`${API_URL}/v3/auth/key?${params}`, { credentials: "include" }) + if (!res.ok) { + const errorData = (await res.json().catch(() => ({}))) as { message?: string } + throw new Error(errorData.message || "Failed to create plugin key") + } + return (await res.json()) as { key: string } + }, + onMutate: (pluginId) => setConnectingPlugin(pluginId), + onError: (err) => { + toast.error("Failed to connect plugin", { + description: err instanceof Error ? err.message : "Unknown error", + }) + }, + onSettled: () => { + setConnectingPlugin(null) + queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] }) + }, + onSuccess: (data) => { + setNewKey({ open: true, key: data.key }) + toast.success("Plugin connected!") + }, + }) + + const handleRevoke = async (keyId: string) => { + try { + await authClient.apiKey.delete({ keyId }) + toast.success("Plugin disconnected") + refetchKeys() + } catch { + toast.error("Failed to disconnect plugin") + } + } + + const handleUpgrade = async () => { + try { + await autumn.attach({ productId: "api_pro", successUrl: "https://app.supermemory.ai/?view=integrations" }) + window.location.reload() + } catch (error) { + console.error(error) + } + } + + const handleCopyKey = async () => { + try { + await navigator.clipboard.writeText(newKey.key) + setKeyCopied(true) + setTimeout(() => setKeyCopied(false), 2000) + toast.success("API key copied!") + } catch { + toast.error("Failed to copy") + } + } + + const isLoading = autumn.isLoading || isCheckingStatus + const availablePlugins = pluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG) + + return ( + <> + {/* Marketing hero for free users */} + {!hasProProduct && !isLoading && ( +
+
+
+
+ +
+
+

+ Unlock Persistent Memory for Your Tools +

+

+ Upgrade to Pro to connect plugins and give your AI tools long-term memory +

+
+
+ +
+ {[ + { icon: Brain, title: "Context Retention", desc: "AI remembers your preferences across sessions" }, + { icon: Zap, title: "Instant Recall", desc: "Past decisions surface automatically when relevant" }, + { icon: Key, title: "Secure & Private", desc: "Your data stays yours with encrypted storage" }, + ].map(({ icon: Icon, title, desc }) => ( +
+ +
+

{title}

+

{desc}

+
+
+ ))} +
+ +
+ {Object.values(PLUGIN_CATALOG).map((plugin) => ( +
+ {plugin.name} +
+ ))} + + Claude Code, OpenCode, ClawdBot & more + +
+ + +
+
+ )} + +
+
+ {/* Connected plugins */} + {connectedPlugins.length > 0 && ( +
+ + Connected Plugins + + {connectedPlugins.map((plugin) => { + const info = PLUGIN_CATALOG[plugin.pluginId] + return ( +
+
+ {info && ( +
+ {info.name} +
+ )} +
+

+ {info?.name || plugin.pluginId} +

+
+
+ Connected + {plugin.keyStart && ( + {plugin.keyStart}... + )} +
+
+ +
+
+ ) + })} +
+ )} + + {/* Available plugins */} +
+ + {connectedPlugins.length > 0 ? "Add More Plugins" : "Available Plugins"} + +
+ {availablePlugins.map((pluginId) => { + const plugin = PLUGIN_CATALOG[pluginId] + if (!plugin) return null + + const isConnected = connectedPluginIds.includes(pluginId) + const isCurrentlyConnecting = connectingPlugin === pluginId + + return ( +
+
+
+ {plugin.name} +
+
+
+ {plugin.name} + {isConnected && ( + + Connected + + )} +
+

{plugin.description}

+
+
+ +
    + {plugin.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ +
+ {isConnected ? ( + + ) : ( + + )} +
+ {plugin.docsUrl && ( + + Docs + + )} + {plugin.repoUrl && ( + + GitHub + + )} +
+
+
+ ) + })} +
+
+
+
+ + {/* API Key modal */} + setNewKey({ open, key: open ? newKey.key : "" })}> + + + + + Plugin Connected + + +
+

+ Save your API key now — you won't be able to see it again. +

+
+ + +
+ +
+
+
+
+ + ) +} diff --git a/apps/web/components/new/integrations/raycast-detail.tsx b/apps/web/components/new/integrations/raycast-detail.tsx new file mode 100644 index 000000000..29ecdf209 --- /dev/null +++ b/apps/web/components/new/integrations/raycast-detail.tsx @@ -0,0 +1,201 @@ +"use client" + +import { cn } from "@lib/utils" +import { dmSans125ClassName } from "@/lib/fonts" +import { RaycastIcon } from "@/components/new/integration-icons" +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { generateId } from "@lib/generate-id" +import { RAYCAST_EXTENSION_URL } from "@repo/lib/constants" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogPortal, +} from "@ui/components/dialog" +import { useMutation } from "@tanstack/react-query" +import { Check, Copy, Download, Key, Loader } from "lucide-react" +import { useId, useState } from "react" +import { toast } from "sonner" + +function PillButton({ + children, + onClick, + disabled, +}: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean +}) { + return ( + + ) +} + +export function RaycastDetail() { + const { org } = useAuth() + const [showModal, setShowModal] = useState(false) + const [apiKey, setApiKey] = useState("") + const [copied, setCopied] = useState(false) + const apiKeyId = useId() + + const handleCopy = async (key: string) => { + try { + await navigator.clipboard.writeText(key) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + toast.success("API key copied to clipboard!") + } catch { + toast.error("Failed to copy API key") + } + } + + const createKeyMutation = useMutation({ + mutationFn: async () => { + if (!org?.id) throw new Error("Organization ID is required") + const res = await authClient.apiKey.create({ + metadata: { organizationId: org.id, type: "raycast-extension" }, + name: `raycast-${generateId().slice(0, 8)}`, + prefix: `sm_${org.id}_`, + }) + return res.key + }, + onSuccess: (key) => { + setApiKey(key) + setShowModal(true) + setCopied(false) + handleCopy(key) + }, + onError: (error) => { + toast.error("Failed to create API key", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + return ( + <> +
+
+
+ +
+

+ Raycast Extension +

+

+ Add and search memories from Mac and Windows +

+
+
+ +
+ createKeyMutation.mutate()} disabled={createKeyMutation.isPending}> + {createKeyMutation.isPending ? ( + + ) : ( + + )} + + {createKeyMutation.isPending ? "Generating..." : "Get API key"} + + + window.open(RAYCAST_EXTENSION_URL, "_blank")}> + + Install extension + +
+
+
+ + { + setShowModal(open) + if (!open) { setApiKey(""); setCopied(false) } + }}> + + + + + Setup Raycast Extension + + +
+
+ +
+ + +
+
+
+

Follow these steps:

+
+ {[ + "Install the Raycast extension from the Raycast Store", + "Open Raycast preferences and paste your API key", + "Use \"Add Memory\" or \"Search Memories\" commands!", + ].map((text, i) => ( +
+
+ {i + 1} +
+

{text}

+
+ ))} +
+
+ +
+
+
+
+ + ) +} diff --git a/apps/web/components/new/integrations/shortcuts-detail.tsx b/apps/web/components/new/integrations/shortcuts-detail.tsx new file mode 100644 index 000000000..6a3f7368e --- /dev/null +++ b/apps/web/components/new/integrations/shortcuts-detail.tsx @@ -0,0 +1,234 @@ +"use client" + +import { cn } from "@lib/utils" +import { dmSans125ClassName } from "@/lib/fonts" +import { AppleShortcutsIcon } from "@/components/new/integration-icons" +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { generateId } from "@lib/generate-id" +import { + ADD_MEMORY_SHORTCUT_URL, + SEARCH_MEMORY_SHORTCUT_URL, +} from "@repo/lib/constants" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogPortal, +} from "@ui/components/dialog" +import { useMutation } from "@tanstack/react-query" +import { Check, Copy, Loader, Plus, Search } from "lucide-react" +import Image from "next/image" +import { useId, useState } from "react" +import { toast } from "sonner" + +function PillButton({ + children, + onClick, + disabled, +}: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean +}) { + return ( + + ) +} + +export function ShortcutsDetail() { + const { org } = useAuth() + const [showApiKeyModal, setShowApiKeyModal] = useState(false) + const [apiKey, setApiKey] = useState("") + const [copied, setCopied] = useState(false) + const [selectedShortcutType, setSelectedShortcutType] = useState< + "add" | "search" | null + >(null) + const apiKeyId = useId() + + const handleCopyApiKey = async (key: string) => { + try { + await navigator.clipboard.writeText(key) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + toast.success("API key copied to clipboard!") + } catch { + toast.error("Failed to copy API key") + } + } + + const createApiKeyMutation = useMutation({ + mutationFn: async () => { + const res = await authClient.apiKey.create({ + metadata: { organizationId: org?.id, type: "ios-shortcut" }, + name: `ios-${generateId().slice(0, 8)}`, + prefix: `sm_${org?.id}_`, + }) + return res.key + }, + onSuccess: (key) => { + setApiKey(key) + setShowApiKeyModal(true) + setCopied(false) + handleCopyApiKey(key) + }, + onError: (error) => { + toast.error("Failed to create API key", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) + + const handleShortcutClick = (type: "add" | "search") => { + setSelectedShortcutType(type) + createApiKeyMutation.mutate() + } + + const handleOpenShortcut = () => { + if (selectedShortcutType === "add") { + window.open(ADD_MEMORY_SHORTCUT_URL, "_blank") + } else if (selectedShortcutType === "search") { + window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank") + } + } + + return ( + <> +
+
+
+ +
+

+ Apple Shortcuts +

+

+ Add memories directly from iPhone, iPad or Mac +

+
+
+ +
+ handleShortcutClick("add")} + disabled={createApiKeyMutation.isPending} + > + {createApiKeyMutation.isPending && selectedShortcutType === "add" ? ( + + ) : ( + + )} + + {createApiKeyMutation.isPending && selectedShortcutType === "add" + ? "Creating..." + : "Add memory shortcut"} + + + handleShortcutClick("search")} + disabled={createApiKeyMutation.isPending} + > + {createApiKeyMutation.isPending && selectedShortcutType === "search" ? ( + + ) : ( + + )} + + {createApiKeyMutation.isPending && selectedShortcutType === "search" + ? "Creating..." + : "Search memory shortcut"} + + +
+
+
+ + { + setShowApiKeyModal(open) + if (!open) { setSelectedShortcutType(null); setApiKey(""); setCopied(false) } + }}> + + + + + Setup Apple Shortcut + + +
+
+ +
+ + +
+
+
+

Follow these steps:

+
+ {["Click \"Add to Shortcuts\" below to open the shortcut", "Paste your API key when prompted", "Start using your shortcut!"].map((text, i) => ( +
+
+ {i + 1} +
+

{text}

+
+ ))} +
+
+ +
+
+
+
+ + ) +} diff --git a/apps/web/components/new/onboarding/welcome/profile-step.tsx b/apps/web/components/new/onboarding/welcome/profile-step.tsx index 4e320bfaa..6bb67f424 100644 --- a/apps/web/components/new/onboarding/welcome/profile-step.tsx +++ b/apps/web/components/new/onboarding/welcome/profile-step.tsx @@ -2,13 +2,12 @@ import { motion } from "motion/react" import { Button } from "@ui/components/button" import { useState } from "react" import { useRouter } from "next/navigation" -import { cn } from "@lib/utils" -import { dmSansClassName } from "@/lib/fonts" import { parseXHandle, parseLinkedInHandle, toXProfileUrl, toLinkedInProfileUrl, + normalizeUrl, } from "@/lib/url-helpers" import { analytics } from "@/lib/analytics" @@ -77,16 +76,28 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) { } const handleTwitterChange = (value: string) => { - const parsedHandle = parseXHandle(value) - setTwitterHandle(parsedHandle) - const error = validateTwitterHandle(parsedHandle) + setTwitterHandle(value) + setErrors((prev) => ({ ...prev, twitter: null })) + } + + const handleTwitterBlur = () => { + if (!twitterHandle.trim()) return + const parsed = parseXHandle(twitterHandle) + setTwitterHandle(parsed) + const error = validateTwitterHandle(parsed) setErrors((prev) => ({ ...prev, twitter: error })) } const handleLinkedInChange = (value: string) => { - const parsedHandle = parseLinkedInHandle(value) - setLinkedinProfile(parsedHandle) - const error = validateLinkedInHandle(parsedHandle) + setLinkedinProfile(value) + setErrors((prev) => ({ ...prev, linkedin: null })) + } + + const handleLinkedInBlur = () => { + if (!linkedinProfile.trim()) return + const parsed = parseLinkedInHandle(linkedinProfile) + setLinkedinProfile(parsed) + const error = validateLinkedInHandle(parsed) setErrors((prev) => ({ ...prev, linkedin: error })) } @@ -109,48 +120,19 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) { > X/Twitter -
-
-
- x.com/ -
- handleTwitterChange(e.target.value)} - onBlur={() => { - if (twitterHandle.trim()) { - const error = validateTwitterHandle(twitterHandle) - setErrors((prev) => ({ ...prev, twitter: error })) - } - }} - className={`flex-1 px-4 py-2 bg-[#070E1B] text-white placeholder-onboarding focus:outline-none transition-colors ${ - errors.twitter ? "bg-[#290F0A]" : "" - }`} - /> -
- {errors.twitter && ( -
-
-
-

{errors.twitter}

-
-
- )} -
+ handleTwitterChange(e.target.value)} + onBlur={handleTwitterBlur} + className={`w-full px-4 py-2 bg-[#070E1B] border rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors h-[40px] ${ + errors.twitter + ? "border-[#52596633] bg-[#290F0A]" + : "border-onboarding/20" + }`} + />
@@ -160,48 +142,19 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) { > LinkedIn -
-
-
- linkedin.com/in/ -
- handleLinkedInChange(e.target.value)} - onBlur={() => { - if (linkedinProfile.trim()) { - const error = validateLinkedInHandle(linkedinProfile) - setErrors((prev) => ({ ...prev, linkedin: error })) - } - }} - className={`flex-1 px-4 py-2 bg-[#070E1B] text-white placeholder-onboarding focus:outline-none transition-colors ${ - errors.linkedin ? "bg-[#290F0A]" : "" - }`} - /> -
- {errors.linkedin && ( -
-
-
-

{errors.linkedin}

-
-
- )} -
+ handleLinkedInChange(e.target.value)} + onBlur={handleLinkedInBlur} + className={`w-full px-4 py-2 bg-[#070E1B] border rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors h-[40px] ${ + errors.linkedin + ? "border-[#52596633] bg-[#290F0A]" + : "border-onboarding/20" + }`} + />
{ const formData = { - twitter: toXProfileUrl(twitterHandle), - linkedin: toLinkedInProfileUrl(linkedinProfile), + twitter: toXProfileUrl(parseXHandle(twitterHandle)), + linkedin: toLinkedInProfileUrl(parseLinkedInHandle(linkedinProfile)), description: description, - otherLinks: otherLinks.filter((l) => l.trim()), + otherLinks: otherLinks + .filter((l) => l.trim()) + .map((l) => normalizeUrl(l.trim())), } analytics.onboardingProfileSubmitted({ has_twitter: !!twitterHandle.trim(), diff --git a/apps/web/components/new/settings/integrations.tsx b/apps/web/components/new/settings/integrations.tsx index f28695e7f..479a5852f 100644 --- a/apps/web/components/new/settings/integrations.tsx +++ b/apps/web/components/new/settings/integrations.tsx @@ -24,6 +24,11 @@ import Image from "next/image" import { useSearchParams } from "next/navigation" import { useEffect, useId, useState } from "react" import { toast } from "sonner" +import { + ChromeIcon, + AppleShortcutsIcon, + RaycastIcon, +} from "@/components/new/integration-icons" function SectionTitle({ children }: { children: React.ReactNode }) { return ( @@ -106,73 +111,6 @@ function FeatureItem({ text }: { text: string }) { ) } -function ChromeIcon({ className }: { className?: string }) { - return ( - - Google Chrome Icon - - - - - - - ) -} - -function AppleShortcutsIcon() { - return ( -
- Apple Shortcuts -
- ) -} - -function RaycastIcon({ className }: { className?: string }) { - return ( - - Raycast Icon - - - ) -} export default function Integrations() { const { org } = useAuth() diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 6d8c00ebe..adc72e132 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -29,7 +29,7 @@ export const analytics = { chatHistoryViewed: () => safeCapture("chat_history_viewed"), chatDeleted: () => safeCapture("chat_deleted"), - viewModeChanged: (mode: "graph" | "list") => + viewModeChanged: (mode: "graph" | "list" | "integrations") => safeCapture("view_mode_changed", { mode }), documentCardClicked: () => safeCapture("document_card_clicked"), @@ -84,7 +84,7 @@ export const analytics = { safeCapture("onboarding_integration_clicked", props), onboardingChromeExtensionClicked: (props: { - source: "onboarding" | "settings" + source: "onboarding" | "settings" | "integrations" }) => safeCapture("onboarding_chrome_extension_clicked", props), onboardingMcpDetailOpened: () => safeCapture("onboarding_mcp_detail_opened"), diff --git a/apps/web/lib/search-params.ts b/apps/web/lib/search-params.ts index 129331ca1..3a38939fd 100644 --- a/apps/web/lib/search-params.ts +++ b/apps/web/lib/search-params.ts @@ -23,6 +23,8 @@ export const shareParam = parseAsBoolean.withDefault(false) export const feedbackParam = parseAsBoolean.withDefault(false) // View & filter states -export const viewParam = parseAsStringLiteral(["graph", "list"] as const).withDefault("graph") +const viewLiterals = ["graph", "list", "integrations"] as const +export type ViewParamValue = (typeof viewLiterals)[number] +export const viewParam = parseAsStringLiteral(viewLiterals).withDefault("graph") export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault([]) export const projectParam = parseAsString.withDefault("sm_project_default") diff --git a/apps/web/lib/view-mode-context.tsx b/apps/web/lib/view-mode-context.tsx index c2a6837eb..e797b47ac 100644 --- a/apps/web/lib/view-mode-context.tsx +++ b/apps/web/lib/view-mode-context.tsx @@ -1,11 +1,13 @@ "use client" import { useQueryState } from "nuqs" -import { viewParam } from "@/lib/search-params" +import { viewParam, type ViewParamValue } from "@/lib/search-params" import { analytics } from "@/lib/analytics" import { useCallback } from "react" -type ViewMode = "graph" | "list" +export type ViewMode = ViewParamValue + +type SetViewMode = (value: ViewMode | null) => Promise export function useViewMode() { const [viewMode, _setViewMode] = useQueryState("view", viewParam) @@ -13,7 +15,7 @@ export function useViewMode() { const setViewMode = useCallback( (mode: ViewMode) => { analytics.viewModeChanged(mode) - _setViewMode(mode) + ;(_setViewMode as SetViewMode)(mode) }, [_setViewMode], ) diff --git a/apps/web/public/images/plugins/claude-code.svg b/apps/web/public/images/plugins/claude-code.svg new file mode 100644 index 000000000..210688d84 --- /dev/null +++ b/apps/web/public/images/plugins/claude-code.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/web/public/images/plugins/clawdbot.svg b/apps/web/public/images/plugins/clawdbot.svg new file mode 100644 index 000000000..f50fb48a7 --- /dev/null +++ b/apps/web/public/images/plugins/clawdbot.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/public/images/plugins/opencode.svg b/apps/web/public/images/plugins/opencode.svg new file mode 100644 index 000000000..b79140a50 --- /dev/null +++ b/apps/web/public/images/plugins/opencode.svg @@ -0,0 +1 @@ + \ No newline at end of file