diff --git a/apps/web/app/(app)/integrations/[card]/page.tsx b/apps/web/app/(app)/integrations/[card]/page.tsx new file mode 100644 index 00000000..b8d28cf1 --- /dev/null +++ b/apps/web/app/(app)/integrations/[card]/page.tsx @@ -0,0 +1,13 @@ +import { notFound } from "next/navigation" +import { AppExperience } from "@/components/app-experience" +import { isIntegrationCard } from "@/lib/integration-routes" + +export default async function IntegrationCardPage({ + params, +}: { + params: Promise<{ card: string }> +}) { + const { card } = await params + if (!isIntegrationCard(card)) notFound() + return +} diff --git a/apps/web/app/(app)/integrations/page.tsx b/apps/web/app/(app)/integrations/page.tsx new file mode 100644 index 00000000..350ef97a --- /dev/null +++ b/apps/web/app/(app)/integrations/page.tsx @@ -0,0 +1,5 @@ +import { AppExperience } from "@/components/app-experience" + +export default function IntegrationsPage() { + return +} diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 6e3a3603..240d355e 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -1,878 +1,5 @@ -"use client" +import { AppExperience } from "@/components/app-experience" -import { - useState, - useCallback, - useEffect, - useMemo, - useRef, - useSyncExternalStore, -} from "react" -import { AnimatePresence, motion } from "motion/react" -import { useQueryState } from "nuqs" -import { Header, PublicHeader } from "@/components/header" -import { MobileBottomNav } from "@/components/bottom-nav" -import { ChatSidebar, HomeChatComposer } from "@/components/chat" -import type { ChatAttachmentDraft } from "@/components/chat/attachments" -import { DashboardView } from "@/components/dashboard-view" -import { BrainHomeView } from "@/components/brain-home/brain-home-view" -import { useHasCompanyBrain } from "@/hooks/use-company-brain" -import { MemoriesGrid } from "@/components/memories-grid" -import { GraphLayoutView } from "@/components/graph-layout-view" -import { IntegrationsView, DetailWrapper } from "@/components/integrations-view" -import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view" -import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view" -import { ChromeDetail } from "@/components/integrations/chrome-detail" -import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail" -import { RaycastDetail } from "@/components/integrations/raycast-detail" -import { PluginsDetail } from "@/components/integrations/plugins-detail" -import { AnimatedGradientBackground } from "@/components/animated-gradient-background" -import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti" -import { AddDocumentModal } from "@/components/add-document" -import { DocumentModal } from "@/components/document-modal" -import { DocumentsCommandPalette } from "@/components/documents-command-palette" -import { FullscreenNoteModal } from "@/components/fullscreen-note-modal" -import type { HighlightItem } from "@/components/highlights-card" -import { DigestsView } from "@/components/digests-view" -import { HotkeysProvider } from "react-hotkeys-hook" -import { useHotkeys } from "react-hotkeys-hook" -import { useIsMobile } from "@hooks/use-mobile" -import { useAuth } from "@lib/auth-context" -import { useProject } from "@/stores" -import { useContainerTags } from "@/hooks/use-container-tags" -import { DEFAULT_PROJECT_ID } from "@lib/constants" -import { - useQuickNoteDraftReset, - useQuickNoteDraft, -} from "@/stores/quick-note-draft" -import { analytics } from "@/lib/analytics" -import type { ModelId, ReasoningEffort } from "@/lib/models" -import { useDocumentMutations } from "@/hooks/use-document-mutations" -import { useQuery, useQueryClient } from "@tanstack/react-query" -import { toast } from "sonner" -import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" -import type { z } from "zod" -import { useViewMode } from "@/lib/view-mode-context" -import type { MemoryOfDay } from "@/components/dashboard-view" -import { ErrorBoundary } from "@/components/error-boundary" -import { cn } from "@lib/utils" -import { - addDocumentParam, - searchParam, - qParam, - docParam, - fullscreenParam, - threadParam, - type IntegrationParamValue, -} from "@/lib/search-params" -import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" -import { getToolDocumentSpace } from "@/lib/plugin-space" - -type DocumentsResponse = z.infer -type DocumentWithMemories = DocumentsResponse["documents"][0] - -function subscribeViewportWidth(cb: () => void) { - window.addEventListener("resize", cb) - return () => window.removeEventListener("resize", cb) -} - -function getViewportWidth() { - return window.innerWidth -} - -const GRADIENT_TOP_WIDTH_MAX = 1440 - -function gradientTopPositionForWidth(width: number) { - const minW = 320 - const pctWide = 15 - const pctNarrow = 55 - const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width)) - const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW) - const eased = t * t - return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%` -} - -function ViewErrorFallback() { - return ( -
-

- Something went wrong.{" "} - -

-
- ) -} - -export default function NewPage() { - const isMobile = useIsMobile() - const { user, session, isSessionPending, org } = useAuth() - - const { selectedProject, selectedProjects, setSelectedProject } = useProject() - const selectedProjectTag = selectedProjects[0] - const { allProjects } = useContainerTags() - const dashboardSpaceLabel = useMemo( - () => - getChatSpaceDisplayLabel({ - selectedProject, - allProjects, - }), - [selectedProject, allProjects], - ) - const emptyStateSpaceName = selectedProjectTag - ? selectedProjectTag === DEFAULT_PROJECT_ID - ? "My Space" - : (allProjects.find((p) => p.containerTag === selectedProjectTag)?.name ?? - selectedProjectTag) - : undefined - - const { viewMode, setViewMode } = useViewMode() - const isCompanyBrain = useHasCompanyBrain() - - // Slack OAuth redirects back here with ?slack=connected — toast then clean up. - useEffect(() => { - const sp = new URLSearchParams(window.location.search) - if (sp.get("slack") !== "connected") return - const team = sp.get("team") - toast.success( - team - ? `Supermemory added to ${team} on Slack` - : "Supermemory added to your Slack", - ) - sp.delete("slack") - sp.delete("team") - const qs = sp.toString() - window.history.replaceState( - null, - "", - window.location.pathname + (qs ? `?${qs}` : ""), - ) - }, []) - const queryClient = useQueryClient() - const [highlightsForceAt, setHighlightsForceAt] = useState(0) - - // Chrome extension auth: send session token via postMessage so the content script can store it - useEffect(() => { - const url = new URL(window.location.href) - if (!url.searchParams.get("extension-auth-success")) return - const sessionToken = session?.token - const userData = { email: user?.email, name: user?.name, userId: user?.id } - if (sessionToken && userData.email) { - window.postMessage( - { token: encodeURIComponent(sessionToken), userData }, - window.location.origin, - ) - url.searchParams.delete("extension-auth-success") - window.history.replaceState({}, "", url.toString()) - } - }, [user, session]) - - // URL-driven modal states - const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam) - const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam) - const [searchPrefill, setSearchPrefill] = useQueryState("q", qParam) - const [docId, setDocId] = useQueryState("doc", docParam) - const [isFullscreen, setIsFullscreen] = useQueryState( - "fullscreen", - fullscreenParam, - ) - const [, setThreadIdUrl] = useQueryState("thread", threadParam) - - // Ephemeral local state (not worth URL-encoding) - const [fullscreenInitialContent, setFullscreenInitialContent] = useState("") - const [queuedChatSeed, setQueuedChatSeed] = useState(null) - const [queuedChatModel, setQueuedChatModel] = useState(null) - const [queuedChatReasoningEffort, setQueuedChatReasoningEffort] = - useState(null) - const [queuedChatProject, setQueuedChatProject] = useState( - null, - ) - const [queuedChatAttachments, setQueuedChatAttachments] = useState< - ChatAttachmentDraft[] | null - >(null) - const [queuedHighlightContent, setQueuedHighlightContent] = useState< - string | null - >(null) - const [queuedMessageSource, setQueuedMessageSource] = useState< - "highlight" | "home" - >("highlight") - const [selectedDocument, setSelectedDocument] = - useState(null) - - // Clear document when docId is removed (e.g. back button) - useEffect(() => { - if (!docId) setSelectedDocument(null) - }, [docId]) - - useEffect(() => { - if (viewMode === "dashboard") void setThreadIdUrl(null) - }, [viewMode, setThreadIdUrl]) - - // Resolve document from cache when loading with ?doc= (deep link / refresh) - useEffect(() => { - if (!docId || selectedDocument) return - - const tryResolve = () => { - const queries = queryClient.getQueriesData<{ - pages: DocumentsResponse[] - }>({ queryKey: ["documents-with-memories"] }) - for (const [, data] of queries) { - if (!data?.pages) continue - for (const page of data.pages) { - const doc = page.documents?.find((d) => d.id === docId) - if (doc) { - setSelectedDocument(doc) - return true - } - } - } - return false - } - - if (tryResolve()) return - - const unsubscribe = queryClient.getQueryCache().subscribe(() => { - if (tryResolve()) unsubscribe() - }) - return unsubscribe - }, [docId, selectedDocument, queryClient]) - - const resetDraft = useQuickNoteDraftReset(selectedProject) - const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "") - const quickNoteDraftRef = useRef(quickNoteDraft) - quickNoteDraftRef.current = quickNoteDraft - - const { noteMutation, bulkDeleteMutation } = useDocumentMutations({ - onClose: () => { - resetDraft() - setIsFullscreen(false) - }, - }) - - const [selectedDocumentIds, setSelectedDocumentIds] = useState>( - new Set(), - ) - const [isSelectionMode, setIsSelectionMode] = useState(false) - - const handleToggleSelection = useCallback((documentId: string) => { - setSelectedDocumentIds((prev) => { - const next = new Set(prev) - if (next.has(documentId)) { - next.delete(documentId) - } else { - next.add(documentId) - } - return next - }) - }, []) - - const handleClearSelection = useCallback(() => { - setSelectedDocumentIds(new Set()) - setIsSelectionMode(false) - }, []) - - const handleEnterSelectionMode = useCallback(() => { - setIsSelectionMode(true) - }, []) - - const handleSelectAllVisible = useCallback((visibleIds: string[]) => { - setSelectedDocumentIds((prev) => { - const next = new Set(prev) - for (const id of visibleIds) { - next.add(id) - } - return next - }) - }, []) - - const handleBulkDelete = useCallback(() => { - const ids = Array.from(selectedDocumentIds) - if (ids.length === 0) return - bulkDeleteMutation.mutate( - { documentIds: ids }, - { - onSuccess: () => { - setSelectedDocumentIds(new Set()) - setIsSelectionMode(false) - if (selectedDocument && ids.includes(selectedDocument.id ?? "")) { - setDocId(null) - } - }, - }, - ) - }, [selectedDocumentIds, bulkDeleteMutation, selectedDocument, setDocId]) - - type SpaceHighlightsResponse = { - highlights: HighlightItem[] - questions: string[] - generatedAt: string - } - - const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1" - const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours - - const handleResetHighlights = useCallback(async () => { - toast.success("Refreshing daily brief…") - try { - await caches.delete(HIGHLIGHTS_CACHE_NAME) - } catch {} - setHighlightsForceAt(Date.now()) - }, []) - - const { data: highlightsData, isLoading: isLoadingHighlights } = - useQuery({ - queryKey: ["space-highlights", selectedProject, highlightsForceAt], - queryFn: async (): Promise => { - const spaceId = selectedProject || "sm_project_default" - const forceRefresh = highlightsForceAt > 0 - const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}` - - if (!forceRefresh) { - const cache = await caches.open(HIGHLIGHTS_CACHE_NAME) - const cached = await cache.match(cacheKey) - if (cached) { - const age = - Date.now() - Number(cached.headers.get("x-cached-at") || 0) - if (age < HIGHLIGHTS_MAX_AGE) { - return cached.json() - } - } - } - - const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - spaceId, - highlightsCount: 3, - questionsCount: 4, - includeHighlights: true, - includeQuestions: true, - forceRefresh, - }), - }, - ) - - if (!response.ok) { - throw new Error("Failed to fetch space highlights") - } - - const data = await response.json() - - // Update browser cache with fresh data (works for both normal and forced refresh) - try { - const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME) - const cacheResponse = new Response(JSON.stringify(data), { - headers: { - "Content-Type": "application/json", - "x-cached-at": String(Date.now()), - }, - }) - await freshCache.put(cacheKey, cacheResponse) - } catch {} - - // Reset force flag after the forced fetch completes so future project-switches - // use the normal cache path instead of always bypassing it. - if (forceRefresh) setHighlightsForceAt(0) - - return data - }, - staleTime: HIGHLIGHTS_MAX_AGE, - refetchOnWindowFocus: false, - }) - - const { data: memoryOfDay = null } = useQuery({ - queryKey: [ - "memory-of-day", - user?.id, - org?.id, - new Date().toISOString().slice(0, 10), - ], - queryFn: async (): Promise => { - const cacheKey = `memory-of-day:v2:${user?.id}:${org?.id}:${new Date().toISOString().slice(0, 10)}` - try { - const stored = localStorage.getItem(cacheKey) - if (stored) return JSON.parse(stored) as MemoryOfDay - } catch {} - - const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`, - { credentials: "include" }, - ) - if (!response.ok) return null - const data = (await response.json()) as MemoryOfDay | null - if (data) { - try { - localStorage.setItem(cacheKey, JSON.stringify(data)) - } catch {} - } - return data - }, - staleTime: 24 * 60 * 60 * 1000, - refetchOnWindowFocus: false, - enabled: !!user && !!org, - }) - - useHotkeys("c", () => { - analytics.addDocumentModalOpened() - setAddDoc("note") - }) - useHotkeys("mod+k", (e) => { - e.preventDefault() - analytics.searchOpened({ source: "hotkey" }) - setIsSearchOpen(true) - }) - - const handleOpenDocument = useCallback( - (document: DocumentWithMemories) => { - if (document.id) { - analytics.documentModalOpened({ document_id: document.id }) - setSelectedDocument(document) - setDocId(document.id) - } - }, - [setDocId], - ) - - const handleOpenToolDocument = useCallback( - (document: DocumentWithMemories, pluginClientId: string) => { - const documentSpace = getToolDocumentSpace(document, pluginClientId) - if (documentSpace) { - setSelectedProject(documentSpace) - } - handleOpenDocument(document) - void setViewMode("list") - }, - [handleOpenDocument, setSelectedProject, setViewMode], - ) - - // Separate from handleOpenDocument because the graph view only has a document ID, - // not the full document object. The modal will fetch the document via the docId - // query param, so there may be a brief loading state (unlike handleOpenDocument - // which pre-populates via setSelectedDocument). - const handleOpenDocumentById = useCallback( - (documentId: string) => { - analytics.documentModalOpened({ document_id: documentId }) - setDocId(documentId) - }, - [setDocId], - ) - - const handleQuickNoteSave = useCallback( - (content: string) => { - if (content.trim()) { - const hadPreviousContent = quickNoteDraftRef.current.trim().length > 0 - noteMutation.mutate( - { content, project: selectedProject }, - { - onSuccess: () => { - if (hadPreviousContent) { - analytics.quickNoteEdited() - } else { - analytics.quickNoteCreated() - } - }, - }, - ) - } - }, - [selectedProject, noteMutation], - ) - - const handleFullScreenSave = useCallback( - (content: string) => { - if (content.trim()) { - const hadInitialContent = fullscreenInitialContent.trim().length > 0 - noteMutation.mutate( - { content, project: selectedProject }, - { - onSuccess: () => { - if (hadInitialContent) { - analytics.quickNoteEdited() - } else { - analytics.quickNoteCreated() - } - }, - }, - ) - } - }, - [selectedProject, noteMutation, fullscreenInitialContent], - ) - - const handleMaximize = useCallback( - (content: string) => { - analytics.fullscreenNoteModalOpened() - setFullscreenInitialContent(content) - setIsFullscreen(true) - }, - [setIsFullscreen], - ) - - const handleHighlightsChat = useCallback( - (highlightContent: string, userReply: string) => { - setQueuedHighlightContent(highlightContent) - setQueuedChatSeed(userReply) - setQueuedChatModel(null) - setQueuedChatReasoningEffort(null) - setQueuedChatProject(null) - setQueuedChatAttachments(null) - setQueuedMessageSource("highlight") - void setViewMode("chat") - }, - [setViewMode], - ) - - const handleHomeChatStart = useCallback( - ( - message: string, - model: ModelId, - projectId: string, - reasoningEffort: ReasoningEffort, - attachments?: ChatAttachmentDraft[], - ) => { - setQueuedHighlightContent(null) - setQueuedChatSeed(message) - setQueuedChatModel(model) - setQueuedChatReasoningEffort(reasoningEffort) - setQueuedChatProject(projectId) - setQueuedChatAttachments(attachments ?? null) - setQueuedMessageSource("home") - void setViewMode("chat") - }, - [setViewMode], - ) - - const consumeQueuedChat = useCallback(() => { - setQueuedChatSeed(null) - setQueuedChatModel(null) - setQueuedChatReasoningEffort(null) - setQueuedChatProject(null) - setQueuedChatAttachments(null) - setQueuedHighlightContent(null) - setQueuedMessageSource("highlight") - }, []) - - const handleHighlightsShowRelated = useCallback( - (query: string) => { - analytics.searchOpened({ source: "highlight_related" }) - setSearchPrefill(query) - setIsSearchOpen(true) - }, - [setSearchPrefill, setIsSearchOpen], - ) - - const handleOpenIntegrations = useCallback( - (integration?: IntegrationParamValue) => { - if (integration === "notion" || integration === "google-drive") { - void setAddDoc("connect") - return - } - void setViewMode(integration ?? "integrations") - }, - [setViewMode, setAddDoc], - ) - - const handleOpenPlugins = useCallback(() => { - void setViewMode("plugins") - }, [setViewMode]) - - const handleAddMemory = useCallback( - (tab: "note" | "link") => { - analytics.addDocumentModalOpened() - setAddDoc(tab) - }, - [setAddDoc], - ) - - const viewportWidth = useSyncExternalStore( - subscribeViewportWidth, - getViewportWidth, - () => GRADIENT_TOP_WIDTH_MAX, - ) - const gradientTopPosition = gradientTopPositionForWidth(viewportWidth) - - const isChatView = viewMode === "chat" - const showNovaBackdrop = - viewMode === "graph" || - viewMode === "list" || - viewMode === "dashboard" || - viewMode === "digests" - const isDashboardShell = - viewMode === "dashboard" || (viewMode === "graph" && isMobile) - const isGraphMode = viewMode === "graph" - const showBottomNav = isMobile && !!session && !isChatView - const isPublicIntegrations = - !session && !isSessionPending && viewMode === "integrations" - - return ( - - -
- {showNovaBackdrop && ( -
- -
-
-
- )} - {isPublicIntegrations ? ( - - ) : !session && viewMode === "mcp" ? ( - - ) : ( -
{ - analytics.addDocumentModalOpened() - setAddDoc("note") - }} - onOpenSearch={() => { - analytics.searchOpened({ source: "header" }) - setIsSearchOpen(true) - }} - /> - )} - - -
- }> - {isChatView ? ( -
- { - if (!open) void setViewMode("dashboard") - }} - queuedMessage={queuedChatSeed} - queuedHighlightContent={queuedHighlightContent} - onConsumeQueuedMessage={consumeQueuedChat} - queuedMessageSource={queuedMessageSource} - queuedAttachments={queuedChatAttachments} - initialSelectedModel={queuedChatModel} - initialReasoningEffort={queuedChatReasoningEffort} - initialChatProject={queuedChatProject} - /> -
- ) : viewMode === "integrations" ? ( -
- -
- ) : viewMode === "mcp" ? ( - void setViewMode("integrations")} - /> - ) : viewMode === "plugins" ? ( - void setViewMode("integrations")} - > - - - ) : viewMode === "chrome" ? ( - void setViewMode("integrations")} - > - - - ) : viewMode === "shortcuts" ? ( - void setViewMode("integrations")} - > - - - ) : viewMode === "raycast" ? ( - void setViewMode("integrations")} - > - - - ) : viewMode === "import" ? ( - void setViewMode("integrations")} - /> - ) : viewMode === "digests" ? ( -
- -
- ) : viewMode === "graph" ? ( -
- -
- ) : viewMode === "list" ? ( -
- -
- ) : isCompanyBrain ? ( -
- -
- ) : ( - { - analytics.searchOpened({ source: "header" }) - setIsSearchOpen(true) - }} - onOpenIntegrations={handleOpenIntegrations} - onOpenPlugins={handleOpenPlugins} - onNavigateToMemories={() => void setViewMode("list")} - onNavigateToGraph={() => void setViewMode("graph")} - onOpenDocument={handleOpenDocument} - onOpenToolDocument={handleOpenToolDocument} - onHighlightsChat={handleHighlightsChat} - onHighlightsShowRelated={handleHighlightsShowRelated} - onResetHighlights={handleResetHighlights} - onOpenDigests={() => void setViewMode("digests")} - memoryOfDay={memoryOfDay} - /> - )} -
-
-
-
- - {isDashboardShell && showBottomNav && ( -
- )} - {isDashboardShell && ( -
-
- -
-
- )} - - {showBottomNav && ( - { - analytics.addDocumentModalOpened() - setAddDoc("note") - }} - onOpenSearch={() => { - analytics.searchOpened({ source: "header" }) - setIsSearchOpen(true) - }} - /> - )} - - setAddDoc(null)} - /> - { - setIsSearchOpen(open) - if (!open) setSearchPrefill("") - }} - projectId={selectedProject} - onOpenDocument={handleOpenDocument} - onAddMemory={() => { - analytics.addDocumentModalOpened() - setAddDoc("note") - }} - onOpenIntegrations={() => setViewMode("integrations")} - initialSearch={searchPrefill} - /> - setDocId(null)} - /> - setIsFullscreen(false)} - initialContent={fullscreenInitialContent} - onSave={handleFullScreenSave} - isSaving={noteMutation.isPending} - /> -
- - ) +export default function Page() { + return } diff --git a/apps/web/app/(app)/settings/integrations/page.tsx b/apps/web/app/(app)/settings/integrations/page.tsx index aa8a456b..b128db96 100644 --- a/apps/web/app/(app)/settings/integrations/page.tsx +++ b/apps/web/app/(app)/settings/integrations/page.tsx @@ -1,17 +1,5 @@ -"use client" - -import { useEffect } from "react" -import { useRouter, useSearchParams } from "next/navigation" +import { redirect } from "next/navigation" export default function SettingsIntegrationsPage() { - const router = useRouter() - const searchParams = useSearchParams() - - useEffect(() => { - const params = new URLSearchParams(searchParams.toString()) - params.set("view", "integrations") - router.replace(`/?${params.toString()}`) - }, [router, searchParams]) - - return null + redirect("/integrations") } diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 32b74126..657a4f60 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -15,7 +15,7 @@ export default function SettingsRedirect() { const hash = typeof window !== "undefined" ? window.location.hash : "" const tab = parseHashToTab(hash) router.replace( - tab === "integrations" ? "/?view=integrations" : `/?settings=${tab}`, + tab === "integrations" ? "/integrations" : `/?settings=${tab}`, ) }, [router]) diff --git a/apps/web/components/app-experience.tsx b/apps/web/components/app-experience.tsx new file mode 100644 index 00000000..4f017ca4 --- /dev/null +++ b/apps/web/components/app-experience.tsx @@ -0,0 +1,879 @@ +"use client" + +import { + useState, + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from "react" +import { AnimatePresence, motion } from "motion/react" +import { useQueryState } from "nuqs" +import { Header, PublicHeader } from "@/components/header" +import { MobileBottomNav } from "@/components/bottom-nav" +import { ChatSidebar, HomeChatComposer } from "@/components/chat" +import type { ChatAttachmentDraft } from "@/components/chat/attachments" +import { DashboardView } from "@/components/dashboard-view" +import { BrainHomeView } from "@/components/brain-home/brain-home-view" +import { useHasCompanyBrain } from "@/hooks/use-company-brain" +import { MemoriesGrid } from "@/components/memories-grid" +import { GraphLayoutView } from "@/components/graph-layout-view" +import { IntegrationsView, DetailWrapper } from "@/components/integrations-view" +import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view" +import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view" +import { ChromeDetail } from "@/components/integrations/chrome-detail" +import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail" +import { RaycastDetail } from "@/components/integrations/raycast-detail" +import { PluginsDetail } from "@/components/integrations/plugins-detail" +import { AnimatedGradientBackground } from "@/components/animated-gradient-background" +import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti" +import { AddDocumentModal } from "@/components/add-document" +import { DocumentModal } from "@/components/document-modal" +import { DocumentsCommandPalette } from "@/components/documents-command-palette" +import { FullscreenNoteModal } from "@/components/fullscreen-note-modal" +import type { HighlightItem } from "@/components/highlights-card" +import { DigestsView } from "@/components/digests-view" +import { HotkeysProvider } from "react-hotkeys-hook" +import { useHotkeys } from "react-hotkeys-hook" +import { useIsMobile } from "@hooks/use-mobile" +import { useAuth } from "@lib/auth-context" +import { useProject } from "@/stores" +import { useContainerTags } from "@/hooks/use-container-tags" +import { DEFAULT_PROJECT_ID } from "@lib/constants" +import { + useQuickNoteDraftReset, + useQuickNoteDraft, +} from "@/stores/quick-note-draft" +import { analytics } from "@/lib/analytics" +import type { ModelId, ReasoningEffort } from "@/lib/models" +import { useDocumentMutations } from "@/hooks/use-document-mutations" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" +import type { z } from "zod" +import { useViewMode, useLegacyViewRedirect } from "@/lib/view-mode-context" +import type { MemoryOfDay } from "@/components/dashboard-view" +import { ErrorBoundary } from "@/components/error-boundary" +import { cn } from "@lib/utils" +import { + addDocumentParam, + searchParam, + qParam, + docParam, + fullscreenParam, + threadParam, + type IntegrationParamValue, +} from "@/lib/search-params" +import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" +import { getToolDocumentSpace } from "@/lib/plugin-space" + +type DocumentsResponse = z.infer +type DocumentWithMemories = DocumentsResponse["documents"][0] + +function subscribeViewportWidth(cb: () => void) { + window.addEventListener("resize", cb) + return () => window.removeEventListener("resize", cb) +} + +function getViewportWidth() { + return window.innerWidth +} + +const GRADIENT_TOP_WIDTH_MAX = 1440 + +function gradientTopPositionForWidth(width: number) { + const minW = 320 + const pctWide = 15 + const pctNarrow = 55 + const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width)) + const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW) + const eased = t * t + return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%` +} + +function ViewErrorFallback() { + return ( +
+

+ Something went wrong.{" "} + +

+
+ ) +} + +export function AppExperience() { + const isMobile = useIsMobile() + const { user, session, isSessionPending, org } = useAuth() + + const { selectedProject, selectedProjects, setSelectedProject } = useProject() + const selectedProjectTag = selectedProjects[0] + const { allProjects } = useContainerTags() + const dashboardSpaceLabel = useMemo( + () => + getChatSpaceDisplayLabel({ + selectedProject, + allProjects, + }), + [selectedProject, allProjects], + ) + const emptyStateSpaceName = selectedProjectTag + ? selectedProjectTag === DEFAULT_PROJECT_ID + ? "My Space" + : (allProjects.find((p) => p.containerTag === selectedProjectTag)?.name ?? + selectedProjectTag) + : undefined + + const { viewMode, setViewMode } = useViewMode() + useLegacyViewRedirect() + const isCompanyBrain = useHasCompanyBrain() + + // Slack OAuth redirects back here with ?slack=connected — toast then clean up. + useEffect(() => { + const sp = new URLSearchParams(window.location.search) + if (sp.get("slack") !== "connected") return + const team = sp.get("team") + toast.success( + team + ? `Supermemory added to ${team} on Slack` + : "Supermemory added to your Slack", + ) + sp.delete("slack") + sp.delete("team") + const qs = sp.toString() + window.history.replaceState( + null, + "", + window.location.pathname + (qs ? `?${qs}` : ""), + ) + }, []) + const queryClient = useQueryClient() + const [highlightsForceAt, setHighlightsForceAt] = useState(0) + + // Chrome extension auth: send session token via postMessage so the content script can store it + useEffect(() => { + const url = new URL(window.location.href) + if (!url.searchParams.get("extension-auth-success")) return + const sessionToken = session?.token + const userData = { email: user?.email, name: user?.name, userId: user?.id } + if (sessionToken && userData.email) { + window.postMessage( + { token: encodeURIComponent(sessionToken), userData }, + window.location.origin, + ) + url.searchParams.delete("extension-auth-success") + window.history.replaceState({}, "", url.toString()) + } + }, [user, session]) + + // URL-driven modal states + const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam) + const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam) + const [searchPrefill, setSearchPrefill] = useQueryState("q", qParam) + const [docId, setDocId] = useQueryState("doc", docParam) + const [isFullscreen, setIsFullscreen] = useQueryState( + "fullscreen", + fullscreenParam, + ) + const [, setThreadIdUrl] = useQueryState("thread", threadParam) + + // Ephemeral local state (not worth URL-encoding) + const [fullscreenInitialContent, setFullscreenInitialContent] = useState("") + const [queuedChatSeed, setQueuedChatSeed] = useState(null) + const [queuedChatModel, setQueuedChatModel] = useState(null) + const [queuedChatReasoningEffort, setQueuedChatReasoningEffort] = + useState(null) + const [queuedChatProject, setQueuedChatProject] = useState( + null, + ) + const [queuedChatAttachments, setQueuedChatAttachments] = useState< + ChatAttachmentDraft[] | null + >(null) + const [queuedHighlightContent, setQueuedHighlightContent] = useState< + string | null + >(null) + const [queuedMessageSource, setQueuedMessageSource] = useState< + "highlight" | "home" + >("highlight") + const [selectedDocument, setSelectedDocument] = + useState(null) + + // Clear document when docId is removed (e.g. back button) + useEffect(() => { + if (!docId) setSelectedDocument(null) + }, [docId]) + + useEffect(() => { + if (viewMode === "dashboard") void setThreadIdUrl(null) + }, [viewMode, setThreadIdUrl]) + + // Resolve document from cache when loading with ?doc= (deep link / refresh) + useEffect(() => { + if (!docId || selectedDocument) return + + const tryResolve = () => { + const queries = queryClient.getQueriesData<{ + pages: DocumentsResponse[] + }>({ queryKey: ["documents-with-memories"] }) + for (const [, data] of queries) { + if (!data?.pages) continue + for (const page of data.pages) { + const doc = page.documents?.find((d) => d.id === docId) + if (doc) { + setSelectedDocument(doc) + return true + } + } + } + return false + } + + if (tryResolve()) return + + const unsubscribe = queryClient.getQueryCache().subscribe(() => { + if (tryResolve()) unsubscribe() + }) + return unsubscribe + }, [docId, selectedDocument, queryClient]) + + const resetDraft = useQuickNoteDraftReset(selectedProject) + const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "") + const quickNoteDraftRef = useRef(quickNoteDraft) + quickNoteDraftRef.current = quickNoteDraft + + const { noteMutation, bulkDeleteMutation } = useDocumentMutations({ + onClose: () => { + resetDraft() + setIsFullscreen(false) + }, + }) + + const [selectedDocumentIds, setSelectedDocumentIds] = useState>( + new Set(), + ) + const [isSelectionMode, setIsSelectionMode] = useState(false) + + const handleToggleSelection = useCallback((documentId: string) => { + setSelectedDocumentIds((prev) => { + const next = new Set(prev) + if (next.has(documentId)) { + next.delete(documentId) + } else { + next.add(documentId) + } + return next + }) + }, []) + + const handleClearSelection = useCallback(() => { + setSelectedDocumentIds(new Set()) + setIsSelectionMode(false) + }, []) + + const handleEnterSelectionMode = useCallback(() => { + setIsSelectionMode(true) + }, []) + + const handleSelectAllVisible = useCallback((visibleIds: string[]) => { + setSelectedDocumentIds((prev) => { + const next = new Set(prev) + for (const id of visibleIds) { + next.add(id) + } + return next + }) + }, []) + + const handleBulkDelete = useCallback(() => { + const ids = Array.from(selectedDocumentIds) + if (ids.length === 0) return + bulkDeleteMutation.mutate( + { documentIds: ids }, + { + onSuccess: () => { + setSelectedDocumentIds(new Set()) + setIsSelectionMode(false) + if (selectedDocument && ids.includes(selectedDocument.id ?? "")) { + setDocId(null) + } + }, + }, + ) + }, [selectedDocumentIds, bulkDeleteMutation, selectedDocument, setDocId]) + + type SpaceHighlightsResponse = { + highlights: HighlightItem[] + questions: string[] + generatedAt: string + } + + const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1" + const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours + + const handleResetHighlights = useCallback(async () => { + toast.success("Refreshing daily brief…") + try { + await caches.delete(HIGHLIGHTS_CACHE_NAME) + } catch {} + setHighlightsForceAt(Date.now()) + }, []) + + const { data: highlightsData, isLoading: isLoadingHighlights } = + useQuery({ + queryKey: ["space-highlights", selectedProject, highlightsForceAt], + queryFn: async (): Promise => { + const spaceId = selectedProject || "sm_project_default" + const forceRefresh = highlightsForceAt > 0 + const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}` + + if (!forceRefresh) { + const cache = await caches.open(HIGHLIGHTS_CACHE_NAME) + const cached = await cache.match(cacheKey) + if (cached) { + const age = + Date.now() - Number(cached.headers.get("x-cached-at") || 0) + if (age < HIGHLIGHTS_MAX_AGE) { + return cached.json() + } + } + } + + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + spaceId, + highlightsCount: 3, + questionsCount: 4, + includeHighlights: true, + includeQuestions: true, + forceRefresh, + }), + }, + ) + + if (!response.ok) { + throw new Error("Failed to fetch space highlights") + } + + const data = await response.json() + + // Update browser cache with fresh data (works for both normal and forced refresh) + try { + const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME) + const cacheResponse = new Response(JSON.stringify(data), { + headers: { + "Content-Type": "application/json", + "x-cached-at": String(Date.now()), + }, + }) + await freshCache.put(cacheKey, cacheResponse) + } catch {} + + // Reset force flag after the forced fetch completes so future project-switches + // use the normal cache path instead of always bypassing it. + if (forceRefresh) setHighlightsForceAt(0) + + return data + }, + staleTime: HIGHLIGHTS_MAX_AGE, + refetchOnWindowFocus: false, + }) + + const { data: memoryOfDay = null } = useQuery({ + queryKey: [ + "memory-of-day", + user?.id, + org?.id, + new Date().toISOString().slice(0, 10), + ], + queryFn: async (): Promise => { + const cacheKey = `memory-of-day:v2:${user?.id}:${org?.id}:${new Date().toISOString().slice(0, 10)}` + try { + const stored = localStorage.getItem(cacheKey) + if (stored) return JSON.parse(stored) as MemoryOfDay + } catch {} + + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`, + { credentials: "include" }, + ) + if (!response.ok) return null + const data = (await response.json()) as MemoryOfDay | null + if (data) { + try { + localStorage.setItem(cacheKey, JSON.stringify(data)) + } catch {} + } + return data + }, + staleTime: 24 * 60 * 60 * 1000, + refetchOnWindowFocus: false, + enabled: !!user && !!org, + }) + + useHotkeys("c", () => { + analytics.addDocumentModalOpened() + setAddDoc("note") + }) + useHotkeys("mod+k", (e) => { + e.preventDefault() + analytics.searchOpened({ source: "hotkey" }) + setIsSearchOpen(true) + }) + + const handleOpenDocument = useCallback( + (document: DocumentWithMemories) => { + if (document.id) { + analytics.documentModalOpened({ document_id: document.id }) + setSelectedDocument(document) + setDocId(document.id) + } + }, + [setDocId], + ) + + const handleOpenToolDocument = useCallback( + (document: DocumentWithMemories, pluginClientId: string) => { + const documentSpace = getToolDocumentSpace(document, pluginClientId) + if (documentSpace) { + setSelectedProject(documentSpace) + } + handleOpenDocument(document) + void setViewMode("list") + }, + [handleOpenDocument, setSelectedProject, setViewMode], + ) + + // Separate from handleOpenDocument because the graph view only has a document ID, + // not the full document object. The modal will fetch the document via the docId + // query param, so there may be a brief loading state (unlike handleOpenDocument + // which pre-populates via setSelectedDocument). + const handleOpenDocumentById = useCallback( + (documentId: string) => { + analytics.documentModalOpened({ document_id: documentId }) + setDocId(documentId) + }, + [setDocId], + ) + + const handleQuickNoteSave = useCallback( + (content: string) => { + if (content.trim()) { + const hadPreviousContent = quickNoteDraftRef.current.trim().length > 0 + noteMutation.mutate( + { content, project: selectedProject }, + { + onSuccess: () => { + if (hadPreviousContent) { + analytics.quickNoteEdited() + } else { + analytics.quickNoteCreated() + } + }, + }, + ) + } + }, + [selectedProject, noteMutation], + ) + + const handleFullScreenSave = useCallback( + (content: string) => { + if (content.trim()) { + const hadInitialContent = fullscreenInitialContent.trim().length > 0 + noteMutation.mutate( + { content, project: selectedProject }, + { + onSuccess: () => { + if (hadInitialContent) { + analytics.quickNoteEdited() + } else { + analytics.quickNoteCreated() + } + }, + }, + ) + } + }, + [selectedProject, noteMutation, fullscreenInitialContent], + ) + + const handleMaximize = useCallback( + (content: string) => { + analytics.fullscreenNoteModalOpened() + setFullscreenInitialContent(content) + setIsFullscreen(true) + }, + [setIsFullscreen], + ) + + const handleHighlightsChat = useCallback( + (highlightContent: string, userReply: string) => { + setQueuedHighlightContent(highlightContent) + setQueuedChatSeed(userReply) + setQueuedChatModel(null) + setQueuedChatReasoningEffort(null) + setQueuedChatProject(null) + setQueuedChatAttachments(null) + setQueuedMessageSource("highlight") + void setViewMode("chat") + }, + [setViewMode], + ) + + const handleHomeChatStart = useCallback( + ( + message: string, + model: ModelId, + projectId: string, + reasoningEffort: ReasoningEffort, + attachments?: ChatAttachmentDraft[], + ) => { + setQueuedHighlightContent(null) + setQueuedChatSeed(message) + setQueuedChatModel(model) + setQueuedChatReasoningEffort(reasoningEffort) + setQueuedChatProject(projectId) + setQueuedChatAttachments(attachments ?? null) + setQueuedMessageSource("home") + void setViewMode("chat") + }, + [setViewMode], + ) + + const consumeQueuedChat = useCallback(() => { + setQueuedChatSeed(null) + setQueuedChatModel(null) + setQueuedChatReasoningEffort(null) + setQueuedChatProject(null) + setQueuedChatAttachments(null) + setQueuedHighlightContent(null) + setQueuedMessageSource("highlight") + }, []) + + const handleHighlightsShowRelated = useCallback( + (query: string) => { + analytics.searchOpened({ source: "highlight_related" }) + setSearchPrefill(query) + setIsSearchOpen(true) + }, + [setSearchPrefill, setIsSearchOpen], + ) + + const handleOpenIntegrations = useCallback( + (integration?: IntegrationParamValue) => { + if (integration === "notion" || integration === "google-drive") { + void setAddDoc("connect") + return + } + void setViewMode(integration ?? "integrations") + }, + [setViewMode, setAddDoc], + ) + + const handleOpenPlugins = useCallback(() => { + void setViewMode("plugins") + }, [setViewMode]) + + const handleAddMemory = useCallback( + (tab: "note" | "link") => { + analytics.addDocumentModalOpened() + setAddDoc(tab) + }, + [setAddDoc], + ) + + const viewportWidth = useSyncExternalStore( + subscribeViewportWidth, + getViewportWidth, + () => GRADIENT_TOP_WIDTH_MAX, + ) + const gradientTopPosition = gradientTopPositionForWidth(viewportWidth) + + const isChatView = viewMode === "chat" + const showNovaBackdrop = + viewMode === "graph" || + viewMode === "list" || + viewMode === "dashboard" || + viewMode === "digests" + const isDashboardShell = + viewMode === "dashboard" || (viewMode === "graph" && isMobile) + const isGraphMode = viewMode === "graph" + const showBottomNav = isMobile && !!session && !isChatView + const isPublicIntegrations = + !session && !isSessionPending && viewMode === "integrations" + + return ( + + +
+ {showNovaBackdrop && ( +
+ +
+
+
+ )} + {isPublicIntegrations ? ( + + ) : !session && viewMode === "mcp" ? ( + + ) : ( +
{ + analytics.addDocumentModalOpened() + setAddDoc("note") + }} + onOpenSearch={() => { + analytics.searchOpened({ source: "header" }) + setIsSearchOpen(true) + }} + /> + )} + + +
+ }> + {isChatView ? ( +
+ { + if (!open) void setViewMode("dashboard") + }} + queuedMessage={queuedChatSeed} + queuedHighlightContent={queuedHighlightContent} + onConsumeQueuedMessage={consumeQueuedChat} + queuedMessageSource={queuedMessageSource} + queuedAttachments={queuedChatAttachments} + initialSelectedModel={queuedChatModel} + initialReasoningEffort={queuedChatReasoningEffort} + initialChatProject={queuedChatProject} + /> +
+ ) : viewMode === "integrations" ? ( +
+ +
+ ) : viewMode === "mcp" ? ( + void setViewMode("integrations")} + /> + ) : viewMode === "plugins" ? ( + void setViewMode("integrations")} + > + + + ) : viewMode === "chrome" ? ( + void setViewMode("integrations")} + > + + + ) : viewMode === "shortcuts" ? ( + void setViewMode("integrations")} + > + + + ) : viewMode === "raycast" ? ( + void setViewMode("integrations")} + > + + + ) : viewMode === "import" ? ( + void setViewMode("integrations")} + /> + ) : viewMode === "digests" ? ( +
+ +
+ ) : viewMode === "graph" ? ( +
+ +
+ ) : viewMode === "list" ? ( +
+ +
+ ) : isCompanyBrain ? ( +
+ +
+ ) : ( + { + analytics.searchOpened({ source: "header" }) + setIsSearchOpen(true) + }} + onOpenIntegrations={handleOpenIntegrations} + onOpenPlugins={handleOpenPlugins} + onNavigateToMemories={() => void setViewMode("list")} + onNavigateToGraph={() => void setViewMode("graph")} + onOpenDocument={handleOpenDocument} + onOpenToolDocument={handleOpenToolDocument} + onHighlightsChat={handleHighlightsChat} + onHighlightsShowRelated={handleHighlightsShowRelated} + onResetHighlights={handleResetHighlights} + onOpenDigests={() => void setViewMode("digests")} + memoryOfDay={memoryOfDay} + /> + )} +
+
+
+
+ + {isDashboardShell && showBottomNav && ( +
+ )} + {isDashboardShell && ( +
+
+ +
+
+ )} + + {showBottomNav && ( + { + analytics.addDocumentModalOpened() + setAddDoc("note") + }} + onOpenSearch={() => { + analytics.searchOpened({ source: "header" }) + setIsSearchOpen(true) + }} + /> + )} + + setAddDoc(null)} + /> + { + setIsSearchOpen(open) + if (!open) setSearchPrefill("") + }} + projectId={selectedProject} + onOpenDocument={handleOpenDocument} + onAddMemory={() => { + analytics.addDocumentModalOpened() + setAddDoc("note") + }} + onOpenIntegrations={() => setViewMode("integrations")} + initialSearch={searchPrefill} + /> + setDocId(null)} + /> + setIsFullscreen(false)} + initialContent={fullscreenInitialContent} + onSave={handleFullScreenSave} + isSaving={noteMutation.isPending} + /> +
+ + ) +} diff --git a/apps/web/components/ensure-workspace.tsx b/apps/web/components/ensure-workspace.tsx index 0d4a69bb..675edff4 100644 --- a/apps/web/components/ensure-workspace.tsx +++ b/apps/web/components/ensure-workspace.tsx @@ -23,8 +23,10 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) { const { session, organizations, isRestoring, isSessionPending } = useAuth() const isPublicAppPage = - pathname === "/" && - ["integrations", "mcp"].includes(searchParams.get("view") ?? "") + pathname === "/integrations" || + pathname === "/integrations/mcp" || + (pathname === "/" && + ["integrations", "mcp"].includes(searchParams.get("view") ?? "")) const isGuestPublicAppPage = isPublicAppPage && !session && !isSessionPending const isOnboarding = pathname.startsWith("/onboarding") diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index 8abe42ab..3d977209 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -514,7 +514,7 @@ export function PublicHeader({ return (
@@ -523,7 +523,7 @@ export function PublicHeader({

- +
) diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index 0bd044ae..2f28e791 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -613,7 +613,7 @@ export function PluginsDetail() { try { const result = await autumn.attach({ planId: "api_pro", - successUrl: `${window.location.origin}/?view=integrations`, + successUrl: `${window.location.origin}/integrations`, }) if (result?.paymentUrl) { window.open(result.paymentUrl, "_self") diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index 43bbcee2..ccd098d6 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -710,7 +710,7 @@ export default function ConnectionsMCP() {

router.push("/?view=integrations&cat=ai-clients")} + onClick={() => router.push("/integrations?cat=ai-clients")} > diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx index 578a88bd..a257c223 100644 --- a/apps/web/components/settings/settings-content.tsx +++ b/apps/web/components/settings/settings-content.tsx @@ -195,7 +195,7 @@ export function SettingsContent({ } const handleIntegrations = () => { - void router.push("/?view=integrations") + void router.push("/integrations") } const handleDeleteAccount = async () => { diff --git a/apps/web/lib/integration-routes.ts b/apps/web/lib/integration-routes.ts new file mode 100644 index 00000000..f525da55 --- /dev/null +++ b/apps/web/lib/integration-routes.ts @@ -0,0 +1,40 @@ +import type { ViewParamValue } from "@/lib/search-params" + +// Integration-family views that live under the real /integrations route. +export const INTEGRATION_VIEWS = [ + "integrations", + "mcp", + "plugins", + "chrome", + "connections", + "shortcuts", + "raycast", + "import", +] as const + +export type IntegrationView = (typeof INTEGRATION_VIEWS)[number] + +// Sub-view cards — each is a nested route segment under /integrations. +export const INTEGRATION_CARDS = INTEGRATION_VIEWS.filter( + (v) => v !== "integrations", +) as Exclude[] + +export function isIntegrationView(view: string): view is IntegrationView { + return (INTEGRATION_VIEWS as readonly string[]).includes(view) +} + +export function isIntegrationCard(slug: string): slug is IntegrationView { + return (INTEGRATION_CARDS as readonly string[]).includes(slug) +} + +export function integrationViewToPath(view: IntegrationView): string { + return view === "integrations" ? "/integrations" : `/integrations/${view}` +} + +export function pathToIntegrationView(pathname: string): ViewParamValue | null { + const trimmed = pathname.replace(/\/$/, "") + if (trimmed === "/integrations") return "integrations" + const slug = trimmed.match(/^\/integrations\/([^/]+)$/)?.[1] + if (slug && isIntegrationCard(slug)) return slug + return null +} diff --git a/apps/web/lib/view-mode-context.tsx b/apps/web/lib/view-mode-context.tsx index e797b47a..d26fc731 100644 --- a/apps/web/lib/view-mode-context.tsx +++ b/apps/web/lib/view-mode-context.tsx @@ -1,24 +1,76 @@ "use client" import { useQueryState } from "nuqs" +import { usePathname, useRouter, useSearchParams } from "next/navigation" import { viewParam, type ViewParamValue } from "@/lib/search-params" +import { + integrationViewToPath, + isIntegrationView, + pathToIntegrationView, +} from "@/lib/integration-routes" import { analytics } from "@/lib/analytics" -import { useCallback } from "react" +import { useCallback, useEffect } from "react" export type ViewMode = ViewParamValue -type SetViewMode = (value: ViewMode | null) => Promise +const TRACKED_VIEW_MODES = [ + "dashboard", + "graph", + "list", + "integrations", + "chat", + "digests", +] as const + +function isTrackedViewMode( + mode: ViewMode, +): mode is (typeof TRACKED_VIEW_MODES)[number] { + return (TRACKED_VIEW_MODES as readonly string[]).includes(mode) +} export function useViewMode() { - const [viewMode, _setViewMode] = useQueryState("view", viewParam) + const pathname = usePathname() + const router = useRouter() + const [paramView, setParamView] = useQueryState("view", viewParam) + + // On /integrations[/card] the path is the source of truth; elsewhere the ?view param is. + const pathView = pathToIntegrationView(pathname) + const viewMode: ViewMode = pathView ?? paramView const setViewMode = useCallback( (mode: ViewMode) => { - analytics.viewModeChanged(mode) - ;(_setViewMode as SetViewMode)(mode) + if (isTrackedViewMode(mode)) analytics.viewModeChanged(mode) + if (isIntegrationView(mode)) { + router.push(integrationViewToPath(mode)) + return + } + // Leaving (or already off) the integrations route for a non-integration view. + if (pathToIntegrationView(pathname)) { + router.push(mode === "dashboard" ? "/" : `/?view=${mode}`) + return + } + void setParamView(mode) }, - [_setViewMode], + [router, pathname, setParamView], ) return { viewMode, setViewMode, isInitialized: true } } + +// Forwards legacy /?view=integrations (and sub-views) to the canonical /integrations route, +// preserving any other query params. Call once near the app root. +export function useLegacyViewRedirect() { + const pathname = usePathname() + const router = useRouter() + const searchParams = useSearchParams() + + useEffect(() => { + if (pathname !== "/") return + const view = searchParams.get("view") + if (!view || !isIntegrationView(view)) return + const params = new URLSearchParams(searchParams.toString()) + params.delete("view") + const qs = params.toString() + router.replace(integrationViewToPath(view) + (qs ? `?${qs}` : "")) + }, [pathname, searchParams, router]) +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 2094e5aa..b0e7879f 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -36,6 +36,14 @@ export default async function proxy(request: Request) { return NextResponse.next() } + // Real integrations routes, public in guest mode (mirrors view=integrations / view=mcp). + if ( + url.pathname === "/integrations" || + url.pathname === "/integrations/mcp" + ) { + return NextResponse.next() + } + if (url.pathname.startsWith("/api/")) { if (!sessionCookie) { console.debug("[MIDDLEWARE] API route without session, returning 401")