diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index fac8cc402..00696decc 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -229,7 +229,7 @@ function buildSpotlightCatalog( pro: true, onOpen: () => { track("connections") - void router.push("/?view=connections") + void router.push("/?add=connect") }, }, ], diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 7cb434101..ff3c73bfc 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -21,7 +21,6 @@ import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail import { ChromeDetail } from "@/components/integrations/chrome-detail" import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail" import { RaycastDetail } from "@/components/integrations/raycast-detail" -import { ConnectionsDetail } from "@/components/integrations/connections-detail" import { PluginsDetail } from "@/components/integrations/plugins-detail" import { AnimatedGradientBackground } from "@/components/animated-gradient-background" import { AddDocumentModal } from "@/components/add-document" @@ -628,12 +627,6 @@ export default function NewPage() { > - ) : viewMode === "connections" ? ( - void setViewMode("integrations")} - > - - ) : viewMode === "import" ? ( void setViewMode("integrations")} diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index 8a003dc36..e9114276b 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -6,12 +6,22 @@ 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, ChevronDown, Loader, Trash2, Zap } from "lucide-react" +import { + Check, + ChevronDown, + Clock, + FolderOpen, + Loader, + Trash2, + Zap, +} from "lucide-react" import { useEffect, useState } from "react" import { toast } from "sonner" import type { z } from "zod" -import { dmSansClassName } from "@/lib/fonts" +import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" import { cn } from "@lib/utils" +import { DEFAULT_PROJECT_ID } from "@lib/constants" +import type { Project } from "@lib/types" import { Button } from "@ui/components/button" import { DropdownMenu, @@ -37,26 +47,178 @@ const CONNECTORS: Record< { title: string description: string + documentLabel: string icon: React.ComponentType<{ className?: string }> } > = { "google-drive": { title: "Google Drive", description: "Connect your Google docs, sheets and slides", + documentLabel: "documents", icon: GoogleDrive, }, notion: { title: "Notion", description: "Import your Notion pages and databases", + documentLabel: "pages", icon: Notion, }, onedrive: { title: "OneDrive", description: "Access your Microsoft Office documents", + documentLabel: "documents", icon: OneDrive, }, } as const +function formatRelativeTime(date: string | null | undefined): string { + if (!date) return "Never" + const d = new Date(date) + const diffMs = Date.now() - d.getTime() + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffDays = Math.floor(diffHours / 24) + if (diffHours < 1) return "Just now" + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays === 1) return "Yesterday" + if (diffDays < 7) return `${diffDays} days ago` + return d.toLocaleDateString() +} + +function ConnectionRow({ + connection, + onDelete, + isDeleting, + projects, +}: { + connection: Connection + onDelete: () => void + isDeleting: boolean + projects: Project[] +}) { + const config = CONNECTORS[connection.provider as ConnectorProvider] + if (!config) return null + + const Icon = config.icon + const isConnected = + !connection.expiresAt || new Date(connection.expiresAt) > new Date() + + const getProjectName = (tag: string): string => { + if (tag === DEFAULT_PROJECT_ID) return "Default" + return ( + projects.find((p) => p.containerTag === tag)?.name ?? + tag.replace(/^sm_project_/, "").replace(/_/g, " ") + ) + } + + const documentCount = (connection.metadata?.documentCount as number) ?? 0 + const containerTags = ( + connection as Connection & { containerTags?: string[] } + ).containerTags + const projectName = containerTags?.[0] + ? getProjectName(containerTags[0]) + : null + + return ( +
+
+
+ +
+
+ + {config.title} + +
+
+ + {isConnected ? "Connected" : "Disconnected"} + +
+
+ + {connection.email || "Unknown"} + +
+ +
+
+
+ {projectName && ( +
+ + + {projectName} + +
+ )} +
+ + + {formatRelativeTime(connection.createdAt)} + +
+
+
+ + {documentCount} + + + {config.documentLabel} + +
+
+
+
+ ) +} + interface ConnectContentProps { selectedProject: string } @@ -75,6 +237,9 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { connection: Connection | null }>({ open: false, connection: null }) + const projects = (queryClient.getQueryData(["projects"]) || + []) as Project[] + const handleUpgrade = async () => { setIsUpgrading(true) try { @@ -190,7 +355,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { onSuccess: (_data, variables) => { toast.success( variables.deleteDocuments - ? "Connection removal has started. supermemory will permanently delete all documents related to the connection in the next few minutes." + ? "Connection removal has started. Documents will be permanently deleted in the next few minutes." : "Connection removed. Your memories have been kept.", ) setRemoveDialog({ open: false, connection: null }) @@ -211,128 +376,28 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { }) } - const handleDisconnect = (connection: Connection) => { - setRemoveDialog({ open: true, connection }) - } - 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" - } + const isAnyConnecting = + connectingProvider !== null || addConnectionMutation.isPending return (
-
-

Supermemory Connections

- - PRO - -
- - {/* Connector section - conditional layout based on hasConnections */} - {hasConnections ? ( -
- {Object.entries(CONNECTORS).map(([provider, config]) => { - const Icon = config.icon - const isConnecting = - connectingProvider === provider || - (addConnectionMutation.isPending && - addConnectionMutation.variables?.provider === provider) - - if (provider === "google-drive") { - return ( -
- -
- - - - - - {( - Object.entries(GDRIVE_SCOPE_LABELS) as [ - GDriveSyncScope, - string, - ][] - ).map(([scope, label]) => ( - { - e.stopPropagation() - setGdriveSyncScope(scope) - }} - className="flex items-center justify-between" - > - {label} - {gdriveSyncScope === scope && ( - - )} - - ))} - - -
- ) - } - - return ( - - ) - })} + {/* Top header — only when empty; once connected, the Add CTA moves into the list header below */} + {!hasConnections && ( +
+

Add a connection

+ + PRO +
- ) : ( + )} + + {/* Provider rows — only on empty state. Each is a labelled, descriptive CTA. */} + {!hasConnections && (
{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 && @@ -346,33 +411,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
-
-

{config.title}

- {isConnected && ( - - {connection.metadata?.syncInProgress - ? "Syncing..." - : "Connected"} - - )} -
+

{config.title}

{config.description}

- {isConnected ? ( - - ) : provider === "google-drive" ? ( + {provider === "google-drive" ? (
+ + +
+
+ + Choose a service + +
+
+ { + setConnectingProvider("google-drive") + addConnectionMutation.mutate({ + provider: "google-drive", + syncScope: "scoped", + }) + }} + className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100" + > + +
+ + Google Drive + + + Pick specific files & folders + +
+
+ { + setConnectingProvider("google-drive") + addConnectionMutation.mutate({ + provider: "google-drive", + syncScope: "full", + }) + }} + className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100" + > + +
+ + Google Drive + + + Sync entire drive + +
+
+ { + setConnectingProvider("notion") + addConnectionMutation.mutate({ provider: "notion" }) + }} + className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100" + > + +
+ + Notion + + + Pages and databases + +
+
+ { + setConnectingProvider("onedrive") + addConnectionMutation.mutate({ provider: "onedrive" }) + }} + className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100" + > + +
+ + OneDrive + + + Office documents + +
+
-
- ) - })} +
+ +
+
+ {connections.map((connection) => ( + setRemoveDialog({ open: true, connection })} + isDeleting={deleteConnectionMutation.isPending} + /> + ))}
)} @@ -568,6 +708,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { )}
)} + { diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index be611c3fa..9652f5eab 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -389,10 +389,14 @@ function MemoryOfDayCard({ data }: { data: MemoryOfDay }) { if (!memory) return null + const href = data.sourceDocumentId + ? `/?view=list&doc=${encodeURIComponent(data.sourceDocumentId)}` + : "/?view=list" + return ( -
-
-
- {projectName && ( -
- - - {projectName} - -
- )} -
- - - {formatRelativeTime(connection.createdAt)} - -
-
-
- - {documentCount} - - - {config.documentLabel} - -
-
-
-
- ) -} - -export function ConnectionsDetail() { - const queryClient = useQueryClient() - const autumn = useCustomer() - const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false) - const [removeDialog, setRemoveDialog] = useState<{ - open: boolean - connection: Connection | null - }>({ open: false, connection: null }) - const [, setAddDoc] = useQueryState("add", addDocumentParam) - - const projects = (queryClient.getQueryData(["projects"]) || - []) as Project[] - - const hasProProduct = hasActivePlan(autumn.customer?.products, "api_pro") - - const connectionsFeature = autumn.customer?.features?.connections - const connectionsUsed = connectionsFeature?.usage ?? 0 - const connectionsLimit = connectionsFeature?.included_usage ?? 10 - const canAddConnection = connectionsUsed < connectionsLimit - - const { - data: connections = [], - isLoading: isLoadingConnections, - error: connectionsError, - } = useQuery({ - queryKey: ["connections"], - queryFn: async () => { - const response = await $fetch("@post/connections/list", { - body: { containerTags: [] }, - }) - if (response.error) - throw new Error(response.error?.message || "Failed to load connections") - return response.data as Connection[] - }, - staleTime: 30 * 1000, - refetchInterval: 60 * 1000, - enabled: hasProProduct, - }) - - useEffect(() => { - if (connectionsError) { - toast.error("Failed to load connections", { - description: - connectionsError instanceof Error - ? connectionsError.message - : "Unknown error", - }) - } - }, [connectionsError]) - - const deleteConnectionMutation = useMutation({ - mutationFn: async ({ - connectionId, - deleteDocuments, - }: { - connectionId: string - deleteDocuments: boolean - }) => { - await $fetch(`@delete/connections/${connectionId}`, { - query: { deleteDocuments }, - }) - return { deleteDocuments } - }, - onSuccess: (_data, variables) => { - analytics.connectionDeleted() - toast.success( - variables.deleteDocuments - ? "Connection removal has started. Documents will be permanently deleted in the next few minutes." - : "Connection removed. Your memories have been kept.", - ) - setRemoveDialog({ open: false, connection: null }) - queryClient.invalidateQueries({ queryKey: ["connections"] }) - }, - onError: (error) => { - toast.error("Failed to remove connection", { - description: error instanceof Error ? error.message : "Unknown error", - }) - }, - }) - - const handleUpgrade = async () => { - try { - await autumn.attach({ - productId: "api_pro", - successUrl: "https://app.supermemory.ai/?view=integrations", - }) - window.location.reload() - } catch (error) { - console.error(error) - } - } - - const isLoading = autumn.isLoading - - return ( -
- {!hasProProduct && !isLoading && ( - <> -
-
-
- -

- Connect Google Drive, Notion, and OneDrive to import your - knowledge -

-
- {[ - "Unlimited memories", - "10 connections", - "Advanced search", - "Priority support", - ].map((text) => ( -
- - - {text} - -
- ))} -
- -
-
- - )} - -
-
- - Connected to Supermemory - - - {connections.length}/{connectionsLimit} connections used - -
- - {isLoadingConnections ? ( -
-
-
- ) : connections.length > 0 ? ( - connections.map((connection) => ( - setRemoveDialog({ open: true, connection })} - isDeleting={deleteConnectionMutation.isPending} - disabled={!hasProProduct} - projects={projects} - /> - )) - ) : ( -
- -

- No connections yet -

-

- Connect a service below to import your knowledge -

-
- )} - - setIsAddDocumentOpen(false)} - /> - - { - if (!open) setRemoveDialog({ open: false, connection: null }) - }} - provider={removeDialog.connection?.provider} - documentCount={ - (removeDialog.connection?.metadata?.documentCount as number) ?? 0 - } - onConfirm={(deleteDocuments) => { - if (removeDialog.connection) { - deleteConnectionMutation.mutate({ - connectionId: removeDialog.connection.id, - deleteDocuments, - }) - } - }} - isDeleting={deleteConnectionMutation.isPending} - /> - - -
-
- ) -} diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx index 0c8a2be39..3127c148d 100644 --- a/apps/web/components/memories-grid.tsx +++ b/apps/web/components/memories-grid.tsx @@ -45,6 +45,7 @@ import { } from "@ui/components/alert-dialog" import { AlignLeft, + BoxSelect, CheckIcon, LayoutGrid, Loader, @@ -180,7 +181,13 @@ function MemoriesGridLoading() { // Discriminated union for masonry items type MasonryItem = - | { type: "document"; id: string; data: DocumentWithMemories } + | { + type: "document" + id: string + data: DocumentWithMemories + isSelectionMode: boolean + isSelected: boolean + } | { type: "quick-note"; id: "quick-note" } interface QuickNoteProps { @@ -369,11 +376,17 @@ export function MemoriesGrid({ } for (const doc of documents) { - items.push({ type: "document", id: doc.id, data: doc }) + items.push({ + type: "document", + id: doc.id, + data: doc, + isSelectionMode, + isSelected: doc.id ? selectedDocumentIds.has(doc.id) : false, + }) } return items - }, [documents, isMobile, hasQuickNote]) + }, [documents, isMobile, hasQuickNote, isSelectionMode, selectedDocumentIds]) // Stable key for Masonry based on document IDs, not item values const masonryKey = useMemo(() => { @@ -437,16 +450,12 @@ export function MemoriesGrid({ const renderRef = useRef({ quickNoteProps, handleCardClick, - isSelectionMode, - selectedDocumentIds, onToggleSelection, processingStatusMap, }) renderRef.current = { quickNoteProps, handleCardClick, - isSelectionMode, - selectedDocumentIds, onToggleSelection, processingStatusMap, } @@ -480,8 +489,8 @@ export function MemoriesGrid({ data={doc} width={width} onClick={r.handleCardClick} - isSelectionMode={r.isSelectionMode} - isSelected={doc.id ? r.selectedDocumentIds.has(doc.id) : false} + isSelectionMode={data.isSelectionMode} + isSelected={data.isSelected} onToggleSelection={ doc.id && r.onToggleSelection ? () => r.onToggleSelection?.(doc.id as string) @@ -518,12 +527,16 @@ export function MemoriesGrid({ const isEmpty = documents.length === 0 && !isPending const showNovaEmptyState = isEmpty && emptyStateProps + const allVisibleSelected = + documents.length > 0 && + documents.every((d) => d.id && selectedDocumentIds.has(d.id)) + return (
- {!isEmpty && ( + {!isEmpty && !isSelectionMode && (
- {isSelectionMode && ( - <> - - {selectedDocumentIds.size > 0 ? ( - <> - - - - ) : ( -

- Select one or more documents -

- )} - - )} - {!isSelectionMode && onEnterSelectionMode && ( + {onEnterSelectionMode && ( )}
)} + {!isEmpty && isSelectionMode && ( +
+
+ + + {selectedDocumentIds.size} + + {selectedDocumentIds.size === 1 ? "selected" : "selected"} + + {selectedDocumentIds.size === 0 && ( + + Tap documents to select + + )} +
+
+ + +
+ +
+
+ )} + ("keep") + const [alsoDelete, setAlsoDelete] = useState(false) const displayName = providerName || (provider ? PROVIDER_LABELS[provider] : "this connection") + const memoryNoun = documentCount === 1 ? "memory" : "memories" + const hasMemories = documentCount > 0 + const handleConfirm = () => { - onConfirm(action === "delete") + onConfirm(alsoDelete) } return ( @@ -56,13 +60,13 @@ export function RemoveConnectionDialog({ onOpenChange={(o) => { if (!isDeleting) { onOpenChange(o) - if (!o) setAction("keep") + if (!o) setAlsoDelete(false) } }} > -
-
-
- - Remove connection - - - What would you like to do with the{" "} - {documentCount > 0 ? ( - <> - - {documentCount} - {" "} - memories from{" "} - - ) : ( - <>memories from - )} - - {displayName} - - ? - -
+
+
+ + Disconnect {displayName}? +
-
- + + {hasMemories ? ( + <> + Sync stops. Your{" "} + + {documentCount} {memoryNoun} + {" "} + stay in Supermemory. + + ) : ( + <>Sync stops. No memories were imported from this connection. + )} + - -
+ setAlsoDelete(checked === true)} + disabled={isDeleting} + /> + + Also delete the {documentCount} imported {memoryNoun}{" "} + (optional) + + + )}
diff --git a/apps/web/stores/index.ts b/apps/web/stores/index.ts index 5379b06ee..4d754f773 100644 --- a/apps/web/stores/index.ts +++ b/apps/web/stores/index.ts @@ -13,7 +13,8 @@ export function useProject() { const selectedProject = selectedProjects[0] ?? DEFAULT_PROJECT_ID - const effectiveContainerTags = selectedProjects + const effectiveContainerTags = + selectedProjects.length === 0 ? [DEFAULT_PROJECT_ID] : selectedProjects const setSelectedProjects = useCallback( (projects: string[]) => {