added settings page

This commit is contained in:
Mahesh Sanikommmu 2026-01-12 17:01:56 -08:00
parent d0d7d28a8d
commit 0e6f158c1b
20 changed files with 3732 additions and 193 deletions

View 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>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
}
}

View file

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

View file

@ -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}

View file

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

View 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 &amp; 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 &amp; 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 &amp; 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>
)
}

View file

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

View file

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

View file

@ -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
View 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
}

View file

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