mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-12 23:01:07 +00:00
fix: integrations page
This commit is contained in:
parent
a9e26bb8f9
commit
f50e6234fe
19 changed files with 1895 additions and 239 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
}]
|
||||
: []),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
|
|
|
|||
69
apps/web/components/new/integration-icons.tsx
Normal file
69
apps/web/components/new/integration-icons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
236
apps/web/components/new/integrations-view.tsx
Normal file
236
apps/web/components/new/integrations-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
apps/web/components/new/integrations/chrome-detail.tsx
Normal file
104
apps/web/components/new/integrations/chrome-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
271
apps/web/components/new/integrations/connections-detail.tsx
Normal file
271
apps/web/components/new/integrations/connections-detail.tsx
Normal 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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
489
apps/web/components/new/integrations/plugins-detail.tsx
Normal file
489
apps/web/components/new/integrations/plugins-detail.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
201
apps/web/components/new/integrations/raycast-detail.tsx
Normal file
201
apps/web/components/new/integrations/raycast-detail.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
234
apps/web/components/new/integrations/shortcuts-detail.tsx
Normal file
234
apps/web/components/new/integrations/shortcuts-detail.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
11
apps/web/public/images/plugins/claude-code.svg
Normal file
11
apps/web/public/images/plugins/claude-code.svg
Normal 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 |
86
apps/web/public/images/plugins/clawdbot.svg
Normal file
86
apps/web/public/images/plugins/clawdbot.svg
Normal 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 |
1
apps/web/public/images/plugins/opencode.svg
Normal file
1
apps/web/public/images/plugins/opencode.svg
Normal 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 |
Loading…
Add table
Reference in a new issue