mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-17 21:11:04 +00:00
925 lines
26 KiB
TypeScript
925 lines
26 KiB
TypeScript
"use client"
|
|
|
|
import { cn } from "@lib/utils"
|
|
import { dmSans125ClassName } from "@/lib/fonts"
|
|
import { authClient } from "@lib/auth"
|
|
import { useAuth } from "@lib/auth-context"
|
|
import { hasActivePlan } 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,
|
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/components/tabs"
|
|
|
|
/** Match `FREE_TIER_PLUGIN_IDS` in mono `packages/lib/plugins.ts`. */
|
|
function isFreeTierPlugin(pluginId: string): boolean {
|
|
return pluginId === "hermes"
|
|
}
|
|
|
|
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",
|
|
},
|
|
openclaw: {
|
|
id: "openclaw",
|
|
name: "OpenClaw",
|
|
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/openclaw.svg",
|
|
docsUrl: "https://docs.supermemory.ai/integrations/openclaw",
|
|
repoUrl: "https://github.com/supermemoryai/openclaw-supermemory",
|
|
},
|
|
hermes: {
|
|
id: "hermes",
|
|
name: "Hermes",
|
|
description: "Memory layer for Hermes agent",
|
|
features: [
|
|
"Semantic search across previous sessions",
|
|
"Auto-capture of conversation context",
|
|
"Builds persistent user profile from interactions",
|
|
],
|
|
icon: "/images/plugins/hermes.svg",
|
|
docsUrl: "https://docs.supermemory.ai/integrations/hermes",
|
|
repoUrl: "https://github.com/NousResearch/hermes-agent",
|
|
},
|
|
}
|
|
|
|
interface ConnectedPlugin {
|
|
id: string
|
|
keyId: string
|
|
pluginId: string
|
|
createdAt: string
|
|
lastUsed?: string | null
|
|
keyStart?: string | null
|
|
}
|
|
|
|
function ProUpgradeBanner({ onUpgrade }: { onUpgrade: () => void }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"bg-gradient-to-br from-[#0D121A] to-[#14161A] rounded-[14px] p-6 border border-[#4BA0FA]/20 mb-6",
|
|
"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 Pro plugins
|
|
</h3>
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[14px] text-[#737373] mt-1",
|
|
)}
|
|
>
|
|
Connect Claude Code, OpenCode, OpenClaw, Cursor, and more with a
|
|
Pro plan.
|
|
</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 flex-wrap">
|
|
{Object.values(PLUGIN_CATALOG)
|
|
.filter((p) => !isFreeTierPlugin(p.id))
|
|
.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, OpenClaw & more
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onUpgrade}
|
|
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>
|
|
)
|
|
}
|
|
|
|
function ConnectedPluginRow({
|
|
plugin,
|
|
info,
|
|
onRevoke,
|
|
}: {
|
|
plugin: ConnectedPlugin
|
|
info: PluginInfo | undefined
|
|
onRevoke: (keyId: string) => void
|
|
}) {
|
|
return (
|
|
<div
|
|
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={() => onRevoke(plugin.keyId)}
|
|
className="text-[#737373] hover:text-red-400 transition-colors"
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PluginCard({
|
|
plugin,
|
|
pluginId,
|
|
isConnected,
|
|
isCurrentlyConnecting,
|
|
connectingPlugin,
|
|
needsProUpgrade,
|
|
onConnect,
|
|
onUpgrade,
|
|
}: {
|
|
plugin: PluginInfo
|
|
pluginId: string
|
|
isConnected: boolean
|
|
isCurrentlyConnecting: boolean
|
|
connectingPlugin: string | null
|
|
needsProUpgrade: boolean
|
|
onConnect: (id: string) => void
|
|
onUpgrade: () => void
|
|
}) {
|
|
return (
|
|
<div
|
|
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 flex-wrap">
|
|
<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>
|
|
) : needsProUpgrade ? (
|
|
<button
|
|
type="button"
|
|
onClick={onUpgrade}
|
|
className={cn(
|
|
"w-full flex items-center justify-center gap-2 rounded-full h-9 px-4 text-[12px] font-medium",
|
|
"bg-[#080B0F] text-[#FAFAFA] border border-[#1E293B] hover:border-[#4BA0FA]/40 transition-colors cursor-pointer",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
Upgrade for this plugin
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => onConnect(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>
|
|
)
|
|
}
|
|
|
|
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 hasProProduct = hasActivePlan(autumn.customer?.products, "api_pro")
|
|
|
|
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 freeConnected = useMemo(
|
|
() => connectedPlugins.filter((p) => isFreeTierPlugin(p.pluginId)),
|
|
[connectedPlugins],
|
|
)
|
|
|
|
const proConnected = useMemo(
|
|
() => connectedPlugins.filter((p) => !isFreeTierPlugin(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) {
|
|
if (res.status === 403) {
|
|
throw new Error(
|
|
"This plugin requires a Pro plan. Hermes is available on the Free plan.",
|
|
)
|
|
}
|
|
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
|
|
const availablePlugins = pluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG)
|
|
|
|
const freePluginIds = useMemo(() => {
|
|
const ids = new Set(
|
|
availablePlugins.filter(
|
|
(id) => PLUGIN_CATALOG[id] && isFreeTierPlugin(id),
|
|
),
|
|
)
|
|
if (PLUGIN_CATALOG.hermes) ids.add("hermes")
|
|
return [...ids]
|
|
}, [availablePlugins])
|
|
|
|
const proPluginIds = useMemo(
|
|
() =>
|
|
availablePlugins.filter(
|
|
(id) => PLUGIN_CATALOG[id] && !isFreeTierPlugin(id),
|
|
),
|
|
[availablePlugins],
|
|
)
|
|
|
|
const allCatalogPluginIds = useMemo(
|
|
() => availablePlugins.filter((id) => PLUGIN_CATALOG[id]),
|
|
[availablePlugins],
|
|
)
|
|
|
|
const showPaidAllInOne = !isLoading && hasProProduct
|
|
|
|
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)]",
|
|
)}
|
|
>
|
|
{showPaidAllInOne ? (
|
|
<div className="flex flex-col gap-6">
|
|
{connectedPlugins.length > 0 && (
|
|
<div className="flex flex-col gap-3">
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-semibold text-[16px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
Connected
|
|
</span>
|
|
{connectedPlugins.map((plugin) => (
|
|
<ConnectedPluginRow
|
|
key={plugin.id}
|
|
plugin={plugin}
|
|
info={PLUGIN_CATALOG[plugin.pluginId]}
|
|
onRevoke={handleRevoke}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<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">
|
|
{allCatalogPluginIds.map((pluginId) => {
|
|
const plugin = PLUGIN_CATALOG[pluginId]
|
|
if (!plugin) return null
|
|
const isConnected = connectedPluginIds.includes(pluginId)
|
|
const isCurrentlyConnecting = connectingPlugin === pluginId
|
|
return (
|
|
<PluginCard
|
|
key={pluginId}
|
|
plugin={plugin}
|
|
pluginId={pluginId}
|
|
isConnected={isConnected}
|
|
isCurrentlyConnecting={isCurrentlyConnecting}
|
|
connectingPlugin={connectingPlugin}
|
|
needsProUpgrade={false}
|
|
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
|
onUpgrade={handleUpgrade}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Tabs defaultValue="free" className="gap-0">
|
|
<TabsList
|
|
className={cn(
|
|
"grid h-auto w-full grid-cols-2 gap-0 rounded-none border-0 border-b border-[#252a33] bg-transparent p-0",
|
|
)}
|
|
>
|
|
<TabsTrigger
|
|
value="free"
|
|
className={cn(
|
|
"relative flex min-h-12 w-full min-w-0 cursor-pointer items-center justify-center rounded-none border-0 border-transparent bg-transparent px-3 py-3 text-[15px] font-medium shadow-none",
|
|
"text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
|
"data-[state=active]:bg-transparent data-[state=active]:text-[#FAFAFA] data-[state=active]:shadow-none",
|
|
"after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-t-[1px] after:bg-[#4BA0FA] after:opacity-0 data-[state=active]:after:opacity-100",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
Free plugins
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="pro"
|
|
className={cn(
|
|
"relative flex min-h-12 w-full min-w-0 cursor-pointer items-center justify-center rounded-none border-0 border-transparent bg-transparent px-3 py-3 text-[15px] font-medium shadow-none",
|
|
"text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
|
"data-[state=active]:bg-transparent data-[state=active]:text-[#FAFAFA] data-[state=active]:shadow-none",
|
|
"after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-t-[1px] after:bg-[#4BA0FA] after:opacity-0 data-[state=active]:after:opacity-100",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
Pro plugins
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="free" className="mt-5">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"text-[13px] text-[#737373] mb-4",
|
|
)}
|
|
>
|
|
Included on every plan — connect with no upgrade.
|
|
</p>
|
|
|
|
{freeConnected.length > 0 && (
|
|
<div className="flex flex-col gap-3 mb-6">
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-semibold text-[16px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
Connected
|
|
</span>
|
|
{freeConnected.map((plugin) => (
|
|
<ConnectedPluginRow
|
|
key={plugin.id}
|
|
plugin={plugin}
|
|
info={PLUGIN_CATALOG[plugin.pluginId]}
|
|
onRevoke={handleRevoke}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-semibold text-[16px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{freeConnected.length > 0 ? "Add or manage" : "Available"}
|
|
</span>
|
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
{freePluginIds.map((pluginId) => {
|
|
const plugin = PLUGIN_CATALOG[pluginId]
|
|
if (!plugin) return null
|
|
const isConnected = connectedPluginIds.includes(pluginId)
|
|
const isCurrentlyConnecting = connectingPlugin === pluginId
|
|
return (
|
|
<PluginCard
|
|
key={pluginId}
|
|
plugin={plugin}
|
|
pluginId={pluginId}
|
|
isConnected={isConnected}
|
|
isCurrentlyConnecting={isCurrentlyConnecting}
|
|
connectingPlugin={connectingPlugin}
|
|
needsProUpgrade={false}
|
|
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
|
onUpgrade={handleUpgrade}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="pro" className="mt-5">
|
|
{!hasProProduct && !isLoading && (
|
|
<ProUpgradeBanner onUpgrade={handleUpgrade} />
|
|
)}
|
|
|
|
{proConnected.length > 0 && (
|
|
<div className="flex flex-col gap-3 mb-6">
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-semibold text-[16px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
Connected
|
|
</span>
|
|
{proConnected.map((plugin) => (
|
|
<ConnectedPluginRow
|
|
key={plugin.id}
|
|
plugin={plugin}
|
|
info={PLUGIN_CATALOG[plugin.pluginId]}
|
|
onRevoke={handleRevoke}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<span
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-semibold text-[16px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{proConnected.length > 0 ? "Add more" : "Available plugins"}
|
|
</span>
|
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
{proPluginIds.map((pluginId) => {
|
|
const plugin = PLUGIN_CATALOG[pluginId]
|
|
if (!plugin) return null
|
|
const isConnected = connectedPluginIds.includes(pluginId)
|
|
const isCurrentlyConnecting = connectingPlugin === pluginId
|
|
const needsProUpgrade = !hasProProduct
|
|
return (
|
|
<PluginCard
|
|
key={pluginId}
|
|
plugin={plugin}
|
|
pluginId={pluginId}
|
|
isConnected={isConnected}
|
|
isCurrentlyConnecting={isCurrentlyConnecting}
|
|
connectingPlugin={connectingPlugin}
|
|
needsProUpgrade={needsProUpgrade}
|
|
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
|
onUpgrade={handleUpgrade}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
)}
|
|
</div>
|
|
|
|
<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>
|
|
</>
|
|
)
|
|
}
|