mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
added settings page
This commit is contained in:
parent
d0d7d28a8d
commit
0e6f158c1b
20 changed files with 3732 additions and 193 deletions
243
apps/web/app/new/settings/page.tsx
Normal file
243
apps/web/app/new/settings/page.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"use client"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { motion } from "motion/react"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/utils/fonts"
|
||||
import Account from "@/components/new/settings/account"
|
||||
import Integrations from "@/components/new/settings/integrations"
|
||||
import ConnectionsMCP from "@/components/new/settings/connections-mcp"
|
||||
import Support from "@/components/new/settings/support"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
const TABS = ["account", "integrations", "connections", "support"] as const
|
||||
type SettingsTab = (typeof TABS)[number]
|
||||
|
||||
type NavItem = {
|
||||
id: SettingsTab
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
id: "account",
|
||||
label: "Account & Billing",
|
||||
description: "Manage your profile, plan, usage and payments",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: "Integrations",
|
||||
description: "Save, sync and search memories across tools",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: "Connections & MCP",
|
||||
description: "Sync with Google Drive, Notion, OneDrive and MCP client",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
description: "Find answers or share feedback. We're here to help.",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function parseHashToTab(hash: string): SettingsTab {
|
||||
const cleaned = hash.replace("#", "").toLowerCase()
|
||||
return TABS.includes(cleaned as SettingsTab)
|
||||
? (cleaned as SettingsTab)
|
||||
: "account"
|
||||
}
|
||||
|
||||
export function UserSupermemory({ name }: { name: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-0 top-[-40px] flex items-center justify-center z-10"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
>
|
||||
<Logo className="h-7 text-white" />
|
||||
<div className="flex flex-col items-start justify-center ml-4 space-y-1">
|
||||
<p className="text-white text-[15px] font-medium leading-none">
|
||||
{name.split(" ")[0]}'s
|
||||
</p>
|
||||
<p className="text-white font-bold text-xl leading-none -mt-2">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
|
||||
const hasInitialized = useRef(false)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) return
|
||||
hasInitialized.current = true
|
||||
|
||||
const hash = window.location.hash
|
||||
const tab = parseHashToTab(hash)
|
||||
setActiveTab(tab)
|
||||
|
||||
// If no hash or invalid hash, push #account
|
||||
if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) {
|
||||
window.history.pushState(null, "", "#account")
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const tab = parseHashToTab(window.location.hash)
|
||||
setActiveTab(tab)
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange)
|
||||
return () => window.removeEventListener("hashchange", handleHashChange)
|
||||
}, [])
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<header className="flex justify-between items-center px-6 py-3 shrink-0">
|
||||
<button type="button" onClick={() => router.push("/new")} className="cursor-pointer">
|
||||
<Logo className="h-7" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{user && (
|
||||
<Avatar className="border border-border h-8 w-8 md:h-10 md:w-10">
|
||||
<AvatarImage src={user?.image ?? ""} />
|
||||
<AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-2xl mx-auto space-x-12 flex justify-center pt-4 flex-1 min-h-0">
|
||||
<div className="min-w-xs">
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: 1,
|
||||
padding: 48,
|
||||
paddingTop: 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: "easeOut",
|
||||
delay: 0.2,
|
||||
}}
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<NovaOrb size={175} className="blur-[3px]!" />
|
||||
<UserSupermemory name={user?.name ?? ""} />
|
||||
</motion.div>
|
||||
<nav className={cn("flex flex-col gap-2", dmSansClassName())}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.hash = item.id
|
||||
setActiveTab(item.id)
|
||||
}}
|
||||
className={`text-left p-4 rounded-xl transition-colors flex items-start gap-3 ${
|
||||
activeTab === item.id
|
||||
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
: "text-white/60 hover:text-white hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
}`}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">{item.icon}</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-sm text-white/50">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 overflow-y-auto min-w-2xl [scrollbar-gutter:stable] pr-[17px]">
|
||||
{activeTab === "account" && <Account />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import { toast } from "sonner"
|
|||
import { z } from "zod/v4"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { cn } from "@lib/utils"
|
||||
import type { Project } from "@repo/lib/types"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
|
||||
const clients = {
|
||||
|
|
@ -56,15 +57,6 @@ const mcpMigrationSchema = z.object({
|
|||
),
|
||||
})
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
containerTag: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isExperimental?: boolean
|
||||
}
|
||||
|
||||
interface ConnectAIModalProps {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
|
|
|
|||
|
|
@ -1,10 +1,187 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { fetchConnectionsFeature } from "@repo/lib/queries"
|
||||
import type { ConnectionResponseSchema } from "@repo/validation/api"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { Check, Loader, Trash2, Zap } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { z } from "zod"
|
||||
import { dmSansClassName } from "@/utils/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { Check, Zap } from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
type ConnectorProvider = "google-drive" | "notion" | "onedrive"
|
||||
|
||||
const CONNECTORS: Record<
|
||||
ConnectorProvider,
|
||||
{
|
||||
title: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
> = {
|
||||
"google-drive": {
|
||||
title: "Google Drive",
|
||||
description: "Connect your Google docs, sheets and slides",
|
||||
icon: GoogleDrive,
|
||||
},
|
||||
notion: {
|
||||
title: "Notion",
|
||||
description: "Import your Notion pages and databases",
|
||||
icon: Notion,
|
||||
},
|
||||
onedrive: {
|
||||
title: "OneDrive",
|
||||
description: "Access your Microsoft Office documents",
|
||||
icon: OneDrive,
|
||||
},
|
||||
} as const
|
||||
|
||||
interface ConnectContentProps {
|
||||
selectedProject: string
|
||||
}
|
||||
|
||||
export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const autumn = useCustomer()
|
||||
const [isProUser, setIsProUser] = useState(false)
|
||||
const [connectingProvider, setConnectingProvider] =
|
||||
useState<ConnectorProvider | null>(null)
|
||||
|
||||
// Check Pro status
|
||||
useEffect(() => {
|
||||
if (!autumn.isLoading) {
|
||||
setIsProUser(
|
||||
autumn.customer?.products.some(
|
||||
(product) => product.id === "consumer_pro",
|
||||
) ?? false,
|
||||
)
|
||||
}
|
||||
}, [autumn.isLoading, autumn.customer])
|
||||
|
||||
// Check connections feature limits
|
||||
const { data: connectionsCheck } = fetchConnectionsFeature(
|
||||
autumn,
|
||||
!autumn.isLoading,
|
||||
)
|
||||
const connectionsUsed = connectionsCheck?.balance ?? 0
|
||||
const connectionsLimit = connectionsCheck?.included_usage ?? 0
|
||||
const canAddConnection = connectionsUsed < connectionsLimit
|
||||
|
||||
// Fetch connections
|
||||
const { data: connections = [], error: connectionsError } = useQuery({
|
||||
queryKey: ["connections"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/connections/list", {
|
||||
body: {
|
||||
containerTags: [],
|
||||
},
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to load connections")
|
||||
}
|
||||
|
||||
return response.data as Connection[]
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
|
||||
// Handle connection errors
|
||||
useEffect(() => {
|
||||
if (connectionsError) {
|
||||
toast.error("Failed to load connections", {
|
||||
description:
|
||||
connectionsError instanceof Error
|
||||
? connectionsError.message
|
||||
: "Unknown error",
|
||||
})
|
||||
}
|
||||
}, [connectionsError])
|
||||
|
||||
// Connect mutation
|
||||
const addConnectionMutation = useMutation({
|
||||
mutationFn: async (provider: ConnectorProvider) => {
|
||||
if (!canAddConnection && !isProUser) {
|
||||
throw new Error(
|
||||
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
|
||||
)
|
||||
}
|
||||
|
||||
const response = await $fetch("@post/connections/:provider", {
|
||||
params: { provider },
|
||||
body: {
|
||||
redirectUrl: window.location.href,
|
||||
containerTags: [selectedProject],
|
||||
},
|
||||
})
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: its fine
|
||||
if ("data" in response && !("error" in response.data!)) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
throw new Error(response.error?.message || "Failed to connect")
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data?.authLink) {
|
||||
window.location.href = data.authLink
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setConnectingProvider(null)
|
||||
toast.error("Failed to connect", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Disconnect mutation
|
||||
const deleteConnectionMutation = useMutation({
|
||||
mutationFn: async (connectionId: string) => {
|
||||
await $fetch(`@delete/connections/${connectionId}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
"Connection removal has started. supermemory will permanently delete all documents related to the connection in the next few minutes.",
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ["connections"] })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to remove connection", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleConnect = (provider: ConnectorProvider) => {
|
||||
setConnectingProvider(provider)
|
||||
addConnectionMutation.mutate(provider)
|
||||
}
|
||||
|
||||
const handleDisconnect = (connectionId: string) => {
|
||||
deleteConnectionMutation.mutate(connectionId)
|
||||
}
|
||||
|
||||
const hasConnections = connections.length > 0
|
||||
|
||||
// Helper function to format connection subtext safely
|
||||
const getConnectionSubtext = (connection: Connection): string => {
|
||||
if (connection.email) {
|
||||
return connection.email
|
||||
}
|
||||
|
||||
return "Connected"
|
||||
}
|
||||
|
||||
export function ConnectContent() {
|
||||
const isProUser = true
|
||||
return (
|
||||
<div className="h-full flex flex-col pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
|
|
@ -13,86 +190,219 @@ export function ConnectContent() {
|
|||
PRO
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-[#14161A] rounded-[12px] px-4 py-3 flex items-center gap-3">
|
||||
<GoogleDrive className="w-6 h-6 text-[#737373]" />
|
||||
<div className="space-y-[6px]">
|
||||
<p className="text-[16px] font-medium">Google Drive</p>
|
||||
<p className="text-[16px] text-[#737373]">
|
||||
Connect your Google docs, sheets and slides
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#14161A] rounded-[12px] px-4 py-3 flex items-center gap-3">
|
||||
<Notion className="w-6 h-6 text-[#737373]" />
|
||||
<div className="space-y-[6px]">
|
||||
<p className="text-[16px] font-medium">Notion</p>
|
||||
<p className="text-[16px] text-[#737373]">
|
||||
Import your Notion pages and databases
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#14161A] rounded-[12px] px-4 py-3 flex items-center gap-3">
|
||||
<OneDrive className="w-6 h-6 text-[#737373]" />
|
||||
<div className="space-y-[6px]">
|
||||
<p className="text-[16px] font-medium">OneDrive</p>
|
||||
<p className="text-[16px] text-[#737373]">
|
||||
Access your Microsoft Office documents
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="no-active-connections"
|
||||
className="bg-[#14161A] shadow-inside-out rounded-[12px] px-4 py-6 h-full mb-4 flex flex-col justify-center items-center"
|
||||
>
|
||||
<Zap className="w-6 h-6 text-[#737373] mb-3" />
|
||||
{!isProUser ? (
|
||||
<>
|
||||
<p className="text-[14px] text-[#737373] mb-4 text-center">
|
||||
<a
|
||||
href="/pricing"
|
||||
className="underline text-[#737373] hover:text-white"
|
||||
|
||||
{/* Connector section - conditional layout based on hasConnections */}
|
||||
{hasConnections ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Object.entries(CONNECTORS).map(([provider, config]) => {
|
||||
const Icon = config.icon
|
||||
const isConnecting =
|
||||
connectingProvider === provider ||
|
||||
(addConnectionMutation.isPending &&
|
||||
addConnectionMutation.variables === provider)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => handleConnect(provider as ConnectorProvider)}
|
||||
disabled={
|
||||
!isProUser || isConnecting || addConnectionMutation.isPending
|
||||
}
|
||||
className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] px-4 py-3 flex items-center justify-center gap-2 hover:bg-[#1B1F24] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Upgrade to Pro
|
||||
</a>{" "}
|
||||
to get
|
||||
<br />
|
||||
Supermemory Connections
|
||||
<Icon className="w-6 h-6 text-[#737373]" />
|
||||
<p className="text-[14px] font-medium text-center">
|
||||
{config.title}
|
||||
</p>
|
||||
{isConnecting && (
|
||||
<Loader className="h-4 w-4 animate-spin text-[#4BA0FA]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(CONNECTORS).map(([provider, config]) => {
|
||||
const Icon = config.icon
|
||||
const connection = connections.find(
|
||||
(conn) => conn.provider === provider,
|
||||
)
|
||||
const isConnected = !!connection
|
||||
const isConnecting =
|
||||
connectingProvider === provider ||
|
||||
(addConnectionMutation.isPending &&
|
||||
addConnectionMutation.variables === provider)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="bg-[#14161A] rounded-[12px] px-4 py-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Icon className="w-6 h-6 text-[#737373]" />
|
||||
<div className="space-y-[6px] flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[16px] font-medium">{config.title}</p>
|
||||
{isConnected && (
|
||||
<span className="text-[12px] text-[#4BA0FA] font-medium">
|
||||
{connection.metadata?.syncInProgress
|
||||
? "Syncing..."
|
||||
: "Connected"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[16px] text-[#737373]">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(connection.id)}
|
||||
disabled={deleteConnectionMutation.isPending}
|
||||
className="text-[#737373] hover:text-white hover:bg-[#1B1F24] h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleConnect(provider as ConnectorProvider)
|
||||
}
|
||||
disabled={
|
||||
!isProUser ||
|
||||
isConnecting ||
|
||||
addConnectionMutation.isPending
|
||||
}
|
||||
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected list panel - only when hasConnections */}
|
||||
{hasConnections && (
|
||||
<div className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] shadow-inside-out px-4 py-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[16px] font-semibold">
|
||||
Connected to Supermemory
|
||||
</p>
|
||||
<div className="space-y-2 text-[14px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<span>Unlimited memories</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<span>10 connections</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<span>Advanced search</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<span>Priority support</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"text-[#737373] text-center max-w-[174px] font-medium",
|
||||
dmSansClassName(),
|
||||
{connectionsLimit > 0 && (
|
||||
<p className="text-[12px] text-[#737373]">
|
||||
{connections.length}/{connectionsLimit} connections used
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
<p>No connections yet</p>
|
||||
<p className="text-[12px]">
|
||||
Choose a service above to import your knowledge
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{connections.map((connection) => {
|
||||
const config =
|
||||
CONNECTORS[connection.provider as ConnectorProvider]
|
||||
if (!config) return null
|
||||
|
||||
const Icon = config.icon
|
||||
const subtext = getConnectionSubtext(connection)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={connection.id}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Icon className="w-6 h-6 text-[#737373]" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[16px] font-medium truncate">
|
||||
{config.title}
|
||||
</p>
|
||||
<p className="text-[14px] text-[#737373] truncate">
|
||||
{subtext}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(connection.id)}
|
||||
disabled={deleteConnectionMutation.isPending}
|
||||
className="text-[#737373] hover:text-white hover:bg-[#1B1F24] h-8 w-8 p-0 shrink-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state panel - only when !hasConnections */}
|
||||
{!hasConnections && (
|
||||
<div
|
||||
id="no-active-connections"
|
||||
className="bg-[#14161A] shadow-inside-out rounded-[12px] px-4 py-6 h-full mb-4 flex flex-col justify-center items-center"
|
||||
>
|
||||
<Zap className="w-6 h-6 text-[#737373] mb-3" />
|
||||
{!isProUser ? (
|
||||
<>
|
||||
<p className="text-[14px] text-[#737373] mb-4 text-center">
|
||||
<a
|
||||
href="/pricing"
|
||||
className="underline text-[#737373] hover:text-white"
|
||||
>
|
||||
Upgrade to Pro
|
||||
</a>{" "}
|
||||
to get
|
||||
<br />
|
||||
Supermemory Connections
|
||||
</p>
|
||||
<div className="space-y-2 text-[14px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<span>Unlimited memories</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<span>10 connections</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<span>Advanced search</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-[#4BA0FA]" />
|
||||
<span>Priority support</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"text-[#737373] text-center max-w-[174px] font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<p>No connections yet</p>
|
||||
<p className="text-[12px]">
|
||||
Choose a service above to import your knowledge
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,71 @@
|
|||
import { useState } from "react"
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/utils/fonts"
|
||||
import { FileIcon } from "lucide-react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
export function FileContent() {
|
||||
export interface FileData {
|
||||
file: File | null
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface FileContentProps {
|
||||
onSubmit?: (data: { file: File; title: string; description: string }) => void
|
||||
onDataChange?: (data: FileData) => void
|
||||
isSubmitting?: boolean
|
||||
isOpen?: boolean
|
||||
}
|
||||
|
||||
export function FileContent({ onSubmit, onDataChange, isSubmitting, isOpen }: FileContentProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
|
||||
const canSubmit = selectedFile !== null && !isSubmitting
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (canSubmit && onSubmit && selectedFile) {
|
||||
onSubmit({ file: selectedFile, title, description })
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = (newFile: File | null, newTitle: string, newDescription: string) => {
|
||||
onDataChange?.({ file: newFile, title: newTitle, description: newDescription })
|
||||
}
|
||||
|
||||
const handleFileChange = (file: File | null) => {
|
||||
setSelectedFile(file)
|
||||
updateData(file, title, description)
|
||||
}
|
||||
|
||||
const handleTitleChange = (newTitle: string) => {
|
||||
setTitle(newTitle)
|
||||
updateData(selectedFile, newTitle, description)
|
||||
}
|
||||
|
||||
const handleDescriptionChange = (newDescription: string) => {
|
||||
setDescription(newDescription)
|
||||
updateData(selectedFile, title, newDescription)
|
||||
}
|
||||
|
||||
useHotkeys("mod+enter", handleSubmit, {
|
||||
enabled: isOpen && canSubmit,
|
||||
enableOnFormTags: ["INPUT", "TEXTAREA"],
|
||||
})
|
||||
|
||||
// Reset content when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedFile(null)
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
onDataChange?.({ file: null, title: "", description: "" })
|
||||
}
|
||||
}, [isOpen, onDataChange])
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
|
|
@ -22,14 +82,14 @@ export function FileContent() {
|
|||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) {
|
||||
setSelectedFile(file)
|
||||
handleFileChange(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setSelectedFile(file)
|
||||
handleFileChange(file)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -48,12 +108,14 @@ export function FileContent() {
|
|||
isDragging
|
||||
? "border-[#4BA0FA] bg-[#4BA0FA]/10"
|
||||
: "border-[#737373]/30 hover:border-[#737373]/50",
|
||||
isSubmitting && "opacity-50 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleFileSelect}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
disabled={isSubmitting}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt"
|
||||
/>
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-[#0F1217]">
|
||||
|
|
@ -80,15 +142,21 @@ export function FileContent() {
|
|||
<p className="text-[14px] font-semibold pl-2">Title (optional)</p>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Give this file a title"
|
||||
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out"
|
||||
disabled={isSubmitting}
|
||||
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[14px] font-semibold pl-2">Description (optional)</p>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder="Add notes or context about this file"
|
||||
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out"
|
||||
disabled={isSubmitting}
|
||||
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/utils/fonts"
|
||||
import {
|
||||
|
|
@ -9,19 +9,42 @@ import {
|
|||
GlobeIcon,
|
||||
ZapIcon,
|
||||
ChevronsUpDownIcon,
|
||||
FolderIcon,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { ConnectContent } from "./connections"
|
||||
import { NoteContent } from "./note"
|
||||
import { LinkContent } from "./link"
|
||||
import { FileContent } from "./file"
|
||||
import { LinkContent, type LinkData } from "./link"
|
||||
import { FileContent, type FileData } from "./file"
|
||||
import { useProject } from "@/stores"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
|
||||
import type { Project } from "@repo/lib/types"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { motion } from "motion/react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@repo/ui/components/dropdown-menu"
|
||||
import { toast } from "sonner"
|
||||
import { useDocumentMutations } from "./useDocumentMutations"
|
||||
|
||||
type TabType = "note" | "link" | "file" | "connect"
|
||||
|
||||
interface AddDocumentModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
defaultTab?: TabType
|
||||
}
|
||||
|
||||
export function AddDocumentModal({ isOpen, onClose }: AddDocumentModalProps) {
|
||||
export function AddDocumentModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
defaultTab,
|
||||
}: AddDocumentModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
|
|
@ -35,16 +58,19 @@ export function AddDocumentModal({ isOpen, onClose }: AddDocumentModalProps) {
|
|||
}}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogTitle className="sr-only">Add Document</DialogTitle>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<AddDocument />
|
||||
<AddDocument
|
||||
defaultTab={defaultTab}
|
||||
onClose={onClose}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type TabType = "note" | "link" | "file" | "connect"
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "note" as const,
|
||||
|
|
@ -73,12 +99,150 @@ const tabs = [
|
|||
},
|
||||
]
|
||||
|
||||
export function AddDocument() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>("note")
|
||||
export function AddDocument({
|
||||
defaultTab,
|
||||
onClose,
|
||||
isOpen,
|
||||
}: {
|
||||
defaultTab?: TabType
|
||||
onClose: () => void
|
||||
isOpen?: boolean
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>(defaultTab ?? "note")
|
||||
const { selectedProject: globalSelectedProject } = useProject()
|
||||
const [localSelectedProject, setLocalSelectedProject] = useState<string>(
|
||||
globalSelectedProject,
|
||||
)
|
||||
const [isProjectSelectorOpen, setIsProjectSelectorOpen] = useState(false)
|
||||
|
||||
// Form data state for button click handling
|
||||
const [noteContent, setNoteContent] = useState("")
|
||||
const [linkData, setLinkData] = useState<LinkData>({
|
||||
url: "",
|
||||
title: "",
|
||||
description: "",
|
||||
})
|
||||
const [fileData, setFileData] = useState<FileData>({
|
||||
file: null,
|
||||
title: "",
|
||||
description: "",
|
||||
})
|
||||
|
||||
const { noteMutation, linkMutation, fileMutation } = useDocumentMutations({
|
||||
onClose,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSelectedProject(globalSelectedProject)
|
||||
}, [globalSelectedProject])
|
||||
|
||||
const { data: projects = [], isLoading: isLoadingProjects } = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@get/projects")
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to load projects")
|
||||
}
|
||||
|
||||
return response.data?.projects || []
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
const projectName = useMemo(() => {
|
||||
if (localSelectedProject === DEFAULT_PROJECT_ID) return "Default Project"
|
||||
const found = projects.find(
|
||||
(p: Project) => p.containerTag === localSelectedProject,
|
||||
)
|
||||
return found?.name ?? localSelectedProject
|
||||
}, [projects, localSelectedProject])
|
||||
|
||||
const handleProjectSelect = (containerTag: string) => {
|
||||
setLocalSelectedProject(containerTag)
|
||||
setIsProjectSelectorOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultTab) {
|
||||
setActiveTab(defaultTab)
|
||||
}
|
||||
}, [defaultTab])
|
||||
|
||||
// Submit handlers
|
||||
const handleNoteSubmit = useCallback(
|
||||
(content: string) => {
|
||||
if (!content.trim()) {
|
||||
toast.error("Please enter some content")
|
||||
return
|
||||
}
|
||||
noteMutation.mutate({ content, project: localSelectedProject })
|
||||
},
|
||||
[noteMutation, localSelectedProject],
|
||||
)
|
||||
|
||||
const handleLinkSubmit = useCallback(
|
||||
(data: LinkData) => {
|
||||
if (!data.url.trim()) {
|
||||
toast.error("Please enter a URL")
|
||||
return
|
||||
}
|
||||
linkMutation.mutate({ url: data.url, project: localSelectedProject })
|
||||
},
|
||||
[linkMutation, localSelectedProject],
|
||||
)
|
||||
|
||||
const handleFileSubmit = useCallback(
|
||||
(data: { file: File; title: string; description: string }) => {
|
||||
if (!data.file) {
|
||||
toast.error("Please select a file")
|
||||
return
|
||||
}
|
||||
fileMutation.mutate({
|
||||
file: data.file,
|
||||
title: data.title || undefined,
|
||||
description: data.description || undefined,
|
||||
project: localSelectedProject,
|
||||
})
|
||||
},
|
||||
[fileMutation, localSelectedProject],
|
||||
)
|
||||
|
||||
// Data change handlers
|
||||
const handleNoteContentChange = useCallback((content: string) => {
|
||||
setNoteContent(content)
|
||||
}, [])
|
||||
|
||||
const handleLinkDataChange = useCallback((data: LinkData) => {
|
||||
setLinkData(data)
|
||||
}, [])
|
||||
|
||||
const handleFileDataChange = useCallback((data: FileData) => {
|
||||
setFileData(data)
|
||||
}, [])
|
||||
|
||||
// Button click handler
|
||||
const handleButtonClick = () => {
|
||||
if (activeTab === "note") {
|
||||
handleNoteSubmit(noteContent)
|
||||
} else if (activeTab === "link") {
|
||||
handleLinkSubmit(linkData)
|
||||
} else if (activeTab === "file") {
|
||||
if (fileData.file) {
|
||||
handleFileSubmit(
|
||||
fileData as { file: File; title: string; description: string },
|
||||
)
|
||||
} else {
|
||||
toast.error("Please select a file")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isSubmitting =
|
||||
noteMutation.isPending || linkMutation.isPending || fileMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-row text-white space-x-6">
|
||||
{/* Tabs - 1/3 width */}
|
||||
<div className="w-1/3 flex flex-col justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
{tabs.map((tab) => (
|
||||
|
|
@ -94,8 +258,8 @@ export function AddDocument() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Memories counter */}
|
||||
<div
|
||||
data-testid="memories-counter"
|
||||
className="bg-[#1B1F24] rounded-2xl p-4 mr-4"
|
||||
style={{
|
||||
boxShadow:
|
||||
|
|
@ -124,34 +288,127 @@ export function AddDocument() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - 2/3 width */}
|
||||
<div className="w-2/3 overflow-auto flex flex-col justify-between">
|
||||
{activeTab === "note" && <NoteContent />}
|
||||
{activeTab === "link" && <LinkContent />}
|
||||
{activeTab === "file" && <FileContent />}
|
||||
{activeTab === "connect" && <ConnectContent />}
|
||||
{activeTab === "note" && (
|
||||
<NoteContent
|
||||
onSubmit={handleNoteSubmit}
|
||||
onContentChange={handleNoteContentChange}
|
||||
isSubmitting={noteMutation.isPending}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "link" && (
|
||||
<LinkContent
|
||||
onSubmit={handleLinkSubmit}
|
||||
onDataChange={handleLinkDataChange}
|
||||
isSubmitting={linkMutation.isPending}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "file" && (
|
||||
<FileContent
|
||||
onSubmit={handleFileSubmit}
|
||||
onDataChange={handleFileDataChange}
|
||||
isSubmitting={fileMutation.isPending}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "connect" && (
|
||||
<ConnectContent selectedProject={localSelectedProject} />
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="insideOut">
|
||||
My Space <ChevronsUpDownIcon className="size-4" color="#737373" />
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
open={isProjectSelectorOpen}
|
||||
onOpenChange={setIsProjectSelectorOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
className="gap-2"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<FolderIcon className="size-4" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
{isLoadingProjects ? "..." : projectName}
|
||||
</span>
|
||||
<motion.div
|
||||
animate={{ rotate: isProjectSelectorOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4" color="#737373" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-56 bg-[#1B1F24] border border-[#2A2E35] rounded-[12px] p-1.5 max-h-64 overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleProjectSelect(DEFAULT_PROJECT_ID)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-[8px] cursor-pointer",
|
||||
localSelectedProject === DEFAULT_PROJECT_ID
|
||||
? "bg-[#4BA0FA]/20 text-white"
|
||||
: "text-[#737373] hover:bg-[#14161A] hover:text-white",
|
||||
)}
|
||||
>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Default Project</span>
|
||||
</DropdownMenuItem>
|
||||
{projects
|
||||
.filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID)
|
||||
.map((project: Project) => (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => handleProjectSelect(project.containerTag)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-[8px] cursor-pointer",
|
||||
localSelectedProject === project.containerTag
|
||||
? "bg-[#4BA0FA]/20 text-white"
|
||||
: "text-[#737373] hover:bg-[#14161A] hover:text-white",
|
||||
)}
|
||||
>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="text-[#737373] cursor-pointer rounded-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{activeTab !== "connect" && (
|
||||
<Button variant="insideOut">
|
||||
+ Add {activeTab}{" "}
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm px-1 py-0.5 text-[10px] flex items-center justify-center",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
⌘+Enter
|
||||
</span>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleButtonClick}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
+ Add {activeTab}{" "}
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm px-1 py-0.5 text-[10px] flex items-center justify-center",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
⌘+Enter
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -181,7 +438,7 @@ function TabButton({
|
|||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-4 rounded-[16px] text-left transition-colors w-full",
|
||||
"flex items-start gap-3 p-4 rounded-[16px] text-left transition-colors w-full focus:outline-none focus:ring-0",
|
||||
active
|
||||
? "bg-[#14161A] shadow-inside-out"
|
||||
: "hover:bg-[#14161A]/50 hover:shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,71 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { dmSansClassName } from "@/utils/fonts"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
export interface LinkData {
|
||||
url: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface LinkContentProps {
|
||||
onSubmit?: (data: LinkData) => void
|
||||
onDataChange?: (data: LinkData) => void
|
||||
isSubmitting?: boolean
|
||||
isOpen?: boolean
|
||||
}
|
||||
|
||||
export function LinkContent({ onSubmit, onDataChange, isSubmitting, isOpen }: LinkContentProps) {
|
||||
const [url, setUrl] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
|
||||
const canSubmit = url.trim().length > 0 && !isSubmitting
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (canSubmit && onSubmit) {
|
||||
onSubmit({ url, title, description })
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = (newUrl: string, newTitle: string, newDescription: string) => {
|
||||
onDataChange?.({ url: newUrl, title: newTitle, description: newDescription })
|
||||
}
|
||||
|
||||
const handleUrlChange = (newUrl: string) => {
|
||||
setUrl(newUrl)
|
||||
updateData(newUrl, title, description)
|
||||
}
|
||||
|
||||
const handleTitleChange = (newTitle: string) => {
|
||||
setTitle(newTitle)
|
||||
updateData(url, newTitle, description)
|
||||
}
|
||||
|
||||
const handleDescriptionChange = (newDescription: string) => {
|
||||
setDescription(newDescription)
|
||||
updateData(url, title, newDescription)
|
||||
}
|
||||
|
||||
useHotkeys("mod+enter", handleSubmit, {
|
||||
enabled: isOpen && canSubmit,
|
||||
enableOnFormTags: ["INPUT", "TEXTAREA"],
|
||||
})
|
||||
|
||||
// Reset content when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setUrl("")
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
onDataChange?.({ url: "", title: "", description: "" })
|
||||
}
|
||||
}, [isOpen, onDataChange])
|
||||
|
||||
export function LinkContent() {
|
||||
return (
|
||||
<div className={cn("flex flex-col space-y-4 pt-4", dmSansClassName())}>
|
||||
<div>
|
||||
|
|
@ -14,12 +77,15 @@ export function LinkContent() {
|
|||
<div className="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder="https://maheshthedev.me"
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
"w-full p-4 rounded-xl bg-[#14161A] shadow-inside-out",
|
||||
"w-full p-4 rounded-xl bg-[#14161A] shadow-inside-out disabled:opacity-50",
|
||||
)}
|
||||
/>
|
||||
<Button variant="linkPreview" className="absolute right-2 top-2">
|
||||
<Button variant="linkPreview" className="absolute right-2 top-2" disabled={isSubmitting}>
|
||||
Preview Link
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -31,8 +97,11 @@ export function LinkContent() {
|
|||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Mahesh Sanikommu - Portfolio"
|
||||
className="w-full px-4 py-3 bg-[#0F1217] rounded-xl"
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-4 py-3 bg-[#0F1217] rounded-xl disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -40,8 +109,11 @@ export function LinkContent() {
|
|||
Link description
|
||||
</p>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder="Portfolio website of Mahesh Sanikommu"
|
||||
className="w-full px-4 py-3 bg-[#0F1217] rounded-xl"
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-4 py-3 bg-[#0F1217] rounded-xl disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -49,7 +121,7 @@ export function LinkContent() {
|
|||
Link Preview
|
||||
</p>
|
||||
<div className="w-full px-4 py-3 bg-[#0F1217] rounded-xl">
|
||||
<p>Portfolio website of Mahesh Sanikommu</p>
|
||||
<p>{description || "Portfolio website of Mahesh Sanikommu"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,51 @@
|
|||
export function NoteContent() {
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
interface NoteContentProps {
|
||||
onSubmit?: (content: string) => void
|
||||
onContentChange?: (content: string) => void
|
||||
isSubmitting?: boolean
|
||||
isOpen?: boolean
|
||||
}
|
||||
|
||||
export function NoteContent({ onSubmit, onContentChange, isSubmitting, isOpen }: NoteContentProps) {
|
||||
const [content, setContent] = useState("")
|
||||
|
||||
const canSubmit = content.trim().length > 0 && !isSubmitting
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (canSubmit && onSubmit) {
|
||||
onSubmit(content)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setContent(newContent)
|
||||
onContentChange?.(newContent)
|
||||
}
|
||||
|
||||
useHotkeys("mod+enter", handleSubmit, {
|
||||
enabled: isOpen && canSubmit,
|
||||
enableOnFormTags: ["TEXTAREA"],
|
||||
})
|
||||
|
||||
// Reset content when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setContent("")
|
||||
onContentChange?.("")
|
||||
}
|
||||
}, [isOpen, onContentChange])
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
placeholder="Write your note here..."
|
||||
className="w-full h-full p-4 mb-4! rounded-[14px] bg-[#14161A] shadow-inside-out resize-none"
|
||||
disabled={isSubmitting}
|
||||
className="w-full h-full p-4 mb-4! rounded-[14px] bg-[#14161A] shadow-inside-out resize-none disabled:opacity-50"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
313
apps/web/components/new/add-document/useDocumentMutations.ts
Normal file
313
apps/web/components/new/add-document/useDocumentMutations.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"use client"
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
interface DocumentsQueryData {
|
||||
documents: unknown[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
interface UseDocumentMutationsOptions {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function useDocumentMutations({ onClose }: UseDocumentMutationsOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const noteMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
content,
|
||||
project,
|
||||
}: {
|
||||
content: string
|
||||
project: string
|
||||
}) => {
|
||||
const response = await $fetch("@post/documents", {
|
||||
body: {
|
||||
content: content,
|
||||
containerTags: [project],
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to add note")
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
onMutate: async ({ content, project }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["documents-with-memories", project],
|
||||
})
|
||||
|
||||
const previousMemories = queryClient.getQueryData([
|
||||
"documents-with-memories",
|
||||
project,
|
||||
])
|
||||
|
||||
const optimisticMemory = {
|
||||
id: `temp-${Date.now()}`,
|
||||
content: content,
|
||||
url: null,
|
||||
title: content.substring(0, 100),
|
||||
description: "Processing content...",
|
||||
containerTags: [project],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
status: "queued",
|
||||
type: "note",
|
||||
metadata: {
|
||||
processingStage: "queued",
|
||||
processingMessage: "Added to processing queue",
|
||||
},
|
||||
memoryEntries: [],
|
||||
isOptimistic: true,
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", project],
|
||||
(old: DocumentsQueryData | undefined) => {
|
||||
if (!old) return { documents: [optimisticMemory], totalCount: 1 }
|
||||
return {
|
||||
...old,
|
||||
documents: [optimisticMemory, ...old.documents],
|
||||
totalCount: old.totalCount + 1,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return { previousMemories }
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.previousMemories) {
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", variables.project],
|
||||
context.previousMemories,
|
||||
)
|
||||
}
|
||||
toast.error("Failed to add note", {
|
||||
description: _error instanceof Error ? _error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("Note added successfully!", {
|
||||
description: "Your note is being processed",
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["documents-with-memories", variables.project],
|
||||
})
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
const linkMutation = useMutation({
|
||||
mutationFn: async ({ url, project }: { url: string; project: string }) => {
|
||||
const response = await $fetch("@post/documents", {
|
||||
body: {
|
||||
content: url,
|
||||
containerTags: [project],
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to add link")
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
onMutate: async ({ url, project }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["documents-with-memories", project],
|
||||
})
|
||||
|
||||
const previousMemories = queryClient.getQueryData([
|
||||
"documents-with-memories",
|
||||
project,
|
||||
])
|
||||
|
||||
const optimisticMemory = {
|
||||
id: `temp-${Date.now()}`,
|
||||
content: "",
|
||||
url: url,
|
||||
title: "Processing...",
|
||||
description: "Extracting content...",
|
||||
containerTags: [project],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
status: "queued",
|
||||
type: "link",
|
||||
metadata: {
|
||||
processingStage: "queued",
|
||||
processingMessage: "Added to processing queue",
|
||||
},
|
||||
memoryEntries: [],
|
||||
isOptimistic: true,
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", project],
|
||||
(old: DocumentsQueryData | undefined) => {
|
||||
if (!old) return { documents: [optimisticMemory], totalCount: 1 }
|
||||
return {
|
||||
...old,
|
||||
documents: [optimisticMemory, ...old.documents],
|
||||
totalCount: old.totalCount + 1,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return { previousMemories }
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.previousMemories) {
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", variables.project],
|
||||
context.previousMemories,
|
||||
)
|
||||
}
|
||||
toast.error("Failed to add link", {
|
||||
description: _error instanceof Error ? _error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("Link added successfully!", {
|
||||
description: "Your link is being processed",
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["documents-with-memories", variables.project],
|
||||
})
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
const fileMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
file,
|
||||
title,
|
||||
description,
|
||||
project,
|
||||
}: {
|
||||
file: File
|
||||
title?: string
|
||||
description?: string
|
||||
project: string
|
||||
}) => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("containerTags", JSON.stringify([project]))
|
||||
formData.append(
|
||||
"metadata",
|
||||
JSON.stringify({
|
||||
sm_source: "consumer",
|
||||
}),
|
||||
)
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || "Failed to upload file")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (title || description) {
|
||||
await $fetch(`@patch/documents/${data.id}`, {
|
||||
body: {
|
||||
metadata: {
|
||||
...(title && { title }),
|
||||
...(description && { description }),
|
||||
sm_source: "consumer",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
onMutate: async ({ file, title, description, project }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["documents-with-memories", project],
|
||||
})
|
||||
|
||||
const previousMemories = queryClient.getQueryData([
|
||||
"documents-with-memories",
|
||||
project,
|
||||
])
|
||||
|
||||
const optimisticMemory = {
|
||||
id: `temp-file-${Date.now()}`,
|
||||
content: "",
|
||||
url: null,
|
||||
title: title || file.name,
|
||||
description: description || `Uploading ${file.name}...`,
|
||||
containerTags: [project],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
status: "processing",
|
||||
type: "file",
|
||||
metadata: {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
},
|
||||
memoryEntries: [],
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", project],
|
||||
(old: DocumentsQueryData | undefined) => {
|
||||
if (!old) return { documents: [optimisticMemory], totalCount: 1 }
|
||||
return {
|
||||
...old,
|
||||
documents: [optimisticMemory, ...old.documents],
|
||||
totalCount: old.totalCount + 1,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return { previousMemories }
|
||||
},
|
||||
onError: (_error, variables, context) => {
|
||||
if (context?.previousMemories) {
|
||||
queryClient.setQueryData(
|
||||
["documents-with-memories", variables.project],
|
||||
context.previousMemories,
|
||||
)
|
||||
}
|
||||
toast.error("Failed to upload file", {
|
||||
description: _error instanceof Error ? _error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("File uploaded successfully!", {
|
||||
description: "Your file is being processed",
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["documents-with-memories", variables.project],
|
||||
})
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
noteMutation,
|
||||
linkMutation,
|
||||
fileMutation,
|
||||
}
|
||||
}
|
||||
|
|
@ -34,14 +34,14 @@ function VersionStatus({
|
|||
<title>Latest</title>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.00069 0L9.33082 2.5V7.5L5.00069 10L0.670563 7.5V2.5L5.00069 0ZM7.5008 2.5H5.0008H2.50069L2.5008 7.5H7.5008V5V2.5Z"
|
||||
fill="#00FFA9"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.0008 2.5H2.50069L2.5008 7.5H7.5008V5C6.12009 5 5.0008 3.88071 5.0008 2.5Z"
|
||||
fill="#005236"
|
||||
/>
|
||||
|
|
@ -52,7 +52,7 @@ function VersionStatus({
|
|||
<path
|
||||
d="M9.08072 2.64453V7.35449L5.00064 9.71094L0.920563 7.35449V2.64453L5.00064 0.288086L9.08072 2.64453ZM2.25064 2.25V7.75H7.75064V2.25H2.25064ZM4.76334 2.75C4.88226 4.0691 5.93157 5.11728 7.25064 5.23633V7.25H2.75064V2.75H4.76334ZM7.25064 2.75V4.73438C6.20794 4.61894 5.3806 3.79274 5.26529 2.75H7.25064Z"
|
||||
stroke="#00FFA9"
|
||||
stroke-width="0.5"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
@ -75,24 +75,24 @@ function VersionStatus({
|
|||
cy="5.00073"
|
||||
r="0.833333"
|
||||
stroke="#369BFD"
|
||||
stroke-opacity="0.5"
|
||||
stroke-width="0.833333"
|
||||
strokeOpacity="0.5"
|
||||
strokeWidth="0.833333"
|
||||
/>
|
||||
<circle
|
||||
cx="5.00057"
|
||||
cy="5.00081"
|
||||
r="2.91667"
|
||||
stroke="#369BFD"
|
||||
stroke-opacity="0.5"
|
||||
stroke-width="0.833333"
|
||||
strokeOpacity="0.5"
|
||||
strokeWidth="0.833333"
|
||||
/>
|
||||
<circle
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="4.58333"
|
||||
stroke="#369BFD"
|
||||
stroke-opacity="0.2"
|
||||
stroke-width="0.833333"
|
||||
strokeOpacity="0.2"
|
||||
strokeWidth="0.833333"
|
||||
/>
|
||||
</svg>
|
||||
Static
|
||||
|
|
@ -110,21 +110,21 @@ function VersionStatus({
|
|||
<title>Expiring</title>
|
||||
<g opacity="0.6">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.50066 2.5H7.50077V7.5H2.50077L2.50066 2.5Z"
|
||||
fill="#4D2E00"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.00066 0L9.33078 2.5V7.5L5.00066 10L0.670532 7.5V2.5L5.00066 0ZM7.50077 2.5H2.50066L2.50077 7.5H7.50077V2.5Z"
|
||||
fill="#FE9900"
|
||||
/>
|
||||
<path
|
||||
d="M9.08069 2.64453V7.35449L5.00061 9.71094L0.920532 7.35449V2.64453L5.00061 0.288086L9.08069 2.64453ZM2.25061 2.25V7.75H7.75061V2.25H2.25061ZM7.25061 2.75V7.25H2.75061V2.75H7.25061Z"
|
||||
stroke="#FE9900"
|
||||
stroke-width="0.5"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
|
@ -146,12 +146,12 @@ function VersionStatus({
|
|||
d="M9.08008 2.64453V7.35449L5 9.71094L0.919922 7.35449V2.64453L5 0.288086L9.08008 2.64453Z"
|
||||
fill="#60272C"
|
||||
stroke="#FF6467"
|
||||
stroke-width="0.5"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M2.08333 2.08341L7.91677 7.91685M7.91667 2.08341L2.08333 7.91675"
|
||||
stroke="#9C4044"
|
||||
stroke-width="0.5"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</svg>
|
||||
Forgotten
|
||||
|
|
|
|||
|
|
@ -1,20 +1,33 @@
|
|||
"use client"
|
||||
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { ArrowUpRightIcon, XIcon } from "lucide-react"
|
||||
import type { z } from "zod"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
import dynamic from "next/dynamic"
|
||||
import { Title } from "./title"
|
||||
import { Summary as DocumentSummary } from "./summary"
|
||||
import { dmSansClassName } from "@/utils/fonts"
|
||||
import { GraphListMemories, type MemoryEntry } from "./graph-list-memories"
|
||||
import { PdfViewer } from "./content/pdf"
|
||||
import { YoutubeVideo } from "./content/yt-video"
|
||||
import { TweetContent } from "./content/tweet"
|
||||
import { isTwitterUrl } from "@/utils/url-helpers"
|
||||
|
||||
// Dynamically importing to prevent DOMMatrix error
|
||||
const PdfViewer = dynamic(
|
||||
() => import("./content/pdf").then((mod) => ({ default: mod.PdfViewer })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
Loading PDF viewer...
|
||||
</div>
|
||||
),
|
||||
},
|
||||
) as typeof import("./content/pdf").PdfViewer
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
|
|
@ -43,6 +56,9 @@ export function DocumentModal({
|
|||
}}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{_document?.title} - Document
|
||||
</DialogTitle>
|
||||
<div className="flex justify-between h-fit">
|
||||
<Title
|
||||
title={_document?.title}
|
||||
|
|
|
|||
|
|
@ -27,15 +27,8 @@ import { $fetch } from "@repo/lib/api"
|
|||
import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"
|
||||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
import { useProject } from "@/stores"
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
containerTag: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isExperimental?: boolean
|
||||
}
|
||||
import { useRouter } from "next/navigation"
|
||||
import type { Project } from "@repo/lib/types"
|
||||
|
||||
interface HeaderProps {
|
||||
onAddMemory?: () => void
|
||||
|
|
@ -48,7 +41,7 @@ export function Header({ onAddMemory, onOpenMCP }: HeaderProps) {
|
|||
const projectName = useProjectName()
|
||||
const { selectedProject } = useProject()
|
||||
const { switchProject } = useProjectMutations()
|
||||
|
||||
const router = useRouter()
|
||||
const { data: projects = [] } = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: async () => {
|
||||
|
|
@ -204,7 +197,10 @@ export function Header({ onAddMemory, onOpenMCP }: HeaderProps) {
|
|||
</span>
|
||||
</Button>
|
||||
{user && (
|
||||
<Avatar className="border border-border h-8 w-8 md:h-10 md:w-10">
|
||||
<Avatar
|
||||
className="border border-border h-8 w-8 md:h-10 md:w-10"
|
||||
onClick={() => router.push("/new/settings")}
|
||||
>
|
||||
<AvatarImage src={user?.image ?? ""} />
|
||||
<AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
|
|
|||
694
apps/web/components/new/settings/account.tsx
Normal file
694
apps/web/components/new/settings/account.tsx
Normal file
|
|
@ -0,0 +1,694 @@
|
|||
"use client"
|
||||
|
||||
import { dmSans125ClassName } from "@/utils/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { fetchMemoriesFeature, fetchSubscriptionStatus } from "@lib/queries"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
} from "@ui/components/dialog"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { Check, X, Trash2, LoaderIcon, Settings } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanFeatureRow({
|
||||
icon,
|
||||
text,
|
||||
variant = "muted",
|
||||
}: {
|
||||
icon: "check" | "x"
|
||||
text: string
|
||||
variant?: "muted" | "highlight"
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{icon === "check" ? (
|
||||
<Check
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
variant === "highlight" ? "text-[#4BA0FA]" : "text-[#737373]",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<X className="size-4 shrink-0 text-[#737373]" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] tracking-[-0.14px]",
|
||||
variant === "highlight" ? "text-white" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
const { user, org } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("")
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
|
||||
// Billing data
|
||||
const {
|
||||
data: status = {
|
||||
consumer_pro: { allowed: false, status: null },
|
||||
},
|
||||
isLoading: isCheckingStatus,
|
||||
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
|
||||
|
||||
const proStatus = status.consumer_pro
|
||||
const hasProProduct = proStatus?.status !== null
|
||||
|
||||
const { data: memoriesCheck } = fetchMemoriesFeature(
|
||||
autumn,
|
||||
!autumn.isLoading && !isCheckingStatus,
|
||||
)
|
||||
const memoriesUsed = memoriesCheck?.usage ?? 0
|
||||
const memoriesLimit = memoriesCheck?.included_usage ?? 200
|
||||
|
||||
// Calculate progress percentage
|
||||
const usagePercent = Math.min((memoriesUsed / memoriesLimit) * 100, 100)
|
||||
|
||||
// Handlers
|
||||
const handleUpgrade = async () => {
|
||||
setIsUpgrading(true)
|
||||
try {
|
||||
await autumn.attach({
|
||||
productId: "consumer_pro",
|
||||
successUrl: "https://app.supermemory.ai/new/settings#account",
|
||||
})
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setIsUpgrading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
if (deleteConfirmText !== "DELETE") return
|
||||
// TODO: Implement account deletion API call
|
||||
console.log("Delete account requested")
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeleteConfirmText("")
|
||||
}
|
||||
|
||||
const isDeleteEnabled = deleteConfirmText === "DELETE"
|
||||
|
||||
// Format member since date
|
||||
const memberSince = user?.createdAt
|
||||
? new Date(user.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
: "—"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 pt-4 w-full ">
|
||||
<section id="profile-details" className="flex flex-col gap-4">
|
||||
<SectionTitle>Profile Details</SectionTitle>
|
||||
<SettingsCard>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Avatar + Name/Email */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative size-16 rounded-full bg-linear-to-b from-[#0D121A] to-black overflow-hidden shrink-0">
|
||||
<Avatar className="size-full">
|
||||
<AvatarImage
|
||||
src={user?.image ?? ""}
|
||||
alt={user?.name ?? "User"}
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback className="bg-transparent text-white text-xl">
|
||||
{user?.name?.charAt(0) ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{user?.name ?? "—"}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{user?.email ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization + Member since */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Organization
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{org?.name ?? "Personal"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Member since
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{memberSince}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</section>
|
||||
|
||||
<section id="billing-subscription" className="flex flex-col gap-4">
|
||||
<SectionTitle>Billing & Subscription</SectionTitle>
|
||||
<SettingsCard>
|
||||
<div className="flex flex-col gap-6">
|
||||
{hasProProduct ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-4">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Pro plan
|
||||
</p>
|
||||
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-[3px] rounded-[3px] h-[18px] flex items-center justify-center">
|
||||
ACTIVE
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Expanded memory with connections and more
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="progress-bar" className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Unlimited Memories
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#4BA0FA]",
|
||||
)}
|
||||
>
|
||||
{memoriesUsed}/
|
||||
</span>
|
||||
<span className="text-[#4BA0FA] text-[20px] leading-none ml-0.5">
|
||||
∞
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="progress-bar-fill"
|
||||
className="h-3 w-full rounded-[40px] bg-[#2E353D] blur-[1px] p-px overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="h-full w-full rounded-[40px]"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, #4BA0FA 80.517%, #002757 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
autumn.openBillingPortal?.()
|
||||
}}
|
||||
className={cn(
|
||||
"relative w-full h-11 rounded-full flex items-center justify-center gap-2",
|
||||
"bg-[#0D121A] border border-[rgba(115,115,115,0.2)]",
|
||||
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
Manage billing
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Free plan card */}
|
||||
<div className="flex flex-col gap-4 p-4 rounded-[10px] border border-white/10 overflow-hidden">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Free plan
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<PlanFeatureRow icon="x" text="Limited 200 memories" />
|
||||
<PlanFeatureRow icon="x" text="No connections" />
|
||||
<PlanFeatureRow icon="check" text="Basic search" />
|
||||
<PlanFeatureRow icon="check" text="Basic support" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pro plan card - highlighted */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4 p-4 rounded-[10px]",
|
||||
"bg-[#1B1F24]",
|
||||
"shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]",
|
||||
"relative overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{/* Header with ACTIVE badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Pro plan
|
||||
</p>
|
||||
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-[3px] rounded-[3px] h-[18px] flex items-center justify-center">
|
||||
ACTIVE
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="Unlimited memories"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="10 connections"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="Advanced search"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="Priority support"
|
||||
variant="highlight"
|
||||
/>
|
||||
</div>
|
||||
{/* Inset highlight */}
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Free Plan
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
You are on basic plan
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Memories
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{memoriesUsed}/{memoriesLimit}
|
||||
</p>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px">
|
||||
<div
|
||||
className="h-full rounded-[40px] bg-[#0054AD] transition-all"
|
||||
style={{ width: `${usagePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
disabled={isUpgrading || isCheckingStatus || autumn.isLoading}
|
||||
className={cn(
|
||||
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
|
||||
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
|
||||
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
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 || isCheckingStatus || autumn.isLoading ? (
|
||||
<>
|
||||
<LoaderIcon className="size-4 animate-spin mr-2" />
|
||||
Upgrading...
|
||||
</>
|
||||
) : (
|
||||
"Upgrade to Pro - $9/month"
|
||||
)}
|
||||
{/* Inset blue stroke */}
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Free plan card */}
|
||||
<div className="flex flex-col gap-4 p-4 rounded-[10px] border border-white/10">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Free plan
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<PlanFeatureRow icon="x" text="Limited 200 memories" />
|
||||
<PlanFeatureRow icon="x" text="No connections" />
|
||||
<PlanFeatureRow icon="check" text="Basic search" />
|
||||
<PlanFeatureRow icon="check" text="Basic support" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pro plan card */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4 p-4 rounded-[10px]",
|
||||
"bg-[#1B1F24] border border-white/10",
|
||||
"shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]",
|
||||
"relative overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{/* Header with badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Pro plan
|
||||
</p>
|
||||
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-0.5 rounded-[3px]">
|
||||
RECOMMENDED
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="Unlimited memories"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="10 connections"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="Advanced search"
|
||||
variant="highlight"
|
||||
/>
|
||||
<PlanFeatureRow
|
||||
icon="check"
|
||||
text="Priority support"
|
||||
variant="highlight"
|
||||
/>
|
||||
</div>
|
||||
{/* Inset highlight */}
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</section>
|
||||
|
||||
<section id="delete-account" className="flex flex-col gap-4">
|
||||
<SectionTitle>Delete Account</SectionTitle>
|
||||
<SettingsCard>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA] max-w-[350px]",
|
||||
)}
|
||||
>
|
||||
Permanently delete all your data and cancel any active
|
||||
subscriptions
|
||||
</p>
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteDialogOpen(open)
|
||||
if (!open) setDeleteConfirmText("")
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 px-4 py-2 rounded-full",
|
||||
"bg-[#290F0A] text-[#C73B1B]",
|
||||
"font-normal text-[14px] tracking-[-0.14px]",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
"shrink-0",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-[18px]" />
|
||||
<span>Delete</span>
|
||||
{/* Inset shadow */}
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.4)]" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"bg-[#1B1F24] rounded-[22px] p-4",
|
||||
"shadow-[0px_2.842px_14.211px_rgba(0,0,0,0.25)]",
|
||||
"min-w-xl",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1 flex flex-col gap-1 pl-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Delete account?
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373] leading-[1.35]",
|
||||
)}
|
||||
>
|
||||
This will permanently delete your memories,
|
||||
conversations, settings and cancel any active
|
||||
subscriptions.
|
||||
</p>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative size-7 rounded-full bg-[#0D121A] border border-[#73737333]",
|
||||
"flex items-center justify-center shrink-0",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
<X className="size-4 text-[#737373]" />
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.313px_1.313px_3.938px_rgba(0,0,0,0.7)]" />
|
||||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
{/* Confirmation input */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA] pl-2",
|
||||
)}
|
||||
>
|
||||
Type <span className="text-[#C73B1B]">DELETE</span> to
|
||||
confirm:
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"relative bg-[#14161A] border border-[#52596614] rounded-[12px]",
|
||||
"shadow-[0px_1px_2px_rgba(0,43,87,0.1)]",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="DELETE"
|
||||
className={cn(
|
||||
"w-full px-4 py-3 bg-transparent",
|
||||
"text-[#FAFAFA] placeholder:text-[#737373]",
|
||||
"text-[14px] tracking-[-0.14px]",
|
||||
"outline-none",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
/>
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0px_0px_0px_1px_rgba(43,49,67,0.08),inset_0px_1px_1px_rgba(0,0,0,0.08),inset_0px_2px_4px_rgba(0,0,0,0.02)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-5">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={!isDeleteEnabled}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 pl-4 pr-[18px] py-2 rounded-full",
|
||||
"bg-[#290F0A] text-[#C73B1B]",
|
||||
"font-normal text-[14px] tracking-[-0.14px]",
|
||||
"cursor-pointer transition-opacity",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
isDeleteEnabled && "hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Trash2 className="size-[18px]" />
|
||||
<span>Delete</span>
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.4)]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Modal inset highlight */}
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
568
apps/web/components/new/settings/connections-mcp.tsx
Normal file
568
apps/web/components/new/settings/connections-mcp.tsx
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
"use client"
|
||||
|
||||
import { dmSans125ClassName } from "@/utils/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 { ConnectAIModal } from "@/components/connect-ai-modal"
|
||||
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",
|
||||
description: "Connect your Google Docs, Sheets, and Slides",
|
||||
icon: GoogleDrive,
|
||||
documentLabel: "documents",
|
||||
},
|
||||
notion: {
|
||||
title: "Notion",
|
||||
description: "Import your Notion pages and databases",
|
||||
icon: Notion,
|
||||
documentLabel: "pages",
|
||||
},
|
||||
onedrive: {
|
||||
title: "OneDrive",
|
||||
description: "Access your Microsoft Office documents",
|
||||
icon: OneDrive,
|
||||
documentLabel: "documents",
|
||||
},
|
||||
} as const
|
||||
|
||||
type ConnectorProvider = keyof typeof CONNECTORS
|
||||
|
||||
function SectionTitle({
|
||||
children,
|
||||
badge,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
badge?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
{badge}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProBadge() {
|
||||
return (
|
||||
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-0.5 rounded-[3px]">
|
||||
PRO
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionsCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
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 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(),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionStatusBadge({ connected }: { connected: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[7px] rounded-full",
|
||||
connected ? "bg-[#00AC3F]" : "bg-[#737373]",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px]",
|
||||
connected ? "text-[#00AC3F]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
// Check if connection is active: if expiresAt exists and is in the future, or if no expiresAt
|
||||
const isConnected =
|
||||
!connection.expiresAt || new Date(connection.expiresAt) > new Date()
|
||||
|
||||
// Format relative time
|
||||
const formatRelativeTime = (date: string | null | undefined) => {
|
||||
if (!date) return "Never"
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - 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 getProjectDisplayName = (containerTag: string): string => {
|
||||
if (containerTag === DEFAULT_PROJECT_ID) return "Default Project"
|
||||
const found = projects.find((p) => p.containerTag === containerTag)
|
||||
if (found) return found.name
|
||||
return containerTag.replace(/^sm_project_/, "") // if cached project is not found, remove the prefix
|
||||
}
|
||||
|
||||
const documentCount = (connection.metadata?.documentCount as number) ?? 0
|
||||
const containerTags = (
|
||||
connection as Connection & { containerTags?: string[] }
|
||||
).containerTags
|
||||
const projectName =
|
||||
containerTags && containerTags.length > 0 && containerTags[0]
|
||||
? getProjectDisplayName(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-4">
|
||||
{/* Main row */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon className="size-6 shrink-0" />
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{config.title}
|
||||
</span>
|
||||
<ConnectionStatusBadge connected={isConnected} />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] 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"
|
||||
aria-label="Delete connection"
|
||||
>
|
||||
<Trash2 className="size-[22px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{projectName && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Project: {projectName}
|
||||
</span>
|
||||
<div className="size-[3px] rounded-full bg-[#737373]" />
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Added: {formatRelativeTime(connection.createdAt)}
|
||||
</span>
|
||||
<div className="size-[3px] rounded-full bg-[#737373]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{documentCount} {config.documentLabel} connected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UpgradeOverlay({ onUpgrade }: { onUpgrade: () => void }) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Zap className="size-6 text-[#737373]" />
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#737373] text-center max-w-[184px]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpgrade}
|
||||
className="underline hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Upgrade to Pro
|
||||
</button>{" "}
|
||||
to get Supermemory Connections
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FeatureItem text="Unlimited memories" />
|
||||
<FeatureItem text="10 connections" />
|
||||
<FeatureItem text="Advanced search" />
|
||||
<FeatureItem text="Priority support" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureItem({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] tracking-[-0.14px] text-white",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ConnectionsMCP() {
|
||||
const queryClient = useQueryClient()
|
||||
const autumn = useCustomer()
|
||||
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
|
||||
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
|
||||
[]) as Project[]
|
||||
|
||||
// Billing data
|
||||
const {
|
||||
data: status = {
|
||||
consumer_pro: { allowed: false, status: null },
|
||||
},
|
||||
isLoading: isCheckingStatus,
|
||||
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
|
||||
|
||||
const hasProProduct = status.consumer_pro?.status !== null
|
||||
|
||||
// Get connections data directly from autumn customer
|
||||
const connectionsFeature = autumn.customer?.features?.connections
|
||||
const connectionsUsed = connectionsFeature?.usage ?? 0
|
||||
const connectionsLimit = connectionsFeature?.included_usage ?? 10
|
||||
|
||||
const canAddConnection = connectionsUsed < connectionsLimit
|
||||
|
||||
// Fetch connections
|
||||
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])
|
||||
|
||||
// Delete connection mutation
|
||||
const deleteConnectionMutation = useMutation({
|
||||
mutationFn: async (connectionId: string) => {
|
||||
await $fetch(`@delete/connections/${connectionId}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
analytics.connectionDeleted()
|
||||
toast.success(
|
||||
"Connection removal has started. Supermemory will permanently delete the documents 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",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Upgrade handler
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
await autumn.attach({
|
||||
productId: "consumer_pro",
|
||||
successUrl: "https://app.supermemory.ai/new/settings#connections",
|
||||
})
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = autumn.isLoading || isCheckingStatus
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 pt-4 w-full">
|
||||
{/* Supermemory Connections Section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle badge={<ProBadge />}>
|
||||
Supermemory Connections
|
||||
</SectionTitle>
|
||||
|
||||
<ConnectionsCard className="relative">
|
||||
{/* Blur overlay for free users */}
|
||||
{!hasProProduct && !isLoading && (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-[#14161A]/80 backdrop-blur-sm z-5" />
|
||||
<UpgradeOverlay onUpgrade={handleUpgrade} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<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] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Connected to Supermemory
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] tracking-[-0.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(),
|
||||
"font-medium text-[14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
No connections yet
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Connect a service below to import your knowledge
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PillButton
|
||||
onClick={() => setIsAddDocumentOpen(true)}
|
||||
disabled={!hasProProduct || !canAddConnection}
|
||||
>
|
||||
<Plus className="size-[10px] text-[#FAFAFA]" />
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Connect knowledge bases
|
||||
</span>
|
||||
</PillButton>
|
||||
</div>
|
||||
</ConnectionsCard>
|
||||
</div>
|
||||
|
||||
{/* Supermemory MCP Section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Supermemory MCP</SectionTitle>
|
||||
|
||||
<ConnectionsCard>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Connect your AI to create and use your memories via MCP.{" "}
|
||||
<a
|
||||
href="https://docs.supermemory.ai/supermemory-mcp/introduction"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-[#4BA0FA] transition-colors"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<ConnectAIModal open={mcpModalOpen} onOpenChange={setMcpModalOpen}>
|
||||
<PillButton onClick={() => setMcpModalOpen(true)}>
|
||||
<Plus className="size-[10px] text-[#FAFAFA]" />
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Connect your AI to Supermemory
|
||||
</span>
|
||||
</PillButton>
|
||||
</ConnectAIModal>
|
||||
</div>
|
||||
</ConnectionsCard>
|
||||
</div>
|
||||
|
||||
{/* Add Document Modal */}
|
||||
<AddDocumentModal
|
||||
isOpen={isAddDocumentOpen}
|
||||
onClose={() => setIsAddDocumentOpen(false)}
|
||||
defaultTab="connect"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
761
apps/web/components/new/settings/integrations.tsx
Normal file
761
apps/web/components/new/settings/integrations.tsx
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
"use client"
|
||||
|
||||
import { dmSans125ClassName } from "@/utils/fonts"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { cn } from "@lib/utils"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
import {
|
||||
ADD_MEMORY_SHORTCUT_URL,
|
||||
RAYCAST_EXTENSION_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, Download, Key, Loader, Plus, Search } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useEffect, useId, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function IntegrationCard({
|
||||
children,
|
||||
id,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
id?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
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(),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureItem({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] tracking-[-0.14px] text-white",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// iOS Shortcuts state
|
||||
const [showApiKeyModal, setShowApiKeyModal] = useState(false)
|
||||
const [apiKey, setApiKey] = useState<string>("")
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [selectedShortcutType, setSelectedShortcutType] = useState<
|
||||
"add" | "search" | null
|
||||
>(null)
|
||||
const apiKeyId = useId()
|
||||
|
||||
// Raycast state
|
||||
const [showRaycastApiKeyModal, setShowRaycastApiKeyModal] = useState(false)
|
||||
const [raycastApiKey, setRaycastApiKey] = useState<string>("")
|
||||
const [raycastCopied, setRaycastCopied] = useState(false)
|
||||
const [hasTriggeredRaycast, setHasTriggeredRaycast] = useState(false)
|
||||
const raycastApiKeyId = useId()
|
||||
|
||||
const handleCopyApiKey = async (key: string, isRaycast = false) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
if (isRaycast) {
|
||||
setRaycastCopied(true)
|
||||
setTimeout(() => setRaycastCopied(false), 2000)
|
||||
} else {
|
||||
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 createRaycastApiKeyMutation = 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) => {
|
||||
setRaycastApiKey(key)
|
||||
setShowRaycastApiKeyModal(true)
|
||||
setRaycastCopied(false)
|
||||
handleCopyApiKey(key, true)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create Raycast API key", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const qParam = searchParams.get("q")
|
||||
if (
|
||||
qParam === "raycast" &&
|
||||
!hasTriggeredRaycast &&
|
||||
!createRaycastApiKeyMutation.isPending &&
|
||||
org?.id
|
||||
) {
|
||||
setHasTriggeredRaycast(true)
|
||||
createRaycastApiKeyMutation.mutate()
|
||||
}
|
||||
}, [searchParams, hasTriggeredRaycast, createRaycastApiKeyMutation, org])
|
||||
|
||||
const handleChromeInstall = () => {
|
||||
window.open(
|
||||
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
analytics.extensionInstallClicked()
|
||||
}
|
||||
|
||||
const handleShortcutClick = (shortcutType: "add" | "search") => {
|
||||
setSelectedShortcutType(shortcutType)
|
||||
createApiKeyMutation.mutate()
|
||||
}
|
||||
|
||||
const handleOpenShortcut = () => {
|
||||
if (!selectedShortcutType) {
|
||||
toast.error("No shortcut type selected")
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedShortcutType === "add") {
|
||||
window.open(ADD_MEMORY_SHORTCUT_URL, "_blank")
|
||||
} else if (selectedShortcutType === "search") {
|
||||
window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank")
|
||||
}
|
||||
}
|
||||
|
||||
const handleRaycastClick = () => {
|
||||
createRaycastApiKeyMutation.mutate()
|
||||
}
|
||||
|
||||
const handleRaycastInstall = () => {
|
||||
window.open(RAYCAST_EXTENSION_URL, "_blank")
|
||||
analytics.extensionInstallClicked()
|
||||
}
|
||||
|
||||
const handleDialogClose = (open: boolean) => {
|
||||
setShowApiKeyModal(open)
|
||||
if (!open) {
|
||||
setSelectedShortcutType(null)
|
||||
setApiKey("")
|
||||
setCopied(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRaycastDialogClose = (open: boolean) => {
|
||||
setShowRaycastApiKeyModal(open)
|
||||
if (!open) {
|
||||
setRaycastApiKey("")
|
||||
setRaycastCopied(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pt-4 w-full">
|
||||
<SectionTitle>Integrations</SectionTitle>
|
||||
|
||||
<IntegrationCard id="chrome-extension-card">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div id="chrome-extension-header" className="flex items-center gap-4">
|
||||
<ChromeIcon className="shrink-0 w-10 h-10" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Chrome extension
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Save any webpage directly from your browser
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chrome-extension-cta" className="flex gap-4">
|
||||
<PillButton onClick={handleChromeInstall}>
|
||||
<Download className="size-4 text-[#FAFAFA]" />
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Add to Chrome
|
||||
</span>
|
||||
</PillButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="chrome-extension-features"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-2"
|
||||
>
|
||||
<FeatureItem text="Import all Twitter bookmarks" />
|
||||
<FeatureItem text="Sync ChatGPT memories" />
|
||||
<FeatureItem text="Save any webpage" />
|
||||
<FeatureItem text="One time setup" />
|
||||
</div>
|
||||
</div>
|
||||
</IntegrationCard>
|
||||
|
||||
<IntegrationCard id="apple-shortcuts-card">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div id="apple-shortcuts-header" className="flex items-center gap-4">
|
||||
<AppleShortcutsIcon />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Apple shortcuts
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Add memories directly from iPhone, iPad or Mac
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="apple-shortcuts-cta" 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] tracking-[-0.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] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "search"
|
||||
? "Creating..."
|
||||
: "Search memory shortcut"}
|
||||
</span>
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</IntegrationCard>
|
||||
|
||||
<IntegrationCard id="raycast-extension-card">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div
|
||||
id="raycast-extension-header"
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<RaycastIcon className="shrink-0 w-10 h-10" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Raycast extension
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Add and search memories from Mac and Windows
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="raycast-extension-cta" className="flex gap-4">
|
||||
<PillButton
|
||||
onClick={handleRaycastClick}
|
||||
disabled={createRaycastApiKeyMutation.isPending}
|
||||
>
|
||||
{createRaycastApiKeyMutation.isPending ? (
|
||||
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
|
||||
) : (
|
||||
<Key className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
{createRaycastApiKeyMutation.isPending
|
||||
? "Generating..."
|
||||
: "Get API key"}
|
||||
</span>
|
||||
</PillButton>
|
||||
<PillButton onClick={handleRaycastInstall}>
|
||||
<Download className="size-4 text-[#FAFAFA]" />
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Install extension
|
||||
</span>
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</IntegrationCard>
|
||||
|
||||
<Dialog open={showApiKeyModal} onOpenChange={handleDialogClose}>
|
||||
<DialogPortal>
|
||||
<DialogContent
|
||||
id="ios-shortcuts-modal"
|
||||
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 id="ios-shortcuts-api-key-section" 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 id="ios-shortcuts-steps" className="space-y-3">
|
||||
<h4
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Follow these steps:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div 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">
|
||||
1
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Click "Add to Shortcuts" below to open the shortcut
|
||||
</p>
|
||||
</div>
|
||||
<div 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">
|
||||
2
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Paste your API key when prompted
|
||||
</p>
|
||||
</div>
|
||||
<div 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">
|
||||
3
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Start using your shortcut!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenShortcut}
|
||||
disabled={!selectedShortcutType}
|
||||
className={cn(
|
||||
"flex-1 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>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={showRaycastApiKeyModal}
|
||||
onOpenChange={handleRaycastDialogClose}
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogContent
|
||||
id="raycast-api-key-modal"
|
||||
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 id="raycast-api-key-section" className="space-y-2">
|
||||
<label
|
||||
htmlFor={raycastApiKeyId}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Your Raycast API Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={raycastApiKeyId}
|
||||
type="text"
|
||||
value={raycastApiKey}
|
||||
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(raycastApiKey, true)}
|
||||
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
|
||||
>
|
||||
{raycastCopied ? (
|
||||
<Check className="h-4 w-4 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="raycast-steps" className="space-y-3">
|
||||
<h4
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Follow these steps:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div 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">
|
||||
1
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Install the Raycast extension from the Raycast Store
|
||||
</p>
|
||||
</div>
|
||||
<div 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">
|
||||
2
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Open Raycast preferences and paste your API key
|
||||
</p>
|
||||
</div>
|
||||
<div 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">
|
||||
3
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Use "Add Memory" or "Search Memories" commands!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRaycastInstall}
|
||||
className={cn(
|
||||
"flex-1 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>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
217
apps/web/components/new/settings/support.tsx
Normal file
217
apps/web/components/new/settings/support.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"use client"
|
||||
|
||||
import { dmSans125ClassName } from "@/utils/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { ArrowUpRight } from "lucide-react"
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
question: "How do I upgrade to Pro?",
|
||||
answer:
|
||||
'Go to the Billing tab in settings and click "Upgrade to Pro". You\'ll be redirected to our secure payment processor.',
|
||||
},
|
||||
{
|
||||
question: "What's included in the Pro plan?",
|
||||
answer:
|
||||
'Go to the Billing tab in settings and click "Upgrade to Pro". You\'ll be redirected to our secure payment processor.',
|
||||
},
|
||||
{
|
||||
question: "How do connections work?",
|
||||
answer:
|
||||
"Connections let you sync documents from Google Drive, Notion, and OneDrive automatically. supermemory will index and make them searchable.",
|
||||
},
|
||||
{
|
||||
question: "Can I cancel my subscription anytime?",
|
||||
answer:
|
||||
"Yes. You can cancel anytime from the Billing tab. Your Pro features will remain active until the end of your billing period.",
|
||||
},
|
||||
]
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function SupportCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
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)]",
|
||||
dmSans125ClassName(),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Support() {
|
||||
const handleMessageOnX = () => {
|
||||
window.open("https://x.com/supermemory", "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
const handleSendEmail = () => {
|
||||
window.location.href = "mailto:support@supermemory.com"
|
||||
}
|
||||
|
||||
const handleJoinDiscord = () => {
|
||||
window.open(
|
||||
"https://supermemory.link/discord",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
|
||||
const handleShareFeedback = () => {
|
||||
window.open("https://x.com/supermemory", "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 pt-4 w-full">
|
||||
{/* Support & Help Section */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionTitle>Support & Help</SectionTitle>
|
||||
<SupportCard>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-normal text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Get help
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Need assistance? We're here to help! Choose the best way to
|
||||
reach us.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<PillButton onClick={handleMessageOnX}>
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Message us on X
|
||||
</span>
|
||||
<ArrowUpRight className="size-4 text-[#FAFAFA]" />
|
||||
</PillButton>
|
||||
<PillButton onClick={handleJoinDiscord}>
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Join our Discord
|
||||
</span>
|
||||
<ArrowUpRight className="size-4 text-[#FAFAFA]" />
|
||||
</PillButton>
|
||||
<PillButton onClick={handleSendEmail}>
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Send us an email
|
||||
</span>
|
||||
<ArrowUpRight className="size-4 text-[#FAFAFA]" />
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</SupportCard>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionTitle>Frequently Asked Questions</SectionTitle>
|
||||
<SupportCard>
|
||||
<div className="flex flex-col gap-6">
|
||||
{FAQS.map((faq, index) => (
|
||||
<div key={faq.question}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{faq.question}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{faq.answer}
|
||||
</p>
|
||||
</div>
|
||||
{index < FAQS.length - 1 && (
|
||||
<div className="bg-[#1F2125] h-px w-full mt-6" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SupportCard>
|
||||
</section>
|
||||
|
||||
{/* Feedback Section */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionTitle>Feedback & Feature Requests</SectionTitle>
|
||||
<SupportCard>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-normal text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Have ideas for new features or improvements? We'd love to hear
|
||||
from you!
|
||||
</p>
|
||||
<PillButton
|
||||
onClick={handleShareFeedback}
|
||||
className="w-full flex-none"
|
||||
>
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Share your feedback on X/Twitter
|
||||
</span>
|
||||
<ArrowUpRight className="size-4 text-[#FAFAFA]" />
|
||||
</PillButton>
|
||||
</div>
|
||||
</SupportCard>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -39,17 +39,9 @@ import { useState } from "react"
|
|||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
import { useProjectName } from "@/hooks/use-project-name"
|
||||
import { useProject } from "@/stores"
|
||||
import type { Project } from "@repo/lib/types"
|
||||
import { CreateProjectDialog } from "./create-project-dialog"
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
containerTag: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isExperimental?: boolean
|
||||
}
|
||||
|
||||
export function ProjectSelector() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
|
|
|
|||
|
|
@ -48,18 +48,10 @@ import { toast } from "sonner"
|
|||
import type { z } from "zod"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useProject } from "@/stores"
|
||||
import type { Project } from "@repo/lib/types"
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
containerTag: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isExperimental?: boolean
|
||||
}
|
||||
|
||||
const CONNECTORS = {
|
||||
"google-drive": {
|
||||
title: "Google Drive",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { useState } from "react"
|
|||
import { toast } from "sonner"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { $fetch } from "@repo/lib/api"
|
||||
import type { Project } from "@repo/lib/types"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
const clients = {
|
||||
|
|
@ -33,15 +34,6 @@ const clients = {
|
|||
"claude-code": "Claude Code",
|
||||
} as const
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
containerTag: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isExperimental?: boolean
|
||||
}
|
||||
|
||||
export function InstallationDialogContent() {
|
||||
const [client, setClient] = useState<keyof typeof clients>("cursor")
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>("none")
|
||||
|
|
|
|||
12
packages/lib/types.ts
Normal file
12
packages/lib/types.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Common TypeScript types shared across the application
|
||||
*/
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
containerTag: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isExperimental?: boolean
|
||||
}
|
||||
|
|
@ -808,6 +808,7 @@ export const ConnectionResponseSchema = z.object({
|
|||
id: z.string(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
provider: z.string(),
|
||||
containerTags: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const RequestTypeSchema = RequestTypeEnum
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue