fix: integrations page

This commit is contained in:
Dhravya Shah 2026-02-10 09:44:42 -08:00
parent a9e26bb8f9
commit f50e6234fe
19 changed files with 1895 additions and 239 deletions

View file

@ -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"
@ -31,7 +31,6 @@ import { useViewMode } from "@/lib/view-mode-context"
import { cn } from "@lib/utils"
import {
addDocumentParam,
mcpParam,
searchParam,
qParam,
docParam,
@ -45,12 +44,11 @@ type DocumentWithMemories = DocumentsResponse["documents"][0]
export default function NewPage() {
const isMobile = useIsMobile()
const { selectedProject } = useProject()
const { viewMode } = useViewMode()
const { viewMode, setViewMode } = useViewMode()
const queryClient = useQueryClient()
// 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 +282,6 @@ export default function NewPage() {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenMCP={() => {
analytics.mcpModalOpened()
setIsMCPOpen(true)
}}
onOpenChat={() => setIsChatOpen(true)}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
@ -302,7 +296,11 @@ export default function NewPage() {
)}
>
<div className={cn("relative z-10 flex flex-col md:flex-row h-full")}>
{viewMode === "graph" && !isMobile ? (
{viewMode === "integrations" ? (
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
<IntegrationsView />
</div>
) : viewMode === "graph" && !isMobile ? (
<div className="flex-1">
<GraphLayoutView isChatOpen={chatOpen} />
</div>
@ -354,10 +352,6 @@ export default function NewPage() {
onClose={() => setAddDoc(null)}
defaultTab={addDoc ?? undefined}
/>
<MCPModal
isOpen={isMCPOpen}
onClose={() => setIsMCPOpen(false)}
/>
<DocumentsCommandPalette
open={isSearchOpen}
onOpenChange={(open) => {
@ -370,10 +364,7 @@ export default function NewPage() {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenMCP={() => {
analytics.mcpModalOpened()
setIsMCPOpen(true)
}}
onOpenIntegrations={() => setViewMode("integrations")}
initialSearch={searchPrefill}
/>
<DocumentModal

View file

@ -15,7 +15,8 @@ import { useProject } from "@/stores"
import { toast } from "sonner"
import { useDocumentMutations } from "../../../hooks/use-document-mutations"
import { useCustomer } from "autumn-js/react"
import { useMemoriesUsage } from "@/hooks/use-memories-usage"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { tokensToCredits, formatUsageNumber } from "@/lib/billing-utils"
import { SpaceSelector } from "../space-selector"
import { useIsMobile } from "@hooks/use-mobile"
import { addDocumentParam } from "@/lib/search-params"
@ -131,12 +132,16 @@ export function AddDocument({
const autumn = useCustomer()
const {
memoriesUsed,
memoriesLimit,
hasProProduct,
isLoading: isLoadingMemories,
usagePercent,
} = useMemoriesUsage(autumn)
tokensUsed,
tokensLimit,
tokensPercent,
searchesUsed,
searchesLimit,
searchesPercent,
hasPaidPlan,
isLoading: isLoadingUsage,
} = useTokenUsage(autumn)
const [isUpgrading, setIsUpgrading] = useState(false)
useEffect(() => {
setLocalSelectedProject(globalSelectedProject)
@ -246,37 +251,96 @@ export function AddDocument({
{!isMobile && (
<div
data-testid="memories-counter"
className="bg-[#1B1F24] rounded-2xl p-4 mr-4"
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
data-testid="usage-counter"
className="flex flex-col gap-3 mr-4"
>
<div className="flex justify-between items-center">
<span
className={cn(
"text-white text-[16px] font-medium",
dmSansClassName(),
)}
>
Memories
</span>
<span className={cn("text-[#737373] text-sm", dmSansClassName())}>
{isLoadingMemories
? "…"
: hasProProduct
? "Unlimited"
: `${memoriesUsed}/${memoriesLimit}`}
</span>
</div>
{!hasProProduct && (
<div className="h-1.5 bg-[#0D121A] rounded-full overflow-hidden mt-2">
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<span className={cn("text-[#FAFAFA] text-sm font-medium", dmSansClassName())}>
Credits
</span>
<span className={cn("text-sm font-medium", hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]", dmSansClassName())}>
{isLoadingUsage ? "…" : `${tokensToCredits(tokensUsed)} / ${tokensToCredits(tokensLimit)}`}
</span>
</div>
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full bg-[#2261CA] rounded-full"
style={{ width: `${usagePercent}%` }}
className="h-full rounded-[40px]"
style={{
width: `${tokensPercent}%`,
background: tokensPercent > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<span className={cn("text-[#FAFAFA] text-sm font-medium", dmSansClassName())}>
Search Queries
</span>
<span className={cn("text-sm font-medium", hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]", dmSansClassName())}>
{isLoadingUsage ? "…" : `${formatUsageNumber(searchesUsed)} / ${formatUsageNumber(searchesLimit)}`}
</span>
</div>
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px]"
style={{
width: `${searchesPercent}%`,
background: searchesPercent > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
}}
/>
</div>
</div>
{!hasPaidPlan && (
<button
type="button"
onClick={async () => {
setIsUpgrading(true)
try {
await autumn.attach({
productId: "api_pro",
successUrl: "https://app.supermemory.ai/settings#account",
})
window.location.reload()
} catch (error) {
console.error(error)
setIsUpgrading(false)
}
}}
disabled={isUpgrading}
className={cn(
"relative w-full h-9 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[13px]",
"disabled:opacity-60 disabled:cursor-not-allowed",
"cursor-pointer transition-opacity hover:opacity-90",
dmSansClassName(),
)}
style={{
background: "linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow: "1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
>
{isUpgrading ? (
<>
<Loader2 className="size-3 animate-spin mr-1.5" />
Upgrading...
</>
) : (
"Upgrade to Pro"
)}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
)}
</div>
)}

View file

@ -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: <Code2 className="size-4 text-[#737373]" />,
action: () => { close(); onOpenMCP() },
action: () => { close(); onOpenIntegrations() },
}]
: []),
]

View file

@ -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({
</div>
{!isMobile && (
<Tabs
value={viewMode === "list" ? "grid" : "graph"}
onValueChange={(v) => setViewMode(v === "grid" ? "list" : "graph")}
value={viewMode === "list" ? "grid" : viewMode}
onValueChange={(v) => setViewMode(v === "grid" ? "list" : v as "graph" | "integrations")}
>
<TabsList className="rounded-full border border-[#161F2C] h-11! z-10!">
<TabsTrigger
@ -179,6 +178,16 @@ export function Header({
<LayoutGridIcon className="size-4" />
Graph
</TabsTrigger>
<TabsTrigger
value="integrations"
className={cn(
"rounded-full dark:data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4",
dmSansClassName(),
)}
>
<Cable className="size-4" />
Integrations
</TabsTrigger>
</TabsList>
</Tabs>
)}
@ -220,11 +229,11 @@ export function Header({
Add memory
</DropdownMenuItem>
<DropdownMenuItem
onClick={onOpenMCP}
onClick={() => setViewMode("integrations")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Code2 className="h-4 w-4 text-[#737373]" />
MCP
<Cable className="h-4 w-4 text-[#737373]" />
Integrations
</DropdownMenuItem>
<DropdownMenuItem
onClick={onOpenChat}
@ -271,13 +280,6 @@ export function Header({
C
</span>
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={onOpenMCP}
>
<div className="flex items-center gap-2">MCP</div>
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"

View file

@ -0,0 +1,69 @@
import Image from "next/image"
export function ChromeIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
viewBox="0 0 190.5 190.5"
className={className}
>
<title>Google Chrome Icon</title>
<path
fill="#fff"
d="M95.252 142.873c26.304 0 47.627-21.324 47.627-47.628s-21.323-47.628-47.627-47.628-47.627 21.324-47.627 47.628 21.323 47.628 47.627 47.628z"
/>
<path
fill="#229342"
d="m54.005 119.07-41.24-71.43a95.227 95.227 0 0 0-.003 95.25 95.234 95.234 0 0 0 82.496 47.61l41.24-71.43v-.011a47.613 47.613 0 0 1-17.428 17.443 47.62 47.62 0 0 1-47.632.007 47.62 47.62 0 0 1-17.433-17.437z"
/>
<path
fill="#fbc116"
d="m136.495 119.067-41.239 71.43a95.229 95.229 0 0 0 82.489-47.622A95.24 95.24 0 0 0 190.5 95.248a95.237 95.237 0 0 0-12.772-47.623H95.249l-.01.007a47.62 47.62 0 0 1 23.819 6.372 47.618 47.618 0 0 1 17.439 17.431 47.62 47.62 0 0 1-.001 47.633z"
/>
<path
fill="#1a73e8"
d="M95.252 132.961c20.824 0 37.705-16.881 37.705-37.706S116.076 57.55 95.252 57.55 57.547 74.431 57.547 95.255s16.881 37.706 37.705 37.706z"
/>
<path
fill="#e33b2e"
d="M95.252 47.628h82.479A95.237 95.237 0 0 0 142.87 12.76 95.23 95.23 0 0 0 95.245 0a95.222 95.222 0 0 0-47.623 12.767 95.23 95.23 0 0 0-34.856 34.872l41.24 71.43.011.006a47.62 47.62 0 0 1-.015-47.633 47.61 47.61 0 0 1 41.252-23.815z"
/>
</svg>
)
}
export function AppleShortcutsIcon() {
return (
<div className="relative size-10 shrink-0 rounded-lg overflow-hidden">
<Image
src="/images/ios-shortcuts.png"
alt="Apple Shortcuts"
width={40}
height={40}
className="object-cover"
/>
</div>
)
}
export function RaycastIcon({ className }: { className?: string }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>Raycast Icon</title>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 18.079V21L0 14L1.46 12.54L7 18.081V18.079ZM9.921 21H7L14 28L15.46 26.54L9.921 21ZM26.535 15.462L27.996 14L13.996 0L12.538 1.466L18.077 7.004H14.73L10.864 3.146L9.404 4.606L11.809 7.01H10.129V17.876H20.994V16.196L23.399 18.6L24.859 17.14L20.994 13.274V9.927L26.535 15.462ZM7.73 6.276L6.265 7.738L7.833 9.304L9.294 7.844L7.73 6.276ZM20.162 18.708L18.702 20.17L20.268 21.738L21.73 20.276L20.162 18.708ZM4.596 9.41L3.134 10.872L7 14.738V11.815L4.596 9.41ZM16.192 21.006H13.268L17.134 24.872L18.596 23.41L16.192 21.006Z"
fill="#FF6363"
/>
</svg>
)
}

View file

@ -0,0 +1,236 @@
"use client"
import { useState } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { Button } from "@ui/components/button"
import { MCPDetailView } from "@/components/new/mcp-modal/mcp-detail-view"
import { XBookmarksDetailView } from "@/components/new/onboarding/x-bookmarks-detail-view"
import { ChromeDetail } from "@/components/new/integrations/chrome-detail"
import { ShortcutsDetail } from "@/components/new/integrations/shortcuts-detail"
import { RaycastDetail } from "@/components/new/integrations/raycast-detail"
import { ConnectionsDetail } from "@/components/new/integrations/connections-detail"
import { PluginsDetail } from "@/components/new/integrations/plugins-detail"
import {
ChromeIcon,
AppleShortcutsIcon,
RaycastIcon,
} from "@/components/new/integration-icons"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { Cable, ArrowLeft } from "lucide-react"
import Image from "next/image"
type CardId =
| "mcp"
| "chrome"
| "connections"
| "shortcuts"
| "raycast"
| "import"
| "plugins"
interface IntegrationCardDef {
id: CardId
title: string
description: string
icon: React.ReactNode
pro?: boolean
}
const cards: IntegrationCardDef[] = [
{
id: "plugins",
title: "Plugins",
description: "Claude Code, OpenCode, and other AI tool integrations",
pro: true,
icon: (
<div className="flex items-center -space-x-1.5">
<Image src="/images/plugins/claude-code.svg" alt="Claude Code" width={24} height={24} className="size-6 rounded" />
<Image src="/images/plugins/opencode.svg" alt="OpenCode" width={24} height={24} className="size-6 rounded" />
<Image src="/images/plugins/clawdbot.svg" alt="ClawdBot" width={24} height={24} className="size-6 rounded" />
</div>
),
},
{
id: "connections",
title: "Connections",
description: "Link Notion, Google Drive, or OneDrive to import your docs",
pro: true,
icon: (
<div className="flex items-center -space-x-1">
<GoogleDrive className="size-5" />
<Notion className="size-5" />
<OneDrive className="size-5" />
</div>
),
},
{
id: "mcp",
title: "Connect to AI",
description: "Set up MCP to use your memory in Cursor, Claude, and more",
icon: (
<img
src="/onboarding/mcp.png"
alt="MCP"
className="size-20 h-auto"
/>
),
},
{
id: "chrome",
title: "Chrome Extension",
description: "Save any webpage, import bookmarks, sync ChatGPT memories",
icon: <ChromeIcon className="size-14" />,
},
{
id: "shortcuts",
title: "Apple Shortcuts",
description: "Add memories directly from iPhone, iPad or Mac",
icon: <AppleShortcutsIcon />,
},
{
id: "raycast",
title: "Raycast",
description: "Add and search memories from Raycast on Mac",
icon: <RaycastIcon className="size-10" />,
},
{
id: "import",
title: "Import Bookmarks",
description: "Bring in X/Twitter bookmarks and turn them into memories",
icon: (
<img src="/onboarding/x.png" alt="X" className="size-10" />
),
},
]
function DetailWrapper({
onBack,
children,
}: {
onBack: () => void
children: React.ReactNode
}) {
return (
<div className="flex-1 p-4 md:p-6 pt-2">
<div className="max-w-3xl mx-auto">
<Button
variant="link"
className="text-white hover:text-gray-300 p-0 hover:no-underline cursor-pointer mb-4"
onClick={onBack}
>
<ArrowLeft className="size-4 mr-1" />
Back to Integrations
</Button>
{children}
</div>
</div>
)
}
export function IntegrationsView() {
const [selectedCard, setSelectedCard] = useState<CardId | null>(null)
if (selectedCard === "mcp") {
return <MCPDetailView onBack={() => setSelectedCard(null)} />
}
if (selectedCard === "import") {
return <XBookmarksDetailView onBack={() => setSelectedCard(null)} />
}
if (selectedCard === "chrome") {
return (
<DetailWrapper onBack={() => setSelectedCard(null)}>
<ChromeDetail />
</DetailWrapper>
)
}
if (selectedCard === "shortcuts") {
return (
<DetailWrapper onBack={() => setSelectedCard(null)}>
<ShortcutsDetail />
</DetailWrapper>
)
}
if (selectedCard === "raycast") {
return (
<DetailWrapper onBack={() => setSelectedCard(null)}>
<RaycastDetail />
</DetailWrapper>
)
}
if (selectedCard === "connections") {
return (
<DetailWrapper onBack={() => setSelectedCard(null)}>
<ConnectionsDetail />
</DetailWrapper>
)
}
if (selectedCard === "plugins") {
return (
<DetailWrapper onBack={() => setSelectedCard(null)}>
<PluginsDetail />
</DetailWrapper>
)
}
return (
<div className="flex-1 p-4 md:p-6 pt-2">
<div className="max-w-3xl mx-auto">
<div className="mb-6 space-y-1">
<div className="flex items-center gap-2">
<Cable className="size-5 text-[#4BA0FA]" />
<h2 className="text-white text-xl font-medium">Integrations</h2>
</div>
<p
className={cn(
"text-[#8B8B8B] text-sm",
dmSansClassName(),
)}
>
Connect supermemory to your tools and workflows
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{cards.map((card) => (
<button
key={card.id}
type="button"
onClick={() => setSelectedCard(card.id)}
className={cn(
"bg-[#080B0F] relative rounded-xl p-4 pt-14",
"border border-[#0D121A]",
"hover:border-[#3374FF]/50",
"transition-all duration-300 cursor-pointer text-left w-full",
"hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:200%_auto] hover:bg-[center_top_1rem] hover:bg-no-repeat",
"group",
)}
>
{card.pro && (
<span className="absolute top-3 left-3 bg-[#4BA0FA] text-[#00171A] text-[10px] font-bold tracking-[0.3px] px-1.5 py-0.5 rounded-[3px]">
PRO
</span>
)}
<div className="absolute top-2 right-2 opacity-60 group-hover:opacity-100 transition-opacity">
{card.icon}
</div>
<div className="flex-1">
<h3 className="text-white text-sm font-medium">
{card.title}
</h3>
<p
className={cn(
"text-[#8B8B8B] text-xs leading-relaxed mt-0.5",
dmSansClassName(),
)}
>
{card.description}
</p>
</div>
</button>
))}
</div>
</div>
</div>
)
}

View file

@ -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 (
<button
type="button"
onClick={onClick}
className={cn(
"relative flex items-center justify-center gap-2",
"bg-[#0D121A]",
"rounded-full h-11 px-6",
"cursor-pointer transition-opacity hover:opacity-80",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
dmSans125ClassName(),
)}
>
{children}
</button>
)
}
export function ChromeDetail() {
const handleInstall = () => {
window.open(
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
"_blank",
"noopener,noreferrer",
)
analytics.onboardingChromeExtensionClicked({ source: "integrations" })
}
return (
<div
className={cn(
"bg-[#14161A] rounded-[14px] p-6",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<ChromeIcon className="shrink-0 w-10 h-10" />
<div className="flex flex-col gap-1">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] text-[#FAFAFA]",
)}
>
Chrome Extension
</p>
<p
className={cn(
dmSans125ClassName(),
"text-[14px] text-[#737373]",
)}
>
Save any webpage directly from your browser
</p>
</div>
</div>
<PillButton onClick={handleInstall}>
<Download className="size-4 text-[#FAFAFA]" />
<span className="text-[14px] text-[#FAFAFA] font-medium">
Add to Chrome
</span>
</PillButton>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[
"Import all Twitter bookmarks",
"Sync ChatGPT memories",
"Save any webpage",
"One time setup",
].map((text) => (
<div key={text} className="flex items-center gap-2">
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
<span
className={cn(
dmSans125ClassName(),
"text-[14px] text-white",
)}
>
{text}
</span>
</div>
))}
</div>
</div>
</div>
)
}

View file

@ -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<typeof ConnectionResponseSchema>
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 (
<div className={cn("bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] px-4 py-3", "shadow-[0px_1px_2px_0px_rgba(0,43,87,0.1)]")}>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<Icon className="size-6 shrink-0" />
<div className="flex-1 flex flex-col gap-1">
<div className="flex items-center gap-3">
<span className={cn(dmSans125ClassName(), "font-medium text-[16px] text-[#FAFAFA]")}>{config.title}</span>
<div className="flex items-center gap-2">
<div className={cn("size-[7px] rounded-full", isConnected ? "bg-[#00AC3F]" : "bg-[#737373]")} />
<span className={cn(dmSans125ClassName(), "text-[14px]", isConnected ? "text-[#00AC3F]" : "text-[#737373]")}>
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
</div>
<span className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}>{connection.email || "Unknown"}</span>
</div>
<button
type="button"
onClick={onDelete}
disabled={isDeleting || disabled}
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 className="size-[22px]" />
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
{projectName && (
<>
<span className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}>Project: {projectName}</span>
<div className="size-[3px] rounded-full bg-[#737373]" />
</>
)}
<span className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}>Added: {formatRelativeTime(connection.createdAt)}</span>
<div className="size-[3px] rounded-full bg-[#737373]" />
<span className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}>{documentCount} {config.documentLabel} connected</span>
</div>
</div>
</div>
)
}
export function ConnectionsDetail() {
const queryClient = useQueryClient()
const autumn = useCustomer()
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
const projects = (queryClient.getQueryData<Project[]>(["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 (
<>
<div className={cn("bg-[#14161A] rounded-[14px] p-6 relative overflow-hidden", "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]")}>
{!hasProProduct && !isLoading && (
<>
<div className="absolute inset-0 bg-[#14161A]/80 backdrop-blur-sm z-5" />
<div className="absolute inset-0 flex items-center justify-center z-10">
<div className="flex flex-col items-center gap-4">
<Zap className="size-6 text-[#737373]" />
<p className={cn(dmSans125ClassName(), "text-[14px] text-[#737373] text-center max-w-[220px]")}>
Connect Google Drive, Notion, and OneDrive to import your knowledge
</p>
<div className="flex flex-col gap-2">
{["Unlimited memories", "10 connections", "Advanced search", "Priority support"].map((text) => (
<div key={text} className="flex items-center gap-2">
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
<span className={cn(dmSans125ClassName(), "text-[14px] text-white")}>{text}</span>
</div>
))}
</div>
<button
type="button"
onClick={handleUpgrade}
className={cn(
"flex items-center justify-center gap-2",
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
"rounded-full h-10 px-6 font-medium text-sm transition-colors cursor-pointer",
dmSans125ClassName(),
)}
>
Upgrade to Pro
</button>
</div>
</div>
</>
)}
<div className={cn("flex flex-col gap-4", !hasProProduct && !isLoading && "opacity-30 pointer-events-none")}>
<div className="flex items-center justify-between">
<span className={cn(dmSans125ClassName(), "font-semibold text-[16px] text-[#FAFAFA]")}>
Connected to Supermemory
</span>
<span className={cn(dmSans125ClassName(), "font-semibold text-[16px] text-[#737373]")}>
{connections.length}/{connectionsLimit} connections used
</span>
</div>
<div className="flex flex-col gap-4">
{isLoadingConnections ? (
<div className="flex items-center justify-center py-8">
<div className="size-6 border-2 border-[#737373] border-t-transparent rounded-full animate-spin" />
</div>
) : connections.length > 0 ? (
connections.map((connection) => (
<ConnectionRow
key={connection.id}
connection={connection}
onDelete={() => deleteConnectionMutation.mutate(connection.id)}
isDeleting={deleteConnectionMutation.isPending}
disabled={!hasProProduct}
projects={projects}
/>
))
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Zap className="size-6 text-[#737373] mb-2" />
<p className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}>No connections yet</p>
<p className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}>Connect a service below to import your knowledge</p>
</div>
)}
</div>
<button
type="button"
onClick={() => setIsAddDocumentOpen(true)}
disabled={!hasProProduct || !canAddConnection}
className={cn(
"relative flex items-center justify-center gap-2",
"bg-[#0D121A] rounded-full h-11 px-4 w-full",
"cursor-pointer transition-opacity hover:opacity-80",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"disabled:opacity-50 disabled:cursor-not-allowed",
dmSans125ClassName(),
)}
>
<Plus className="size-[10px] text-[#FAFAFA]" />
<span className="text-[14px] text-[#FAFAFA] font-medium">Connect knowledge bases</span>
</button>
</div>
</div>
<AddDocumentModal isOpen={isAddDocumentOpen} onClose={() => setIsAddDocumentOpen(false)} defaultTab="connect" />
</>
)
}

View file

@ -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<string, PluginInfo> = {
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<string | null>(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<ConnectedPlugin[]>(() => {
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 && (
<div className={cn(
"bg-gradient-to-br from-[#0D121A] to-[#14161A] rounded-[14px] p-6 border border-[#4BA0FA]/20",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}>
<div className="flex flex-col gap-5">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#4BA0FA]/10 shrink-0">
<Zap className="size-6 text-[#4BA0FA]" />
</div>
<div className="flex-1">
<h3 className={cn(dmSans125ClassName(), "font-semibold text-[18px] text-[#FAFAFA]")}>
Unlock Persistent Memory for Your Tools
</h3>
<p className={cn(dmSans125ClassName(), "text-[14px] text-[#737373] mt-1")}>
Upgrade to Pro to connect plugins and give your AI tools long-term memory
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{[
{ 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 }) => (
<div key={title} className="flex items-start gap-2.5">
<Icon className="mt-0.5 size-4 text-[#4BA0FA] shrink-0" />
<div>
<p className={cn(dmSans125ClassName(), "text-[13px] font-medium text-[#FAFAFA]")}>{title}</p>
<p className={cn(dmSans125ClassName(), "text-[11px] text-[#737373]")}>{desc}</p>
</div>
</div>
))}
</div>
<div className="flex items-center gap-3">
{Object.values(PLUGIN_CATALOG).map((plugin) => (
<div key={plugin.id} className="flex h-9 w-9 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
<Image alt={plugin.name} className="size-5" height={20} src={plugin.icon} width={20} />
</div>
))}
<span className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}>
Claude Code, OpenCode, ClawdBot & more
</span>
</div>
<button
type="button"
onClick={handleUpgrade}
className={cn(
"w-full sm:w-auto flex items-center justify-center gap-2",
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
"rounded-full h-11 px-6 font-medium text-sm transition-colors cursor-pointer",
dmSans125ClassName(),
)}
>
Upgrade to Pro
</button>
</div>
</div>
)}
<div className={cn("bg-[#14161A] rounded-[14px] p-6 relative overflow-hidden", "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]")}>
<div className={cn("flex flex-col gap-6", !hasProProduct && !isLoading && "opacity-30 pointer-events-none")}>
{/* Connected plugins */}
{connectedPlugins.length > 0 && (
<div className="flex flex-col gap-3">
<span className={cn(dmSans125ClassName(), "font-semibold text-[16px] text-[#FAFAFA]")}>
Connected Plugins
</span>
{connectedPlugins.map((plugin) => {
const info = PLUGIN_CATALOG[plugin.pluginId]
return (
<div
key={plugin.id}
className={cn("bg-[#0D121A] border border-[rgba(82,89,102,0.2)] rounded-[12px] px-4 py-3", "shadow-[0px_1px_2px_0px_rgba(0,43,87,0.1)]")}
>
<div className="flex items-center gap-3">
{info && (
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
<Image alt={info.name} className="size-6" height={24} src={info.icon} width={24} />
</div>
)}
<div className="flex-1">
<p className={cn(dmSans125ClassName(), "font-medium text-[14px] text-[#FAFAFA]")}>
{info?.name || plugin.pluginId}
</p>
<div className="flex items-center gap-2">
<div className="size-[7px] rounded-full bg-[#00AC3F]" />
<span className={cn(dmSans125ClassName(), "text-[12px] text-[#00AC3F]")}>Connected</span>
{plugin.keyStart && (
<span className={cn(dmSans125ClassName(), "text-[12px] text-[#737373] font-mono")}>{plugin.keyStart}...</span>
)}
</div>
</div>
<button
type="button"
onClick={() => handleRevoke(plugin.keyId)}
className="text-[#737373] hover:text-red-400 transition-colors"
>
<Trash2 className="size-4" />
</button>
</div>
</div>
)
})}
</div>
)}
{/* Available plugins */}
<div className="flex flex-col gap-3">
<span className={cn(dmSans125ClassName(), "font-semibold text-[16px] text-[#FAFAFA]")}>
{connectedPlugins.length > 0 ? "Add More Plugins" : "Available Plugins"}
</span>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{availablePlugins.map((pluginId) => {
const plugin = PLUGIN_CATALOG[pluginId]
if (!plugin) return null
const isConnected = connectedPluginIds.includes(pluginId)
const isCurrentlyConnecting = connectingPlugin === pluginId
return (
<div
key={pluginId}
className={cn(
"bg-[#0D121A] rounded-[12px] p-4 flex flex-col gap-3 border",
isConnected ? "border-[#4BA0FA]/30" : "border-[rgba(82,89,102,0.2)]",
)}
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
<Image alt={plugin.name} className="size-6" height={24} src={plugin.icon} width={24} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className={cn(dmSans125ClassName(), "font-medium text-[14px] text-[#FAFAFA]")}>{plugin.name}</span>
{isConnected && (
<span className="flex items-center gap-1 text-[10px] text-[#00AC3F] border border-[#00AC3F]/30 rounded-full px-1.5 py-0.5">
<CheckCircle className="size-2.5" /> Connected
</span>
)}
</div>
<p className={cn(dmSans125ClassName(), "text-[12px] text-[#737373] mt-0.5")}>{plugin.description}</p>
</div>
</div>
<ul className="space-y-1.5">
{plugin.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<ArrowRight className="mt-0.5 size-3 shrink-0 text-[#4BA0FA]" />
<span className={cn(dmSans125ClassName(), "text-[12px] text-[#8B8B8B]")}>{feature}</span>
</li>
))}
</ul>
<div className="mt-auto flex flex-col gap-2">
{isConnected ? (
<button
type="button"
disabled
className={cn(
"w-full flex items-center justify-center gap-2 rounded-full h-9 px-4 text-[12px] font-medium",
"bg-[#080B0F] text-[#737373] border border-[#1E293B] opacity-60",
dmSans125ClassName(),
)}
>
<CheckCircle className="size-3.5" /> Already Connected
</button>
) : (
<button
type="button"
onClick={() => createPluginKeyMutation.mutate(pluginId)}
disabled={!!connectingPlugin}
className={cn(
"w-full flex items-center justify-center gap-2 rounded-full h-9 px-4 text-[12px] font-medium",
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white transition-colors cursor-pointer",
"disabled:opacity-50 disabled:cursor-not-allowed",
dmSans125ClassName(),
)}
>
{isCurrentlyConnecting ? (
<><Loader className="size-3.5 animate-spin" /> Connecting...</>
) : (
"Connect Plugin"
)}
</button>
)}
<div className="flex gap-2">
{plugin.docsUrl && (
<a
href={plugin.docsUrl}
target="_blank"
rel="noopener noreferrer"
className={cn("flex-1 flex items-center justify-center gap-1 text-[11px] text-[#737373] hover:text-white transition-colors", dmSans125ClassName())}
>
<BookOpen className="size-3" /> Docs
</a>
)}
{plugin.repoUrl && (
<a
href={plugin.repoUrl}
target="_blank"
rel="noopener noreferrer"
className={cn("flex-1 flex items-center justify-center gap-1 text-[11px] text-[#737373] hover:text-white transition-colors", dmSans125ClassName())}
>
<ExternalLink className="size-3" /> GitHub
</a>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
{/* API Key modal */}
<Dialog open={newKey.open} onOpenChange={(open) => setNewKey({ open, key: open ? newKey.key : "" })}>
<DialogPortal>
<DialogContent className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100">
<DialogHeader>
<DialogTitle className={cn(dmSans125ClassName(), "text-[#FAFAFA] text-lg font-semibold")}>
Plugin Connected
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className={cn(dmSans125ClassName(), "text-sm text-[#737373]")}>
Save your API key now you won't be able to see it again.
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={newKey.key}
readOnly
className={cn("flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono", dmSans125ClassName())}
/>
<button
type="button"
onClick={handleCopyKey}
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
>
{keyCopied ? <Check className="h-4 w-4 text-[#4BA0FA]" /> : <Copy className="h-4 w-4" />}
</button>
</div>
<button
type="button"
onClick={() => {
handleCopyKey()
setNewKey({ open: false, key: "" })
}}
className={cn(
"w-full flex items-center justify-center gap-2",
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
"rounded-lg h-11 px-4 font-medium text-sm transition-colors",
dmSans125ClassName(),
)}
>
<Copy className="size-4" /> Copy & Close
</button>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</>
)
}

View file

@ -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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"relative flex items-center justify-center gap-2",
"bg-[#0D121A]",
"rounded-full h-11 px-4 flex-1",
"cursor-pointer transition-opacity hover:opacity-80",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"disabled:opacity-50 disabled:cursor-not-allowed",
dmSans125ClassName(),
)}
>
{children}
</button>
)
}
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 (
<>
<div
className={cn(
"bg-[#14161A] rounded-[14px] p-6",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<RaycastIcon className="shrink-0 w-10 h-10" />
<div className="flex flex-col gap-1">
<p className={cn(dmSans125ClassName(), "font-semibold text-[16px] text-[#FAFAFA]")}>
Raycast Extension
</p>
<p className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}>
Add and search memories from Mac and Windows
</p>
</div>
</div>
<div className="flex gap-4">
<PillButton onClick={() => createKeyMutation.mutate()} disabled={createKeyMutation.isPending}>
{createKeyMutation.isPending ? (
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
) : (
<Key className="size-4 text-[#FAFAFA]" />
)}
<span className="text-[14px] text-[#FAFAFA] font-medium">
{createKeyMutation.isPending ? "Generating..." : "Get API key"}
</span>
</PillButton>
<PillButton onClick={() => window.open(RAYCAST_EXTENSION_URL, "_blank")}>
<Download className="size-4 text-[#FAFAFA]" />
<span className="text-[14px] text-[#FAFAFA] font-medium">Install extension</span>
</PillButton>
</div>
</div>
</div>
<Dialog open={showModal} onOpenChange={(open) => {
setShowModal(open)
if (!open) { setApiKey(""); setCopied(false) }
}}>
<DialogPortal>
<DialogContent className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100">
<DialogHeader>
<DialogTitle className={cn(dmSans125ClassName(), "text-[#FAFAFA] text-lg font-semibold")}>
Setup Raycast Extension
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor={apiKeyId} className={cn(dmSans125ClassName(), "text-sm font-medium text-[#737373]")}>
Your Raycast API Key
</label>
<div className="flex items-center gap-2">
<input
id={apiKeyId}
type="text"
value={apiKey}
readOnly
className={cn("flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono", dmSans125ClassName())}
/>
<button
type="button"
onClick={() => handleCopy(apiKey)}
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
>
{copied ? <Check className="h-4 w-4 text-[#4BA0FA]" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-3">
<h4 className={cn(dmSans125ClassName(), "text-sm font-medium text-[#737373]")}>Follow these steps:</h4>
<div className="space-y-2">
{[
"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) => (
<div key={text} className="flex items-start gap-3">
<div className="shrink-0 w-6 h-6 bg-[#FF6363]/20 text-[#FF6363] rounded-full flex items-center justify-center text-xs font-medium">
{i + 1}
</div>
<p className={cn(dmSans125ClassName(), "text-sm text-[#737373]")}>{text}</p>
</div>
))}
</div>
</div>
<button
type="button"
onClick={() => window.open(RAYCAST_EXTENSION_URL, "_blank")}
className={cn(
"w-full flex items-center justify-center gap-2",
"bg-[#FF6363] hover:bg-[#FF6363]/90 text-white",
"rounded-lg h-11 px-4 font-medium text-sm transition-colors",
dmSans125ClassName(),
)}
>
<RaycastIcon className="size-4" />
Install Extension
</button>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</>
)
}

View file

@ -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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"relative flex items-center justify-center gap-2",
"bg-[#0D121A]",
"rounded-full h-11 px-4 flex-1",
"cursor-pointer transition-opacity hover:opacity-80",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"disabled:opacity-50 disabled:cursor-not-allowed",
dmSans125ClassName(),
)}
>
{children}
</button>
)
}
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 (
<>
<div
className={cn(
"bg-[#14161A] rounded-[14px] p-6",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<AppleShortcutsIcon />
<div className="flex flex-col gap-1">
<p className={cn(dmSans125ClassName(), "font-semibold text-[16px] text-[#FAFAFA]")}>
Apple Shortcuts
</p>
<p className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}>
Add memories directly from iPhone, iPad or Mac
</p>
</div>
</div>
<div className="flex gap-4">
<PillButton
onClick={() => handleShortcutClick("add")}
disabled={createApiKeyMutation.isPending}
>
{createApiKeyMutation.isPending && selectedShortcutType === "add" ? (
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
) : (
<Plus className="size-4 text-[#FAFAFA]" />
)}
<span className="text-[14px] text-[#FAFAFA] font-medium">
{createApiKeyMutation.isPending && selectedShortcutType === "add"
? "Creating..."
: "Add memory shortcut"}
</span>
</PillButton>
<PillButton
onClick={() => handleShortcutClick("search")}
disabled={createApiKeyMutation.isPending}
>
{createApiKeyMutation.isPending && selectedShortcutType === "search" ? (
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
) : (
<Search className="size-4 text-[#FAFAFA]" />
)}
<span className="text-[14px] text-[#FAFAFA] font-medium">
{createApiKeyMutation.isPending && selectedShortcutType === "search"
? "Creating..."
: "Search memory shortcut"}
</span>
</PillButton>
</div>
</div>
</div>
<Dialog open={showApiKeyModal} onOpenChange={(open) => {
setShowApiKeyModal(open)
if (!open) { setSelectedShortcutType(null); setApiKey(""); setCopied(false) }
}}>
<DialogPortal>
<DialogContent className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100">
<DialogHeader>
<DialogTitle className={cn(dmSans125ClassName(), "text-[#FAFAFA] text-lg font-semibold")}>
Setup Apple Shortcut
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor={apiKeyId} className={cn(dmSans125ClassName(), "text-sm font-medium text-[#737373]")}>
Your API Key
</label>
<div className="flex items-center gap-2">
<input
id={apiKeyId}
type="text"
value={apiKey}
readOnly
className={cn("flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono", dmSans125ClassName())}
/>
<button
type="button"
onClick={() => handleCopyApiKey(apiKey)}
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
>
{copied ? <Check className="h-4 w-4 text-[#4BA0FA]" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-3">
<h4 className={cn(dmSans125ClassName(), "text-sm font-medium text-[#737373]")}>Follow these steps:</h4>
<div className="space-y-2">
{["Click \"Add to Shortcuts\" below to open the shortcut", "Paste your API key when prompted", "Start using your shortcut!"].map((text, i) => (
<div key={text} className="flex items-start gap-3">
<div className="shrink-0 w-6 h-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
{i + 1}
</div>
<p className={cn(dmSans125ClassName(), "text-sm text-[#737373]")}>{text}</p>
</div>
))}
</div>
</div>
<button
type="button"
onClick={handleOpenShortcut}
disabled={!selectedShortcutType}
className={cn(
"w-full flex items-center justify-center gap-2",
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
"rounded-lg h-11 px-4 font-medium text-sm",
"disabled:opacity-50 disabled:cursor-not-allowed transition-colors",
dmSans125ClassName(),
)}
>
<Image src="/images/ios-shortcuts.png" alt="iOS Shortcuts" width={16} height={16} />
Add to Shortcuts
</button>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</>
)
}

View file

@ -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
</label>
<div className="relative flex items-center">
<div
className={`flex items-center border rounded-xl overflow-hidden h-[40px] w-full ${
errors.twitter
? "border-[#52596633] bg-[#290F0A]"
: "border-onboarding/20"
}`}
>
<div className="px-3 py-2 bg-[#070E1B] text-white/60 text-sm border-r border-onboarding/20 whitespace-nowrap">
x.com/
</div>
<input
id="twitter-handle"
type="text"
placeholder="handle"
value={twitterHandle}
onChange={(e) => 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]" : ""
}`}
/>
</div>
{errors.twitter && (
<div className="absolute left-full ml-3">
<div
className={cn(
"relative shrink-0 px-3 py-2 bg-[#290F0A] text-[#C73B1B] rounded-xl",
dmSansClassName(),
)}
>
<div className="absolute left-0.5 top-1/2 -translate-x-full -translate-y-1/2 w-0 h-0 border-t-[6px] border-b-[6px] border-r-8 border-t-transparent border-b-transparent border-[#290F0A]" />
<p className="text-xs whitespace-nowrap">{errors.twitter}</p>
</div>
</div>
)}
</div>
<input
id="twitter-handle"
type="text"
placeholder="x.com/handle or @handle"
value={twitterHandle}
onChange={(e) => 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"
}`}
/>
</div>
<div className="text-left gap-[6px] flex flex-col" id="linkedin-field">
@ -160,48 +142,19 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) {
>
LinkedIn
</label>
<div className="relative flex items-center">
<div
className={`flex items-center border rounded-xl overflow-hidden h-[40px] w-full ${
errors.linkedin
? "border-[#52596633] bg-[#290F0A]"
: "border-onboarding/20"
}`}
>
<div className="px-3 py-2 bg-[#070E1B] text-white/60 text-sm border-r border-onboarding/20 whitespace-nowrap w-[140px]">
linkedin.com/in/
</div>
<input
id="linkedin-profile"
type="text"
placeholder="username"
value={linkedinProfile}
onChange={(e) => 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]" : ""
}`}
/>
</div>
{errors.linkedin && (
<div className="absolute left-full ml-3">
<div
className={cn(
"relative shrink-0 px-3 py-2 bg-[#290F0A] text-[#C73B1B] rounded-xl",
dmSansClassName(),
)}
>
<div className="absolute left-0.5 top-1/2 -translate-x-full -translate-y-1/2 w-0 h-0 border-t-[6px] border-b-[6px] border-r-8 border-t-transparent border-b-transparent border-[#290F0A]" />
<p className="text-xs whitespace-nowrap">{errors.linkedin}</p>
</div>
</div>
)}
</div>
<input
id="linkedin-profile"
type="text"
placeholder="linkedin.com/in/username or username"
value={linkedinProfile}
onChange={(e) => 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"
}`}
/>
</div>
<div
@ -287,10 +240,12 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) {
}}
onClick={() => {
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(),

View file

@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
viewBox="0 0 190.5 190.5"
className={className}
>
<title>Google Chrome Icon</title>
<path
fill="#fff"
d="M95.252 142.873c26.304 0 47.627-21.324 47.627-47.628s-21.323-47.628-47.627-47.628-47.627 21.324-47.627 47.628 21.323 47.628 47.627 47.628z"
/>
<path
fill="#229342"
d="m54.005 119.07-41.24-71.43a95.227 95.227 0 0 0-.003 95.25 95.234 95.234 0 0 0 82.496 47.61l41.24-71.43v-.011a47.613 47.613 0 0 1-17.428 17.443 47.62 47.62 0 0 1-47.632.007 47.62 47.62 0 0 1-17.433-17.437z"
/>
<path
fill="#fbc116"
d="m136.495 119.067-41.239 71.43a95.229 95.229 0 0 0 82.489-47.622A95.24 95.24 0 0 0 190.5 95.248a95.237 95.237 0 0 0-12.772-47.623H95.249l-.01.007a47.62 47.62 0 0 1 23.819 6.372 47.618 47.618 0 0 1 17.439 17.431 47.62 47.62 0 0 1-.001 47.633z"
/>
<path
fill="#1a73e8"
d="M95.252 132.961c20.824 0 37.705-16.881 37.705-37.706S116.076 57.55 95.252 57.55 57.547 74.431 57.547 95.255s16.881 37.706 37.705 37.706z"
/>
<path
fill="#e33b2e"
d="M95.252 47.628h82.479A95.237 95.237 0 0 0 142.87 12.76 95.23 95.23 0 0 0 95.245 0a95.222 95.222 0 0 0-47.623 12.767 95.23 95.23 0 0 0-34.856 34.872l41.24 71.43.011.006a47.62 47.62 0 0 1-.015-47.633 47.61 47.61 0 0 1 41.252-23.815z"
/>
</svg>
)
}
function AppleShortcutsIcon() {
return (
<div className="relative size-10 shrink-0 rounded-lg overflow-hidden">
<Image
src="/images/ios-shortcuts.png"
alt="Apple Shortcuts"
width={40}
height={40}
className="object-cover"
/>
</div>
)
}
function RaycastIcon({ className }: { className?: string }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 28 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>Raycast Icon</title>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 18.079V21L0 14L1.46 12.54L7 18.081V18.079ZM9.921 21H7L14 28L15.46 26.54L9.921 21ZM26.535 15.462L27.996 14L13.996 0L12.538 1.466L18.077 7.004H14.73L10.864 3.146L9.404 4.606L11.809 7.01H10.129V17.876H20.994V16.196L23.399 18.6L24.859 17.14L20.994 13.274V9.927L26.535 15.462ZM7.73 6.276L6.265 7.738L7.833 9.304L9.294 7.844L7.73 6.276ZM20.162 18.708L18.702 20.17L20.268 21.738L21.73 20.276L20.162 18.708ZM4.596 9.41L3.134 10.872L7 14.738V11.815L4.596 9.41ZM16.192 21.006H13.268L17.134 24.872L18.596 23.41L16.192 21.006Z"
fill="#FF6363"
/>
</svg>
)
}
export default function Integrations() {
const { org } = useAuth()

View file

@ -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"),

View file

@ -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")

View file

@ -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<URLSearchParams>
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],
)

View file

@ -0,0 +1,11 @@
<svg viewBox="0 0 66 52" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="13" width="6" height="13" fill="#CC785C"/>
<rect x="60" y="13" width="6" height="13" fill="#CC785C"/>
<rect x="6" y="39" width="6" height="13" fill="#CC785C"/>
<rect x="18" y="39" width="6" height="13" fill="#CC785C"/>
<rect x="42" y="39" width="6" height="13" fill="#CC785C"/>
<rect x="54" y="39" width="6" height="13" fill="#CC785C"/>
<rect x="6" width="54" height="39" fill="#CC785C"/>
<rect x="12" y="13" width="6" height="6.5" fill="black"/>
<rect x="48" y="13" width="6" height="6.5" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 618 B

View file

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 440.691 440.691" xml:space="preserve">
<g>
<g>
<path style="fill:#C93F3F;" d="M134.3,294.013L134.3,294.013c-5.092-4.546-5.534-12.36-0.988-17.452l14.418-16.149l33.092,2.725
l-1.014,12.318l-26.958-2.219L134.3,294.013z"/>
<path style="fill:#C93F3F;" d="M134.3,321.382L134.3,321.382c-5.092-4.546-5.534-12.36-0.988-17.452l14.418-16.148l33.092,2.725
l-1.014,12.318l-26.958-2.219L134.3,321.382z"/>
<path style="fill:#C93F3F;" d="M306.391,294.013L306.391,294.013c5.092-4.546,5.534-12.36,0.988-17.452l-14.418-16.149
l-33.092,2.725l1.014,12.318l26.958-2.219L306.391,294.013z"/>
<path style="fill:#C93F3F;" d="M134.3,266.639L134.3,266.639c-5.092-4.546-5.534-12.36-0.988-17.452l14.418-16.149l33.092,2.725
l-1.014,12.318l-26.958-2.219L134.3,266.639z"/>
<path style="fill:#C93F3F;" d="M306.391,266.639L306.391,266.639c5.092-4.546,5.534-12.36,0.988-17.452l-14.418-16.149
l-33.092,2.725l1.014,12.318l26.958-2.219L306.391,266.639z"/>
<path style="fill:#C93F3F;" d="M306.391,321.382L306.391,321.382c5.092-4.546,5.534-12.36,0.988-17.452l-14.418-16.148
l-33.092,2.725l1.014,12.318l26.958-2.219L306.391,321.382z"/>
</g>
<g>
<path style="fill:#C93F3F;" d="M198.187,165.276c0,0,0.011-0.165,0.033-0.484c0.017-0.304,0.043-0.755,0.077-1.344
c0.064-1.228,0.126-3.076,0.156-5.442c0.025-2.368,0.015-5.259-0.087-8.585c-0.104-3.325-0.283-7.084-0.615-11.181
c-0.293-4.102-0.802-8.531-1.398-13.214c-0.662-4.671-1.42-9.6-2.464-14.649c-0.974-5.068-2.273-10.237-3.675-15.477
c-1.503-5.204-3.129-10.479-5.086-15.633l-1.457-3.873c-0.533-1.27-1.066-2.539-1.597-3.806c-1.025-2.556-2.219-5.018-3.378-7.48
l-0.867-1.844l-0.94-1.794c-0.627-1.195-1.251-2.383-1.871-3.565c-1.243-2.365-2.651-4.593-3.941-6.852
c-1.351-2.218-2.797-4.323-4.152-6.433c-1.415-2.065-2.901-4.013-4.296-5.955c-2.934-3.764-5.796-7.281-8.677-10.314
c-0.706-0.773-1.398-1.531-2.074-2.271c-0.716-0.701-1.415-1.386-2.096-2.053c-1.372-1.332-2.674-2.596-3.897-3.784
c-2.592-2.21-4.809-4.21-6.726-5.744c-3.915-2.978-6.152-4.679-6.152-4.679s2.377,1.51,6.538,4.154
c2.049,1.374,4.436,3.186,7.241,5.198c1.334,1.092,2.753,2.255,4.249,3.48c0.747,0.618,1.513,1.251,2.297,1.9
c0.747,0.69,1.511,1.397,2.292,2.118c3.184,2.832,6.392,6.155,9.715,9.749c1.589,1.857,3.28,3.726,4.908,5.719
c1.567,2.036,3.237,4.078,4.817,6.24c1.518,2.2,3.165,4.383,4.65,6.707c0.742,1.164,1.489,2.335,2.24,3.511l1.127,1.774
l1.055,1.825c1.411,2.439,2.864,4.89,4.146,7.439c0.662,1.266,1.324,2.535,1.988,3.806l1.848,3.883
c2.482,5.179,4.633,10.505,6.654,15.791c1.919,5.317,3.726,10.596,5.189,15.787c1.533,5.174,2.761,10.246,3.865,15.074
c1.039,4.839,1.962,9.437,2.634,13.714c0.712,4.272,1.236,8.214,1.643,11.721c0.405,3.508,0.677,6.585,0.865,9.133
c0.183,2.551,0.286,4.569,0.333,5.976c0.02,0.729,0.035,1.288,0.045,1.665c0.006,0.362,0.008,0.548,0.008,0.548L198.187,165.276z"
/>
<path style="fill:#C93F3F;" d="M227.335,165.732c0,0,0.003-0.187,0.008-0.548c0.01-0.377,0.025-0.936,0.045-1.665
c0.047-1.407,0.149-3.425,0.333-5.976c0.188-2.548,0.46-5.625,0.865-9.133c0.407-3.508,0.931-7.45,1.643-11.721
c0.672-4.277,1.596-8.875,2.634-13.714c1.104-4.828,2.331-9.9,3.865-15.074c1.462-5.191,3.27-10.47,5.189-15.787
c2.021-5.286,4.173-10.613,6.654-15.791l1.848-3.883c0.663-1.27,1.326-2.539,1.988-3.806c1.283-2.549,2.735-5,4.146-7.439
l1.055-1.825l1.127-1.774c0.751-1.177,1.497-2.347,2.24-3.511c1.485-2.324,3.132-4.507,4.65-6.707
c1.581-2.161,3.25-4.203,4.817-6.24c1.628-1.993,3.319-3.862,4.908-5.719c3.323-3.595,6.531-6.918,9.715-9.749
c0.78-0.721,1.545-1.428,2.292-2.118c0.784-0.649,1.55-1.282,2.297-1.9c1.496-1.226,2.915-2.388,4.249-3.48
c2.805-2.011,5.192-3.824,7.241-5.198c4.161-2.643,6.538-4.154,6.538-4.154s-2.237,1.701-6.152,4.679
c-1.916,1.534-4.134,3.535-6.726,5.744c-1.223,1.188-2.525,2.452-3.897,3.784c-0.681,0.668-1.38,1.352-2.096,2.053
c-0.676,0.74-1.367,1.497-2.074,2.271c-2.882,3.033-5.743,6.55-8.677,10.314c-1.395,1.943-2.881,3.891-4.296,5.955
c-1.355,2.11-2.801,4.215-4.152,6.433c-1.29,2.259-2.698,4.487-3.941,6.852c-0.62,1.182-1.244,2.37-1.871,3.565l-0.94,1.794
l-0.867,1.844c-1.158,2.462-2.353,4.924-3.378,7.48c-0.532,1.267-1.064,2.536-1.597,3.806l-1.457,3.873
c-1.957,5.154-3.583,10.429-5.086,15.633c-1.403,5.24-2.701,10.409-3.675,15.477c-1.045,5.048-1.803,9.978-2.464,14.649
c-0.597,4.683-1.105,9.112-1.398,13.214c-0.332,4.097-0.511,7.856-0.615,11.181c-0.102,3.325-0.112,6.217-0.087,8.585
c0.03,2.366,0.092,4.214,0.156,5.442c0.034,0.589,0.06,1.04,0.077,1.344c0.022,0.319,0.033,0.484,0.033,0.484L227.335,165.732z"/>
</g>
<path style="fill:#E24747;" d="M266.326,401.138h-91.962c-15.633,0-28.306,12.673-28.306,28.306v0
c0,6.211,5.035,11.246,11.246,11.246h126.082c6.211,0,11.246-5.035,11.246-11.246v0
C294.633,413.811,281.959,401.138,266.326,401.138z"/>
<polygon style="fill:#C93F3F;" points="259.486,358.29 181.205,358.29 188.88,336.865 251.811,336.865 "/>
<polygon style="fill:#C93F3F;" points="259.486,336.865 181.205,336.865 188.88,315.441 251.811,315.441 "/>
<polygon style="fill:#C93F3F;" points="259.486,379.714 181.205,379.714 188.88,358.29 251.811,358.29 "/>
<polygon style="fill:#C93F3F;" points="259.486,401.138 181.205,401.138 188.88,379.714 251.811,379.714 "/>
<path style="fill:#F74B4B;" d="M258.265,315.665c5.709,0,10.337-4.628,10.337-10.337v-76.592c0-35.649-17.225-69.996-48.256-96.226
c-31.032,26.229-48.256,60.576-48.256,96.226v76.592c0,5.709,4.628,10.337,10.337,10.337H258.265z"/>
<g>
<g>
<circle style="fill:#FFFFFF;" cx="239.159" cy="181.127" r="12.173"/>
<circle style="fill:#4D4D4D;" cx="239.159" cy="181.127" r="7.448"/>
</g>
<g>
<circle style="fill:#FFFFFF;" cx="201.532" cy="181.127" r="12.173"/>
<circle style="fill:#4D4D4D;" cx="201.532" cy="181.127" r="7.448"/>
</g>
</g>
<g>
<path style="fill:#F74B4B;" d="M114.844,8.638l-5.329,65.495h0c-7.257-0.59-12.662-6.952-12.071-14.21L102.319,0
C73.984,25.929,59.985,65.914,69.122,106.041c6.042,26.536,21.178,48.638,41.368,63.782
c29.157-25.889,43.682-66.456,34.414-107.159C140.053,41.358,129.33,22.92,114.844,8.638z"/>
<path style="fill:#F74B4B;" d="M198.833,222.122h-69.581c-16.407,0-29.755-13.348-29.755-29.755v-41.871h21.012v41.871
c0,4.821,3.922,8.743,8.743,8.743h69.581V222.122z"/>
<path style="fill:#F74B4B;" d="M325.847,8.638l5.329,65.495h0c7.257-0.59,12.662-6.952,12.071-14.21L338.372,0
c28.336,25.929,42.334,65.914,33.198,106.041c-6.042,26.536-21.178,48.638-41.368,63.782
c-29.157-25.889-43.682-66.456-34.414-107.159C300.638,41.358,311.361,22.92,325.847,8.638z"/>
<path style="fill:#F74B4B;" d="M311.439,222.122h-69.581V201.11h69.581c4.821,0,8.743-3.922,8.743-8.743v-41.871h21.012v41.871
C341.194,208.774,327.846,222.122,311.439,222.122z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -0,0 +1 @@
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86274)'><mask id='mask0_1401_86274' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86274)'><path d='M180 240H60V120H180V240Z' fill='#CFCECD'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#211E1E'/></g></g><defs><clipPath id='clip0_1401_86274'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 577 B