diff options
149 files changed, 7859 insertions, 6779 deletions
diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index e2f72129..df052a3d 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,4 +1,4 @@ -import { LoginPage } from "@repo/ui/pages/login" +import { LoginPage } from "@repo/ui/pages/login"; export default function Page() { return ( @@ -8,5 +8,5 @@ export default function Page() { "Private, secure, and reliable.", ]} /> - ) + ); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 095763b3..e6d9094d 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,39 +1,39 @@ -import type { Metadata } from "next" -import { JetBrains_Mono, Inter } from "next/font/google" -import "../globals.css" -import "@ui/globals.css" -import { AuthProvider } from "@lib/auth-context" -import { ErrorTrackingProvider } from "@lib/error-tracking" -import { PostHogProvider } from "@lib/posthog" -import { QueryProvider } from "@lib/query-client" -import { AutumnProvider } from "autumn-js/react" -import { Suspense } from "react" -import { Toaster } from "sonner" -import { TourProvider } from "@/components/tour" -import { MobilePanelProvider } from "@/lib/mobile-panel-context" +import type { Metadata } from "next"; +import { Inter, JetBrains_Mono } from "next/font/google"; +import "../globals.css"; +import "@ui/globals.css"; +import { AuthProvider } from "@lib/auth-context"; +import { ErrorTrackingProvider } from "@lib/error-tracking"; +import { PostHogProvider } from "@lib/posthog"; +import { QueryProvider } from "@lib/query-client"; +import { AutumnProvider } from "autumn-js/react"; +import { Suspense } from "react"; +import { Toaster } from "sonner"; +import { TourProvider } from "@/components/tour"; +import { MobilePanelProvider } from "@/lib/mobile-panel-context"; -import { ViewModeProvider } from "@/lib/view-mode-context" +import { ViewModeProvider } from "@/lib/view-mode-context"; const sans = Inter({ subsets: ["latin"], variable: "--font-sans", -}) +}); const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono", -}) +}); export const metadata: Metadata = { metadataBase: new URL("https://app.supermemory.ai"), description: "Your memories, wherever you are", title: "supermemory app", -} +}; export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) { return ( <html className="dark bg-sm-black" lang="en"> @@ -65,5 +65,5 @@ export default function RootLayout({ </AutumnProvider> </body> </html> - ) + ); } diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts index 17611deb..9077d287 100644 --- a/apps/web/app/manifest.ts +++ b/apps/web/app/manifest.ts @@ -1,20 +1,20 @@ -import type { MetadataRoute } from 'next' - +import type { MetadataRoute } from "next"; + export default function manifest(): MetadataRoute.Manifest { - return { - name: 'Supermemory', - short_name: 'supermemory', - description: 'Your memories, wherever you are', - start_url: '/', - display: 'standalone', - background_color: '#ffffff', - theme_color: '#000000', - icons: [ - { - src: '/images/logo.png', - sizes: '192x192', - type: 'image/png', - } - ], - } -}
\ No newline at end of file + return { + name: "Supermemory", + short_name: "supermemory", + description: "Your memories, wherever you are", + start_url: "/", + display: "standalone", + background_color: "#ffffff", + theme_color: "#000000", + icons: [ + { + src: "/images/logo.png", + sizes: "192x192", + type: "image/png", + }, + ], + }; +} diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index 98cf6b58..d37d1e7c 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -1,20 +1,20 @@ -"use client" // Error boundaries must be Client Components +"use client"; // Error boundaries must be Client Components -import { Button } from "@ui/components/button" -import { Title1Bold } from "@ui/text/title/title-1-bold" -import { useRouter } from "next/navigation" -import { useEffect } from "react" +import { Button } from "@ui/components/button"; +import { Title1Bold } from "@ui/text/title/title-1-bold"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; export default function NotFound({ error, }: { - error: Error & { digest?: string } + error: Error & { digest?: string }; }) { - const router = useRouter() + const router = useRouter(); useEffect(() => { // Log the error to an error reporting service - console.error(error) - }, [error]) + console.error(error); + }, [error]); return ( <html lang="en"> @@ -23,5 +23,5 @@ export default function NotFound({ <Button onClick={() => router.back()}>Go back</Button> </body> </html> - ) + ); } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 05b32a15..335d79e3 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,4 +1,4 @@ -"use client" +"use client"; import { useIsMobile } from "@hooks/use-mobile"; import { useAuth } from "@lib/auth-context"; @@ -7,32 +7,38 @@ import { MemoryGraph } from "@repo/ui/memory-graph"; import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { Logo, LogoFull } from "@ui/assets/Logo"; -import { GlassMenuEffect } from "@ui/other/glass-effect"; import { Button } from "@ui/components/button"; -import { Gift, LayoutGrid, List, LoaderIcon, MessageSquare, Unplug } from "lucide-react"; +import { GlassMenuEffect } from "@ui/other/glass-effect"; +import { + Gift, + LayoutGrid, + List, + LoaderIcon, + MessageSquare, + Unplug, +} from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { z } from "zod"; +import { ConnectAIModal } from "@/components/connect-ai-modal"; +import { InstallPrompt } from "@/components/install-prompt"; import { MemoryListView } from "@/components/memory-list-view"; import Menu from "@/components/menu"; +import { ProjectSelector } from "@/components/project-selector"; +import { ReferralUpgradeModal } from "@/components/referral-upgrade-modal"; import type { TourStep } from "@/components/tour"; import { TourAlertDialog, useTour } from "@/components/tour"; -import { useProject } from "@/stores"; +import { AddMemoryView } from "@/components/views/add-memory"; +import { ChatRewrite } from "@/components/views/chat"; import { TOUR_STEP_IDS, TOUR_STORAGE_KEY } from "@/lib/tour-constants"; import { useViewMode } from "@/lib/view-mode-context"; -import { useChatOpen } from "@/stores"; -import { ChatRewrite } from "@/components/views/chat"; +import { useChatOpen, useProject } from "@/stores"; import { useGraphHighlights } from "@/stores/highlights"; -import { ProjectSelector } from "@/components/project-selector"; -import { AddMemoryView } from "@/components/views/add-memory"; -import { ReferralUpgradeModal } from "@/components/referral-upgrade-modal"; -import { ConnectAIModal } from "@/components/connect-ai-modal"; -import { InstallPrompt } from "@/components/install-prompt"; -type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema> -type DocumentWithMemories = DocumentsResponse["documents"][0] +type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>; +type DocumentWithMemories = DocumentsResponse["documents"][0]; const MemoryGraphPage = () => { const { documentIds: allHighlightDocumentIds } = useGraphHighlights(); @@ -41,26 +47,26 @@ const MemoryGraphPage = () => { const { selectedProject } = useProject(); const { setSteps, isTourCompleted } = useTour(); const { isOpen, setIsOpen } = useChatOpen(); - const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([]) - const [showAddMemoryView, setShowAddMemoryView] = useState(false) - const [showReferralModal, setShowReferralModal] = useState(false) + const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([]); + const [showAddMemoryView, setShowAddMemoryView] = useState(false); + const [showReferralModal, setShowReferralModal] = useState(false); // Fetch projects meta to detect experimental flag const { data: projectsMeta = [] } = useQuery({ queryKey: ["projects"], queryFn: async () => { - const response = await $fetch("@get/projects") - return response.data?.projects ?? [] + const response = await $fetch("@get/projects"); + return response.data?.projects ?? []; }, staleTime: 5 * 60 * 1000, - }) + }); const isCurrentProjectExperimental = !!projectsMeta.find( (p: any) => p.containerTag === selectedProject, - )?.isExperimental + )?.isExperimental; // Tour state - const [showTourDialog, setShowTourDialog] = useState(false) + const [showTourDialog, setShowTourDialog] = useState(false); // Define tour steps with useMemo to prevent recreation const tourSteps: TourStep[] = useMemo(() => { @@ -193,36 +199,36 @@ const MemoryGraphPage = () => { selectorId: TOUR_STEP_IDS.FLOATING_CHAT, position: "left", }, - ] - }, []) + ]; + }, []); // Check if tour has been completed before useEffect(() => { - const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true" + const hasCompletedTour = localStorage.getItem(TOUR_STORAGE_KEY) === "true"; if (!hasCompletedTour && !isTourCompleted) { const timer = setTimeout(() => { - setShowTourDialog(true) - }, 1000) // Show after 1 second - return () => clearTimeout(timer) + setShowTourDialog(true); + }, 1000); // Show after 1 second + return () => clearTimeout(timer); } - }, [isTourCompleted]) + }, [isTourCompleted]); // Set up tour steps useEffect(() => { - setSteps(tourSteps) - }, [setSteps, tourSteps]) + setSteps(tourSteps); + }, [setSteps, tourSteps]); // Save tour completion to localStorage useEffect(() => { if (isTourCompleted) { - localStorage.setItem(TOUR_STORAGE_KEY, "true") + localStorage.setItem(TOUR_STORAGE_KEY, "true"); } - }, [isTourCompleted]) + }, [isTourCompleted]); // Progressive loading via useInfiniteQuery - const IS_DEV = process.env.NODE_ENV === "development" - const PAGE_SIZE = IS_DEV ? 3 : 100 - const MAX_TOTAL = 1000 + const IS_DEV = process.env.NODE_ENV === "development"; + const PAGE_SIZE = IS_DEV ? 3 : 100; + const MAX_TOTAL = 1000; const { data, @@ -244,69 +250,76 @@ const MemoryGraphPage = () => { containerTags: selectedProject ? [selectedProject] : undefined, }, disableValidation: true, - }) + }); if (response.error) { - throw new Error(response.error?.message || "Failed to fetch documents") + throw new Error(response.error?.message || "Failed to fetch documents"); } - return response.data + return response.data; }, getNextPageParam: (lastPage, allPages) => { const loaded = allPages.reduce( (acc, p) => acc + (p.documents?.length ?? 0), 0, - ) - if (loaded >= MAX_TOTAL) return undefined + ); + if (loaded >= MAX_TOTAL) return undefined; - const { currentPage, totalPages } = lastPage.pagination + const { currentPage, totalPages } = lastPage.pagination; if (currentPage < totalPages) { - return currentPage + 1 + return currentPage + 1; } - return undefined + return undefined; }, staleTime: 5 * 60 * 1000, - }) + }); const baseDocuments = useMemo(() => { - return data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? [] - }, [data]) + return ( + data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? [] + ); + }, [data]); const allDocuments = useMemo(() => { - if (injectedDocs.length === 0) return baseDocuments - const byId = new Map<string, DocumentWithMemories>() - for (const d of injectedDocs) byId.set(d.id, d) - for (const d of baseDocuments) if (!byId.has(d.id)) byId.set(d.id, d) - return Array.from(byId.values()) - }, [baseDocuments, injectedDocs]) + if (injectedDocs.length === 0) return baseDocuments; + const byId = new Map<string, DocumentWithMemories>(); + for (const d of injectedDocs) byId.set(d.id, d); + for (const d of baseDocuments) if (!byId.has(d.id)) byId.set(d.id, d); + return Array.from(byId.values()); + }, [baseDocuments, injectedDocs]); - const totalLoaded = allDocuments.length - const hasMore = hasNextPage - const isLoadingMore = isFetchingNextPage + const totalLoaded = allDocuments.length; + const hasMore = hasNextPage; + const isLoadingMore = isFetchingNextPage; const loadMoreDocuments = useCallback(async (): Promise<void> => { if (hasNextPage && !isFetchingNextPage) { - await fetchNextPage() - return + await fetchNextPage(); + return; } - return - }, [hasNextPage, isFetchingNextPage, fetchNextPage]) + return; + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); // Reset injected docs when project changes - useEffect(() => { setInjectedDocs([]) }, [selectedProject]) + useEffect(() => { + setInjectedDocs([]); + }, [selectedProject]); // Surgical fetch of missing highlighted documents (customId-based IDs from search) useEffect(() => { - if (!isOpen) return - if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0) return - const present = new Set<string>() + if (!isOpen) return; + if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0) + return; + const present = new Set<string>(); for (const d of [...baseDocuments, ...injectedDocs]) { - if (d.id) present.add(d.id) - if ((d as any).customId) present.add((d as any).customId as string) + if (d.id) present.add(d.id); + if ((d as any).customId) present.add((d as any).customId as string); } - const missing = allHighlightDocumentIds.filter((id: string) => !present.has(id)) - if (missing.length === 0) return - let cancelled = false + const missing = allHighlightDocumentIds.filter( + (id: string) => !present.has(id), + ); + if (missing.length === 0) return; + let cancelled = false; const run = async () => { try { const resp = await $fetch("@post/memories/documents/by-ids", { @@ -316,49 +329,63 @@ const MemoryGraphPage = () => { containerTags: selectedProject ? [selectedProject] : undefined, }, disableValidation: true, - }) - if (cancelled || (resp as any)?.error) return - const extraDocs = (resp as any)?.data?.documents as DocumentWithMemories[] | undefined - if (!extraDocs || extraDocs.length === 0) return + }); + if (cancelled || (resp as any)?.error) return; + const extraDocs = (resp as any)?.data?.documents as + | DocumentWithMemories[] + | undefined; + if (!extraDocs || extraDocs.length === 0) return; setInjectedDocs((prev) => { - const seen = new Set<string>([...prev.map((d) => d.id), ...baseDocuments.map((d) => d.id)]) - const merged = [...prev] + const seen = new Set<string>([ + ...prev.map((d) => d.id), + ...baseDocuments.map((d) => d.id), + ]); + const merged = [...prev]; for (const doc of extraDocs) { if (!seen.has(doc.id)) { - merged.push(doc) - seen.add(doc.id) + merged.push(doc); + seen.add(doc.id); } } - return merged - }) - } catch { } - } - void run() - return () => { cancelled = true } - }, [isOpen, allHighlightDocumentIds.join('|'), baseDocuments, injectedDocs, selectedProject, $fetch]) + return merged; + }); + } catch {} + }; + void run(); + return () => { + cancelled = true; + }; + }, [ + isOpen, + allHighlightDocumentIds.join("|"), + baseDocuments, + injectedDocs, + selectedProject, + $fetch, + ]); // Handle view mode change const handleViewModeChange = useCallback( (mode: "graph" | "list") => { - setViewMode(mode) + setViewMode(mode); }, [setViewMode], - ) + ); // Prevent body scrolling useEffect(() => { - document.body.style.overflow = 'hidden' - document.body.style.height = '100vh' - document.documentElement.style.overflow = 'hidden' - document.documentElement.style.height = '100vh' - + document.body.style.overflow = "hidden"; + document.body.style.height = "100vh"; + document.documentElement.style.overflow = "hidden"; + document.documentElement.style.height = "100vh"; + return () => { - document.body.style.overflow = '' - document.body.style.height = '' - document.documentElement.style.overflow = '' - document.documentElement.style.height = '' - } - }, []) + document.body.style.overflow = ""; + document.body.style.height = ""; + document.documentElement.style.overflow = ""; + document.documentElement.style.height = ""; + }; + }, []); return ( <div className="relative h-screen bg-[#0f1419] overflow-hidden touch-none"> @@ -409,52 +436,52 @@ const MemoryGraphPage = () => { </span> </motion.button> - <motion.button - animate={{ - color: viewMode === "list" ? "#93c5fd" : "#cbd5e1", - }} - className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors" - onClick={() => handleViewModeChange("list")} - transition={{ duration: 0.2 }} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - {viewMode === "list" && ( - <motion.div - className="absolute inset-0 bg-blue-500/20 rounded-md" - layoutId="activeBackground" - transition={{ - type: "spring", - stiffness: 400, - damping: 30, - }} - /> - )} - <span className="relative z-10 flex items-center gap-2"> - <List className="w-4 h-4" /> - <span className="hidden md:inline">List</span> - </span> - </motion.button> - </div> - </motion.div> + <motion.button + animate={{ + color: viewMode === "list" ? "#93c5fd" : "#cbd5e1", + }} + className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors" + onClick={() => handleViewModeChange("list")} + transition={{ duration: 0.2 }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + {viewMode === "list" && ( + <motion.div + className="absolute inset-0 bg-blue-500/20 rounded-md" + layoutId="activeBackground" + transition={{ + type: "spring", + stiffness: 400, + damping: 30, + }} + /> + )} + <span className="relative z-10 flex items-center gap-2"> + <List className="w-4 h-4" /> + <span className="hidden md:inline">List</span> + </span> + </motion.button> + </div> + </motion.div> - {/* Animated content switching */} - <AnimatePresence mode="wait"> - {viewMode === "graph" ? ( - <motion.div - animate={{ opacity: 1, scale: 1 }} - className="absolute inset-0" - exit={{ opacity: 0, scale: 0.95 }} - id={TOUR_STEP_IDS.MEMORY_GRAPH} - initial={{ opacity: 0, scale: 0.95 }} - key="graph" - transition={{ - type: "spring", - stiffness: 500, - damping: 30, - }} - > - <MemoryGraph + {/* Animated content switching */} + <AnimatePresence mode="wait"> + {viewMode === "graph" ? ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="absolute inset-0" + exit={{ opacity: 0, scale: 0.95 }} + id={TOUR_STEP_IDS.MEMORY_GRAPH} + initial={{ opacity: 0, scale: 0.95 }} + key="graph" + transition={{ + type: "spring", + stiffness: 500, + damping: 30, + }} + > + <MemoryGraph documents={allDocuments} error={error} hasMore={hasMore} @@ -470,99 +497,108 @@ const MemoryGraphPage = () => { occludedRightPx={isOpen && !isMobile ? 600 : 0} autoLoadOnViewport={false} isExperimental={isCurrentProjectExperimental} - > - <div className="absolute inset-0 flex items-center justify-center"> - <div className="rounded-xl overflow-hidden"> - <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> - <p className="text-lg font-medium mb-2">No Memories to Visualize</p> - <button - type="button" - className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" - onClick={() => setShowAddMemoryView(true)} - > - Create one? - </button> + > + <div className="absolute inset-0 flex items-center justify-center"> + <div className="rounded-xl overflow-hidden"> + <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> + <p className="text-lg font-medium mb-2"> + No Memories to Visualize + </p> + <button + type="button" + className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" + onClick={() => setShowAddMemoryView(true)} + > + Create one? + </button> + </div> </div> </div> - </div> - </MemoryGraph> - </motion.div> - ) : ( - <motion.div - animate={{ opacity: 1, scale: 1 }} - className="absolute inset-0 md:ml-18" - exit={{ opacity: 0, scale: 0.95 }} - id={TOUR_STEP_IDS.MEMORY_LIST} - initial={{ opacity: 0, scale: 0.95 }} - key="list" - transition={{ - type: "spring", - stiffness: 500, - damping: 30, - }} - > - <MemoryListView - documents={allDocuments} - error={error} - hasMore={hasMore} - isLoading={isPending} - isLoadingMore={isLoadingMore} - loadMoreDocuments={loadMoreDocuments} - totalLoaded={totalLoaded} + </MemoryGraph> + </motion.div> + ) : ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="absolute inset-0 md:ml-18" + exit={{ opacity: 0, scale: 0.95 }} + id={TOUR_STEP_IDS.MEMORY_LIST} + initial={{ opacity: 0, scale: 0.95 }} + key="list" + transition={{ + type: "spring", + stiffness: 500, + damping: 30, + }} > - <div className="absolute inset-0 flex items-center justify-center"> - <div className="rounded-xl overflow-hidden"> - <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> - <p className="text-lg font-medium mb-2">No Memories to Visualize</p> - <button - className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" - onClick={() => setShowAddMemoryView(true)} - type="button" - > - Create one? - </button> + <MemoryListView + documents={allDocuments} + error={error} + hasMore={hasMore} + isLoading={isPending} + isLoadingMore={isLoadingMore} + loadMoreDocuments={loadMoreDocuments} + totalLoaded={totalLoaded} + > + <div className="absolute inset-0 flex items-center justify-center"> + <div className="rounded-xl overflow-hidden"> + <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> + <p className="text-lg font-medium mb-2"> + No Memories to Visualize + </p> + <button + className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" + onClick={() => setShowAddMemoryView(true)} + type="button" + > + Create one? + </button> + </div> </div> </div> - </div> - </MemoryListView> - </motion.div> - )} - </AnimatePresence> - - {/* Top Bar */} - <div className="absolute top-2 left-0 right-0 z-10 p-4 flex items-center justify-between"> - <div className="flex items-center gap-3 justify-between w-full md:w-fit md:justify-start"> - <Link - className="pointer-events-auto" - href="https://supermemory.ai" - rel="noopener noreferrer" - target="_blank" - > - <LogoFull className="h-8 hidden md:block" id={TOUR_STEP_IDS.LOGO} /> - <Logo className="h-8 md:hidden" id={TOUR_STEP_IDS.LOGO} /> - </Link> - - <div className="hidden sm:block"> - <ProjectSelector /> - </div> - - <ConnectAIModal> - <Button - variant="outline" - size="sm" - className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3" + </MemoryListView> + </motion.div> + )} + </AnimatePresence> + + {/* Top Bar */} + <div className="absolute top-2 left-0 right-0 z-10 p-4 flex items-center justify-between"> + <div className="flex items-center gap-3 justify-between w-full md:w-fit md:justify-start"> + <Link + className="pointer-events-auto" + href="https://supermemory.ai" + rel="noopener noreferrer" + target="_blank" > - <Unplug className="h-4 w-4" /> - <span className="hidden sm:inline ml-2">Connect to your AI</span> - <span className="sm:hidden ml-1">Connect AI</span> - </Button> - </ConnectAIModal> - </div> + <LogoFull + className="h-8 hidden md:block" + id={TOUR_STEP_IDS.LOGO} + /> + <Logo className="h-8 md:hidden" id={TOUR_STEP_IDS.LOGO} /> + </Link> + + <div className="hidden sm:block"> + <ProjectSelector /> + </div> + + <ConnectAIModal> + <Button + variant="outline" + size="sm" + className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3" + > + <Unplug className="h-4 w-4" /> + <span className="hidden sm:inline ml-2"> + Connect to your AI + </span> + <span className="sm:hidden ml-1">Connect AI</span> + </Button> + </ConnectAIModal> + </div> - <div> - <Menu /> + <div> + <Menu /> + </div> </div> - </div> {/* Floating Open Chat Button */} {!isOpen && !isMobile && ( @@ -598,7 +634,7 @@ const MemoryGraphPage = () => { id={TOUR_STEP_IDS.FLOATING_CHAT} > <motion.div - animate={{ x: isOpen ? 0 : (isMobile ? "100%" : 600) }} + animate={{ x: isOpen ? 0 : isMobile ? "100%" : 600 }} className="absolute inset-0" exit={{ x: isMobile ? "100%" : 600 }} initial={{ x: isMobile ? "100%" : 600 }} @@ -607,7 +643,8 @@ const MemoryGraphPage = () => { type: "spring", stiffness: 500, damping: 40, - }}> + }} + > <ChatRewrite /> </motion.div> </motion.div> @@ -623,18 +660,18 @@ const MemoryGraphPage = () => { <TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} /> {/* Referral/Upgrade Modal */} - <ReferralUpgradeModal - isOpen={showReferralModal} - onClose={() => setShowReferralModal(false)} + <ReferralUpgradeModal + isOpen={showReferralModal} + onClose={() => setShowReferralModal(false)} /> </div> - ) -} + ); +}; // Wrapper component to handle auth and waitlist checks export default function Page() { - const router = useRouter() - const { user } = useAuth() + const router = useRouter(); + const { user } = useAuth(); // Check waitlist status const { @@ -645,24 +682,24 @@ export default function Page() { queryKey: ["waitlist-status", user?.id], queryFn: async () => { try { - const response = await $fetch("@get/waitlist/status") - return response.data + const response = await $fetch("@get/waitlist/status"); + return response.data; } catch (error) { - console.error("Error checking waitlist status:", error) + console.error("Error checking waitlist status:", error); // Return null to indicate error, will handle in useEffect - return null + return null; } }, enabled: !!user && !user.isAnonymous, staleTime: 5 * 60 * 1000, // 5 minutes retry: 1, // Only retry once on failure - }) + }); useEffect(() => { if (waitlistStatus && !waitlistStatus.accessGranted) { - router.push("/waitlist") + router.push("/waitlist"); } - }, []) + }, []); // Show loading state while checking authentication and waitlist status if (!user || isCheckingWaitlist) { @@ -673,9 +710,14 @@ export default function Page() { <p className="text-white/60">Loading...</p> </div> </div> - ) + ); } // If we have a user and they have access, show the main component - return <><MemoryGraphPage /><InstallPrompt /></>; + return ( + <> + <MemoryGraphPage /> + <InstallPrompt /> + </> + ); } diff --git a/apps/web/app/ref/[code]/page.tsx b/apps/web/app/ref/[code]/page.tsx index d1456152..3acbe657 100644 --- a/apps/web/app/ref/[code]/page.tsx +++ b/apps/web/app/ref/[code]/page.tsx @@ -1,41 +1,41 @@ -"use client" +"use client"; -import { $fetch } from "@lib/api" -import { Button } from "@ui/components/button" +import { $fetch } from "@lib/api"; +import { Button } from "@ui/components/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, -} from "@ui/components/card" -import { CheckIcon, CopyIcon, LoaderIcon, ShareIcon } from "lucide-react" -import Link from "next/link" -import { useRouter, useParams } from "next/navigation" -import { useEffect, useState } from "react" -import { toast } from "sonner" +} from "@ui/components/card"; +import { CheckIcon, CopyIcon, LoaderIcon, ShareIcon } from "lucide-react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; export default function ReferralPage() { - const router = useRouter() - const params = useParams() - const referralCode = params.code as string - - const [isJoiningWaitlist, setIsJoiningWaitlist] = useState(false) - const [isLoading, setIsLoading] = useState(true) + const router = useRouter(); + const params = useParams(); + const referralCode = params.code as string; + + const [isJoiningWaitlist, setIsJoiningWaitlist] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [referralData, setReferralData] = useState<{ - referrerName?: string - valid: boolean - } | null>(null) - const [copiedLink, setCopiedLink] = useState(false) + referrerName?: string; + valid: boolean; + } | null>(null); + const [copiedLink, setCopiedLink] = useState(false); - const referralLink = `https://supermemory.ai/ref/${referralCode}` + const referralLink = `https://supermemory.ai/ref/${referralCode}`; // Verify referral code and get referrer info useEffect(() => { async function checkReferral() { if (!referralCode) { - setIsLoading(false) - return + setIsLoading(false); + return; } try { @@ -43,42 +43,42 @@ export default function ReferralPage() { // For now, we'll assume it's valid - in the future this should call an API setReferralData({ valid: true, - referrerName: "A supermemory user" // Placeholder - should come from API - }) + referrerName: "A supermemory user", // Placeholder - should come from API + }); } catch (error) { - console.error("Error checking referral:", error) - setReferralData({ valid: false }) + console.error("Error checking referral:", error); + setReferralData({ valid: false }); } finally { - setIsLoading(false) + setIsLoading(false); } } - checkReferral() - }, [referralCode]) + checkReferral(); + }, [referralCode]); const handleJoinWaitlist = async () => { - setIsJoiningWaitlist(true) + setIsJoiningWaitlist(true); try { // Redirect to waitlist signup with referral code - router.push(`/waitlist?ref=${referralCode}`) + router.push(`/waitlist?ref=${referralCode}`); } catch (error) { - console.error("Error joining waitlist:", error) - toast.error("Failed to join waitlist. Please try again.") + console.error("Error joining waitlist:", error); + toast.error("Failed to join waitlist. Please try again."); } finally { - setIsJoiningWaitlist(false) + setIsJoiningWaitlist(false); } - } + }; const handleCopyLink = async () => { try { - await navigator.clipboard.writeText(referralLink) - setCopiedLink(true) - toast.success("Referral link copied!") - setTimeout(() => setCopiedLink(false), 2000) + await navigator.clipboard.writeText(referralLink); + setCopiedLink(true); + toast.success("Referral link copied!"); + setTimeout(() => setCopiedLink(false), 2000); } catch (error) { - toast.error("Failed to copy link") + toast.error("Failed to copy link"); } - } + }; const handleShare = () => { if (navigator.share) { @@ -86,11 +86,11 @@ export default function ReferralPage() { title: "Join supermemory", text: "I'm excited about supermemory - it's going to change how we store and interact with our memories!", url: referralLink, - }) + }); } else { - handleCopyLink() + handleCopyLink(); } - } + }; if (isLoading) { return ( @@ -100,7 +100,7 @@ export default function ReferralPage() { <p className="text-white/60">Checking invitation...</p> </div> </div> - ) + ); } if (!referralData?.valid) { @@ -118,15 +118,13 @@ export default function ReferralPage() { <CardContent> <div className="text-center"> <Button asChild className="w-full"> - <Link href="https://supermemory.ai"> - Go to supermemory - </Link> + <Link href="https://supermemory.ai">Go to supermemory</Link> </Button> </div> </CardContent> </Card> </div> - ) + ); } return ( @@ -142,21 +140,25 @@ export default function ReferralPage() { You're invited to supermemory! </CardTitle> <CardDescription className="text-white/60 mt-2"> - {referralData.referrerName} invited you to join the future of memory management. + {referralData.referrerName} invited you to join the future of + memory management. </CardDescription> </CardHeader> <CardContent> <div className="space-y-4"> <div className="bg-[#0f1419] rounded-lg p-4 border border-white/10"> - <h3 className="text-white font-semibold mb-2">What is supermemory?</h3> + <h3 className="text-white font-semibold mb-2"> + What is supermemory? + </h3> <p className="text-white/70 text-sm leading-relaxed"> - supermemory is an AI-powered personal knowledge base that helps you store, - organize, and interact with all your digital memories - from documents - and links to conversations and ideas. + supermemory is an AI-powered personal knowledge base that + helps you store, organize, and interact with all your digital + memories - from documents and links to conversations and + ideas. </p> </div> - - <Button + + <Button onClick={handleJoinWaitlist} disabled={isJoiningWaitlist} className="w-full bg-orange-500 hover:bg-orange-600 text-white" @@ -169,8 +171,8 @@ export default function ReferralPage() { </Button> <div className="text-center"> - <Link - href="https://supermemory.ai" + <Link + href="https://supermemory.ai" className="text-orange-500 hover:text-orange-400 text-sm underline" > Learn more about supermemory @@ -183,7 +185,9 @@ export default function ReferralPage() { {/* Share Card */} <Card className="bg-[#1a1f2a] border-white/10"> <CardHeader> - <CardTitle className="text-lg text-white">Share with friends</CardTitle> + <CardTitle className="text-lg text-white"> + Share with friends + </CardTitle> <CardDescription className="text-white/60"> Help others discover supermemory and earn priority access. </CardDescription> @@ -223,5 +227,5 @@ export default function ReferralPage() { </Card> </div> </div> - ) -}
\ No newline at end of file + ); +} diff --git a/apps/web/app/ref/page.tsx b/apps/web/app/ref/page.tsx index 0a33ebba..38be47b0 100644 --- a/apps/web/app/ref/page.tsx +++ b/apps/web/app/ref/page.tsx @@ -1,15 +1,15 @@ -"use client" +"use client"; -import { Button } from "@ui/components/button" +import { Button } from "@ui/components/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, -} from "@ui/components/card" -import { ShareIcon } from "lucide-react" -import Link from "next/link" +} from "@ui/components/card"; +import { ShareIcon } from "lucide-react"; +import Link from "next/link"; export default function ReferralHomePage() { return ( @@ -23,28 +23,33 @@ export default function ReferralHomePage() { Missing Referral Code </CardTitle> <CardDescription className="text-white/60 mt-2"> - It looks like you're missing a referral code. Get one from a friend or join directly! + It looks like you're missing a referral code. Get one from a friend + or join directly! </CardDescription> </CardHeader> <CardContent> <div className="space-y-4"> <div className="bg-[#0f1419] rounded-lg p-4 border border-white/10"> - <h3 className="text-white font-semibold mb-2">What is supermemory?</h3> + <h3 className="text-white font-semibold mb-2"> + What is supermemory? + </h3> <p className="text-white/70 text-sm leading-relaxed"> - supermemory is an AI-powered personal knowledge base that helps you store, - organize, and interact with all your digital memories. + supermemory is an AI-powered personal knowledge base that helps + you store, organize, and interact with all your digital + memories. </p> </div> - - <Button asChild className="w-full bg-orange-500 hover:bg-orange-600 text-white"> - <Link href="/waitlist"> - Join the Waitlist - </Link> + + <Button + asChild + className="w-full bg-orange-500 hover:bg-orange-600 text-white" + > + <Link href="/waitlist">Join the Waitlist</Link> </Button> <div className="text-center"> - <Link - href="https://supermemory.ai" + <Link + href="https://supermemory.ai" className="text-orange-500 hover:text-orange-400 text-sm underline" > Learn more about supermemory @@ -54,5 +59,5 @@ export default function ReferralHomePage() { </CardContent> </Card> </div> - ) -}
\ No newline at end of file + ); +} diff --git a/apps/web/app/waitlist/page.tsx b/apps/web/app/waitlist/page.tsx index b493c813..5f591f21 100644 --- a/apps/web/app/waitlist/page.tsx +++ b/apps/web/app/waitlist/page.tsx @@ -1,115 +1,115 @@ -"use client" +"use client"; -import { $fetch } from "@lib/api" -import { authClient } from "@lib/auth" -import { useAuth } from "@lib/auth-context" -import { fetchConsumerProProduct } from "@lib/queries" -import { Button } from "@ui/components/button" +import { $fetch } from "@lib/api"; +import { authClient } from "@lib/auth"; +import { useAuth } from "@lib/auth-context"; +import { fetchConsumerProProduct } from "@lib/queries"; +import { Button } from "@ui/components/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, -} from "@ui/components/card" -import { useCustomer } from "autumn-js/react" -import { Clock, LoaderIcon, SkipForwardIcon, LogOut } from "lucide-react" -import Link from "next/link" -import { useRouter, useSearchParams } from "next/navigation" -import { useEffect, useState } from "react" -import { toast } from "sonner" +} from "@ui/components/card"; +import { useCustomer } from "autumn-js/react"; +import { Clock, LoaderIcon, LogOut, SkipForwardIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; export default function WaitlistPage() { - const router = useRouter() - const searchParams = useSearchParams() - const referralCode = searchParams.get('ref') - const { user } = useAuth() - const [isChecking, setIsChecking] = useState(true) - const [isSkippingWaitlist, setIsSkippingWaitlist] = useState(false) + const router = useRouter(); + const searchParams = useSearchParams(); + const referralCode = searchParams.get("ref"); + const { user } = useAuth(); + const [isChecking, setIsChecking] = useState(true); + const [isSkippingWaitlist, setIsSkippingWaitlist] = useState(false); const [waitlistStatus, setWaitlistStatus] = useState<{ - inWaitlist: boolean - accessGranted: boolean - createdAt: string - } | null>(null) - const autumn = useCustomer() + inWaitlist: boolean; + accessGranted: boolean; + createdAt: string; + } | null>(null); + const autumn = useCustomer(); - // @ts-ignore - const { data: earlyAccess } = fetchConsumerProProduct(autumn) + // @ts-expect-error + const { data: earlyAccess } = fetchConsumerProProduct(autumn); const handleSkipWaitlist = async () => { - setIsSkippingWaitlist(true) + setIsSkippingWaitlist(true); try { const res = await autumn.attach({ productId: "consumer_pro", forceCheckout: true, successUrl: "https://app.supermemory.ai/", - }) + }); if (res.data && "checkout_url" in res.data && res.data.checkout_url) { - router.push(res.data.checkout_url) + router.push(res.data.checkout_url); } } catch (error) { - console.error("Error skipping waitlist:", error) + console.error("Error skipping waitlist:", error); } finally { - setIsSkippingWaitlist(false) + setIsSkippingWaitlist(false); } - } + }; const handleLogout = async () => { try { - await authClient.signOut() - router.push("/") + await authClient.signOut(); + router.push("/"); } catch (error) { - console.error("Error signing out:", error) - toast.error("Failed to sign out") + console.error("Error signing out:", error); + toast.error("Failed to sign out"); } - } + }; useEffect(() => { async function checkAccess() { if (!user) { - router.push("/") - return + router.push("/"); + return; } // Anonymous users should sign in first if (user.isAnonymous) { - authClient.signOut() - router.push("/") - return + authClient.signOut(); + router.push("/"); + return; } try { // Check waitlist status using the new endpoint - const response = await $fetch("@get/waitlist/status") + const response = await $fetch("@get/waitlist/status"); if (response.data) { - setWaitlistStatus(response.data) + setWaitlistStatus(response.data); if (!response.data.inWaitlist) { - authClient.signOut() - router.push("/login") + authClient.signOut(); + router.push("/login"); } // If user has access, redirect to home if (response.data.accessGranted) { - router.push("/") + router.push("/"); } } } catch (error) { - console.error("Error checking waitlist status:", error) + console.error("Error checking waitlist status:", error); // If there's an error, assume user is on waitlist setWaitlistStatus({ inWaitlist: true, accessGranted: false, createdAt: new Date().toISOString(), - }) + }); } finally { - setIsChecking(false) + setIsChecking(false); } } - checkAccess() - }, [user, router]) + checkAccess(); + }, [user, router]); if (isChecking) { return ( @@ -119,7 +119,7 @@ export default function WaitlistPage() { <p className="text-white/60">Checking access...</p> </div> </div> - ) + ); } return ( @@ -133,10 +133,9 @@ export default function WaitlistPage() { You're on the waitlist! </CardTitle> <CardDescription className="text-white/60 mt-2"> - {referralCode + {referralCode ? "Thanks for joining through a friend's invitation! You've been added to the waitlist with priority access." - : "Thanks for your interest in supermemory. We'll notify you as soon as we're ready for you." - } + : "Thanks for your interest in supermemory. We'll notify you as soon as we're ready for you."} </CardDescription> {referralCode && ( <div className="mt-3 px-3 py-2 bg-orange-500/10 rounded-lg border border-orange-500/20"> @@ -199,8 +198,8 @@ export default function WaitlistPage() { <p className="text-white/50 text-xs"> Signed in as {user.email} </p> - <Button - variant="outline" + <Button + variant="outline" size="sm" onClick={handleLogout} className="border-white/20 hover:bg-white/5" @@ -214,5 +213,5 @@ export default function WaitlistPage() { </CardContent> </Card> </div> - ) + ); } diff --git a/apps/web/biome.json b/apps/web/biome.json index 79f38fb5..ea994ee6 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -1,10 +1,11 @@ { - "$schema": "https://biomejs.dev/schemas/2.1.2/schema.json", - "extends": "//", + "root": false, + "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", "linter": { - "domains": { - "next": "recommended", - "react": "recommended" + "rules": { + "nursery": { + "recommended": true + } } } } diff --git a/apps/web/button.tsx b/apps/web/button.tsx index 99d10e10..3a671e03 100644 --- a/apps/web/button.tsx +++ b/apps/web/button.tsx @@ -1,7 +1,7 @@ -import { cn } from "@lib/utils" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" -import type * as React from "react" +import { cn } from "@lib/utils"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -32,7 +32,7 @@ const buttonVariants = cva( size: "default", }, }, -) +); function Button({ className, @@ -42,9 +42,9 @@ function Button({ ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { - asChild?: boolean + asChild?: boolean; }) { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : "button"; return ( <Comp @@ -52,7 +52,7 @@ function Button({ data-slot="button" {...props} /> - ) + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/apps/web/components/connect-ai-modal.tsx b/apps/web/components/connect-ai-modal.tsx index ca5240cd..ff5f1064 100644 --- a/apps/web/components/connect-ai-modal.tsx +++ b/apps/web/components/connect-ai-modal.tsx @@ -1,7 +1,7 @@ -"use client" +"use client"; -import { useIsMobile } from "@hooks/use-mobile" -import { Button } from "@ui/components/button" +import { useIsMobile } from "@hooks/use-mobile"; +import { Button } from "@ui/components/button"; import { Dialog, DialogContent, @@ -9,20 +9,20 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from "@ui/components/dialog" -import { Input } from "@ui/components/input" +} from "@ui/components/dialog"; +import { Input } from "@ui/components/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@ui/components/select" -import { CopyableCell } from "@ui/copyable-cell" -import { CopyIcon, ExternalLink } from "lucide-react" -import Image from "next/image" -import { useState } from "react" -import { toast } from "sonner" +} from "@ui/components/select"; +import { CopyableCell } from "@ui/copyable-cell"; +import { CopyIcon, ExternalLink } from "lucide-react"; +import Image from "next/image"; +import { useState } from "react"; +import { toast } from "sonner"; const clients = { cursor: "Cursor", @@ -34,23 +34,23 @@ const clients = { "roo-cline": "Roo Cline", witsy: "Witsy", enconvo: "Enconvo", -} as const +} as const; interface ConnectAIModalProps { - children: React.ReactNode + children: React.ReactNode; } export function ConnectAIModal({ children }: ConnectAIModalProps) { - const [client, setClient] = useState<keyof typeof clients>("cursor") - const [isOpen, setIsOpen] = useState(false) - const [showAllTools, setShowAllTools] = useState(false) - const isMobile = useIsMobile() - const installCommand = `npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes` + const [client, setClient] = useState<keyof typeof clients>("cursor"); + const [isOpen, setIsOpen] = useState(false); + const [showAllTools, setShowAllTools] = useState(false); + const isMobile = useIsMobile(); + const installCommand = `npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes`; const copyToClipboard = () => { - navigator.clipboard.writeText(installCommand) - toast.success("Copied to clipboard!") - } + navigator.clipboard.writeText(installCommand); + toast.success("Copied to clipboard!"); + }; return ( <Dialog open={isOpen} onOpenChange={setIsOpen}> @@ -59,12 +59,13 @@ export function ConnectAIModal({ children }: ConnectAIModalProps) { <DialogHeader> <DialogTitle>Connect Supermemory to Your AI</DialogTitle> <DialogDescription> - Connect supermemory to your favorite AI tools using the Model Context Protocol (MCP). - This allows your AI assistant to create, search, and access your memories directly. + Connect supermemory to your favorite AI tools using the Model + Context Protocol (MCP). This allows your AI assistant to create, + search, and access your memories directly. </DialogDescription> </DialogHeader> - <div className="mb-6 block md:hidden"> + <div className="mb-6 block md:hidden"> <label className="text-sm font-medium text-white/80 block mb-2" htmlFor="mcp-server-url" @@ -78,10 +79,11 @@ export function ConnectAIModal({ children }: ConnectAIModalProps) { /> </div> <p className="text-xs text-white/50 mt-2"> - Click URL to copy to clipboard. Use this URL to configure supermemory in your AI assistant. + Click URL to copy to clipboard. Use this URL to configure + supermemory in your AI assistant. </p> </div> - + <div className="space-y-6"> <div className="hidden md:block"> <h3 className="text-sm font-medium mb-3">Supported AI Tools</h3> @@ -102,12 +104,18 @@ export function ConnectAIModal({ children }: ConnectAIModalProps) { className={"rounded object-contain"} onError={(e) => { const target = e.target as HTMLImageElement; - target.style.display = 'none'; + target.style.display = "none"; const parent = target.parentElement; - if (parent && !parent.querySelector('.fallback-text')) { - const fallback = document.createElement('span'); - fallback.className = 'fallback-text text-xs font-bold text-muted-foreground'; - fallback.textContent = clientName.substring(0, 2).toUpperCase(); + if ( + parent && + !parent.querySelector(".fallback-text") + ) { + const fallback = document.createElement("span"); + fallback.className = + "fallback-text text-xs font-bold text-muted-foreground"; + fallback.textContent = clientName + .substring(0, 2) + .toUpperCase(); parent.appendChild(fallback); } }} @@ -136,7 +144,9 @@ export function ConnectAIModal({ children }: ConnectAIModalProps) { <div className="space-y-3 flex gap-2 items-center justify-between"> <Select value={client} - onValueChange={(value) => setClient(value as keyof typeof clients)} + onValueChange={(value) => + setClient(value as keyof typeof clients) + } > <SelectTrigger className="w-48 mb-0"> <SelectValue /> @@ -154,12 +164,18 @@ export function ConnectAIModal({ children }: ConnectAIModalProps) { className="rounded object-contain" onError={(e) => { const target = e.target as HTMLImageElement; - target.style.display = 'none'; + target.style.display = "none"; const parent = target.parentElement; - if (parent && !parent.querySelector('.fallback-text')) { - const fallback = document.createElement('span'); - fallback.className = 'fallback-text text-xs font-bold text-muted-foreground'; - fallback.textContent = value.substring(0, 1).toUpperCase(); + if ( + parent && + !parent.querySelector(".fallback-text") + ) { + const fallback = document.createElement("span"); + fallback.className = + "fallback-text text-xs font-bold text-muted-foreground"; + fallback.textContent = value + .substring(0, 1) + .toUpperCase(); parent.appendChild(fallback); } }} @@ -171,22 +187,22 @@ export function ConnectAIModal({ children }: ConnectAIModalProps) { ))} </SelectContent> </Select> - - <div className="relative w-full flex items-center"> - <Input - className="font-mono text-xs w-full pr-10" - readOnly - value={installCommand} - /> - - <Button - onClick={copyToClipboard} - className="absolute right-0 cursor-pointer" - variant="ghost" - > - <CopyIcon className="size-4" /> - </Button> - </div> + + <div className="relative w-full flex items-center"> + <Input + className="font-mono text-xs w-full pr-10" + readOnly + value={installCommand} + /> + + <Button + onClick={copyToClipboard} + className="absolute right-0 cursor-pointer" + variant="ghost" + > + <CopyIcon className="size-4" /> + </Button> + </div> </div> </div> @@ -203,17 +219,20 @@ export function ConnectAIModal({ children }: ConnectAIModalProps) { <div className="flex justify-between items-center pt-4 border-t"> <Button variant="outline" - onClick={() => window.open("https://docs.supermemory.ai/supermemory-mcp/introduction", "_blank")} + onClick={() => + window.open( + "https://docs.supermemory.ai/supermemory-mcp/introduction", + "_blank", + ) + } > <ExternalLink className="w-4 h-4 mr-2" /> Learn More </Button> - <Button onClick={() => setIsOpen(false)}> - Done - </Button> + <Button onClick={() => setIsOpen(false)}>Done</Button> </div> </div> </DialogContent> </Dialog> - ) + ); } diff --git a/apps/web/components/create-project-dialog.tsx b/apps/web/components/create-project-dialog.tsx index 904c3f2d..1672a689 100644 --- a/apps/web/components/create-project-dialog.tsx +++ b/apps/web/components/create-project-dialog.tsx @@ -1,6 +1,6 @@ -"use client" +"use client"; -import { Button } from "@repo/ui/components/button" +import { Button } from "@repo/ui/components/button"; import { Dialog, DialogContent, @@ -8,37 +8,40 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@repo/ui/components/dialog" -import { Input } from "@repo/ui/components/input" -import { Label } from "@repo/ui/components/label" -import { Loader2 } from "lucide-react" -import { motion, AnimatePresence } from "motion/react" -import { useState } from "react" -import { useProjectMutations } from "@/hooks/use-project-mutations" +} from "@repo/ui/components/dialog"; +import { Input } from "@repo/ui/components/input"; +import { Label } from "@repo/ui/components/label"; +import { Loader2 } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useState } from "react"; +import { useProjectMutations } from "@/hooks/use-project-mutations"; interface CreateProjectDialogProps { - open: boolean - onOpenChange: (open: boolean) => void + open: boolean; + onOpenChange: (open: boolean) => void; } -export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogProps) { - const [projectName, setProjectName] = useState("") - const { createProjectMutation } = useProjectMutations() +export function CreateProjectDialog({ + open, + onOpenChange, +}: CreateProjectDialogProps) { + const [projectName, setProjectName] = useState(""); + const { createProjectMutation } = useProjectMutations(); const handleClose = () => { - onOpenChange(false) - setProjectName("") - } + onOpenChange(false); + setProjectName(""); + }; const handleCreate = () => { if (projectName.trim()) { createProjectMutation.mutate(projectName, { onSuccess: () => { - handleClose() - } - }) + handleClose(); + }, + }); } - } + }; return ( <AnimatePresence> @@ -72,7 +75,7 @@ export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogP onChange={(e) => setProjectName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && projectName.trim()) { - handleCreate() + handleCreate(); } }} /> @@ -82,7 +85,10 @@ export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogP </motion.div> </div> <DialogFooter> - <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > <Button type="button" variant="outline" @@ -92,11 +98,16 @@ export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogP Cancel </Button> </motion.div> - <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > <Button type="button" className="bg-white/10 hover:bg-white/20 text-white border-white/20" - disabled={createProjectMutation.isPending || !projectName.trim()} + disabled={ + createProjectMutation.isPending || !projectName.trim() + } onClick={handleCreate} > {createProjectMutation.isPending ? ( @@ -115,5 +126,5 @@ export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogP </Dialog> )} </AnimatePresence> - ) + ); } diff --git a/apps/web/components/glass-menu-effect.tsx b/apps/web/components/glass-menu-effect.tsx index 9d4d4b68..3914ac7b 100644 --- a/apps/web/components/glass-menu-effect.tsx +++ b/apps/web/components/glass-menu-effect.tsx @@ -1,8 +1,8 @@ -import { motion } from "motion/react" +import { motion } from "motion/react"; interface GlassMenuEffectProps { - rounded?: string - className?: string + rounded?: string; + className?: string; } export function GlassMenuEffect({ @@ -33,5 +33,5 @@ export function GlassMenuEffect({ }} /> </motion.div> - ) + ); } diff --git a/apps/web/components/install-prompt.tsx b/apps/web/components/install-prompt.tsx index cde987c4..3bbeb071 100644 --- a/apps/web/components/install-prompt.tsx +++ b/apps/web/components/install-prompt.tsx @@ -1,118 +1,132 @@ -import { useEffect, useState } from "react" -import { motion, AnimatePresence } from "motion/react" -import { X, Download, Share } from "lucide-react" -import { Button } from "@repo/ui/components/button" +import { Button } from "@repo/ui/components/button"; +import { Download, Share, X } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useState } from "react"; export function InstallPrompt() { - const [isIOS, setIsIOS] = useState(false) - const [showPrompt, setShowPrompt] = useState(false) - const [deferredPrompt, setDeferredPrompt] = useState<any>(null) - - useEffect(() => { - const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream - const isInStandaloneMode = window.matchMedia('(display-mode: standalone)').matches - const hasSeenPrompt = localStorage.getItem('install-prompt-dismissed') === 'true' - - setIsIOS(isIOSDevice) - - const isDevelopment = process.env.NODE_ENV === 'development' - setShowPrompt(!hasSeenPrompt && (isDevelopment || (!isInStandaloneMode && (isIOSDevice || 'serviceWorker' in navigator)))) - - const handleBeforeInstallPrompt = (e: Event) => { - e.preventDefault() - setDeferredPrompt(e) - if (!hasSeenPrompt) { - setShowPrompt(true) - } - } - - window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) - - return () => { - window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) - } - }, []) - - const handleInstall = async () => { - if (deferredPrompt) { - deferredPrompt.prompt() - const { outcome } = await deferredPrompt.userChoice - if (outcome === 'accepted') { - localStorage.setItem('install-prompt-dismissed', 'true') - setShowPrompt(false) - } - setDeferredPrompt(null) - } - } - - const handleDismiss = () => { - localStorage.setItem('install-prompt-dismissed', 'true') - setShowPrompt(false) - } - - if (!showPrompt) { - return null - } - - return ( - <AnimatePresence> - <motion.div - animate={{ y: 0, opacity: 1 }} - exit={{ y: 100, opacity: 0 }} - initial={{ y: 100, opacity: 0 }} - className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-sm md:hidden" - > - <div className="bg-black/90 backdrop-blur-md text-white rounded-2xl p-4 shadow-2xl border border-white/10"> - <div className="flex items-start justify-between mb-3"> - <div className="flex items-center gap-2"> - <div className="w-8 h-8 bg-[#0f1419] rounded-lg flex items-center justify-center"> - <Download className="w-4 h-4" /> - </div> - <h3 className="font-semibold text-sm">Install Supermemory</h3> - </div> - <Button - variant="ghost" - size="sm" - onClick={handleDismiss} - className="text-white/60 hover:text-white h-6 w-6 p-0" - > - <X className="w-4 h-4" /> - </Button> - </div> - - <p className="text-white/80 text-xs mb-4 leading-relaxed"> - Add Supermemory to your home screen for quick access and a better experience. - </p> - - {isIOS ? ( - <div className="space-y-3"> - <p className="text-white/70 text-xs flex items-center gap-1"> - 1. Tap the <Share className="w-3 h-3 inline" /> Share button in Safari - </p> - <p className="text-white/70 text-xs"> - 2. Select "Add to Home Screen" ➕ - </p> - <Button - variant="secondary" - size="sm" - onClick={handleDismiss} - className="w-full text-xs" - > - Got it - </Button> - </div> - ) : ( - <Button - onClick={handleInstall} - size="sm" - className="w-full bg-[#0f1419] hover:bg-[#1a1f2a] text-white text-xs" - > - <Download className="w-3 h-3 mr-1" /> - Add to Home Screen - </Button> - )} - </div> - </motion.div> - </AnimatePresence> - ) + const [isIOS, setIsIOS] = useState(false); + const [showPrompt, setShowPrompt] = useState(false); + const [deferredPrompt, setDeferredPrompt] = useState<any>(null); + + useEffect(() => { + const isIOSDevice = + /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream; + const isInStandaloneMode = window.matchMedia( + "(display-mode: standalone)", + ).matches; + const hasSeenPrompt = + localStorage.getItem("install-prompt-dismissed") === "true"; + + setIsIOS(isIOSDevice); + + const isDevelopment = process.env.NODE_ENV === "development"; + setShowPrompt( + !hasSeenPrompt && + (isDevelopment || + (!isInStandaloneMode && + (isIOSDevice || "serviceWorker" in navigator))), + ); + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault(); + setDeferredPrompt(e); + if (!hasSeenPrompt) { + setShowPrompt(true); + } + }; + + window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + + return () => { + window.removeEventListener( + "beforeinstallprompt", + handleBeforeInstallPrompt, + ); + }; + }, []); + + const handleInstall = async () => { + if (deferredPrompt) { + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + if (outcome === "accepted") { + localStorage.setItem("install-prompt-dismissed", "true"); + setShowPrompt(false); + } + setDeferredPrompt(null); + } + }; + + const handleDismiss = () => { + localStorage.setItem("install-prompt-dismissed", "true"); + setShowPrompt(false); + }; + + if (!showPrompt) { + return null; + } + + return ( + <AnimatePresence> + <motion.div + animate={{ y: 0, opacity: 1 }} + exit={{ y: 100, opacity: 0 }} + initial={{ y: 100, opacity: 0 }} + className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-sm md:hidden" + > + <div className="bg-black/90 backdrop-blur-md text-white rounded-2xl p-4 shadow-2xl border border-white/10"> + <div className="flex items-start justify-between mb-3"> + <div className="flex items-center gap-2"> + <div className="w-8 h-8 bg-[#0f1419] rounded-lg flex items-center justify-center"> + <Download className="w-4 h-4" /> + </div> + <h3 className="font-semibold text-sm">Install Supermemory</h3> + </div> + <Button + variant="ghost" + size="sm" + onClick={handleDismiss} + className="text-white/60 hover:text-white h-6 w-6 p-0" + > + <X className="w-4 h-4" /> + </Button> + </div> + + <p className="text-white/80 text-xs mb-4 leading-relaxed"> + Add Supermemory to your home screen for quick access and a better + experience. + </p> + + {isIOS ? ( + <div className="space-y-3"> + <p className="text-white/70 text-xs flex items-center gap-1"> + 1. Tap the <Share className="w-3 h-3 inline" /> Share button in + Safari + </p> + <p className="text-white/70 text-xs"> + 2. Select "Add to Home Screen" ➕ + </p> + <Button + variant="secondary" + size="sm" + onClick={handleDismiss} + className="w-full text-xs" + > + Got it + </Button> + </div> + ) : ( + <Button + onClick={handleInstall} + size="sm" + className="w-full bg-[#0f1419] hover:bg-[#1a1f2a] text-white text-xs" + > + <Download className="w-3 h-3 mr-1" /> + Add to Home Screen + </Button> + )} + </div> + </motion.div> + </AnimatePresence> + ); } diff --git a/apps/web/components/memory-list-view.tsx b/apps/web/components/memory-list-view.tsx index 654a3ad1..8269562a 100644 --- a/apps/web/components/memory-list-view.tsx +++ b/apps/web/components/memory-list-view.tsx @@ -1,5 +1,7 @@ -"use client" +"use client"; +import { useIsMobile } from "@hooks/use-mobile"; +import { cn } from "@lib/utils"; import { GoogleDocs, GoogleDrive, @@ -12,165 +14,178 @@ import { NotionDoc, OneDrive, PDF, -} from "@repo/ui/assets/icons" -import { Badge } from "@repo/ui/components/badge" -import { Card, CardContent, CardHeader } from "@repo/ui/components/card" - +} from "@repo/ui/assets/icons"; +import { Badge } from "@repo/ui/components/badge"; +import { Card, CardContent, CardHeader } from "@repo/ui/components/card"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@repo/ui/components/drawer"; import { Sheet, SheetContent, SheetHeader, SheetTitle, -} from "@repo/ui/components/sheet" +} from "@repo/ui/components/sheet"; +import { colors } from "@repo/ui/memory-graph/constants"; +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Label1Regular } from "@ui/text/label/label-1-regular"; import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle, -} from "@repo/ui/components/drawer" -import { colors } from "@repo/ui/memory-graph/constants" -import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" -import { Label1Regular } from "@ui/text/label/label-1-regular" -import { Brain, Calendar, ExternalLink, FileText, Sparkles } from "lucide-react" -import { useVirtualizer } from "@tanstack/react-virtual" -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" -import type { z } from "zod" -import { analytics } from "@/lib/analytics" -import useResizeObserver from "@/hooks/use-resize-observer" -import { useIsMobile } from "@hooks/use-mobile" -import { cn } from "@lib/utils" - -type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema> -type DocumentWithMemories = DocumentsResponse["documents"][0] -type MemoryEntry = DocumentWithMemories["memoryEntries"][0] + Brain, + Calendar, + ExternalLink, + FileText, + Sparkles, +} from "lucide-react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { z } from "zod"; +import useResizeObserver from "@/hooks/use-resize-observer"; +import { analytics } from "@/lib/analytics"; + +type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>; +type DocumentWithMemories = DocumentsResponse["documents"][0]; +type MemoryEntry = DocumentWithMemories["memoryEntries"][0]; interface MemoryListViewProps { - children?: React.ReactNode - documents: DocumentWithMemories[] - isLoading: boolean - isLoadingMore: boolean - error: Error | null - totalLoaded: number - hasMore: boolean - loadMoreDocuments: () => Promise<void> + children?: React.ReactNode; + documents: DocumentWithMemories[]; + isLoading: boolean; + isLoadingMore: boolean; + error: Error | null; + totalLoaded: number; + hasMore: boolean; + loadMoreDocuments: () => Promise<void>; } const GreetingMessage = memo(() => { const getGreeting = () => { - const hour = new Date().getHours() - if (hour < 12) return "Good morning" - if (hour < 17) return "Good afternoon" - return "Good evening" - } + const hour = new Date().getHours(); + if (hour < 12) return "Good morning"; + if (hour < 17) return "Good afternoon"; + return "Good evening"; + }; return ( <div className="flex items-center gap-3 mb-3 px-4 md:mb-6 md:mt-3"> <div> - <h1 + <h1 className="text-lg md:text-xl font-semibold" style={{ color: colors.text.primary }} > {getGreeting()}! </h1> - <p - className="text-xs md:text-sm" - style={{ color: colors.text.muted }} - > + <p className="text-xs md:text-sm" style={{ color: colors.text.muted }}> Welcome back to your memory collection </p> </div> </div> - ) -}) + ); +}); const formatDate = (date: string | Date) => { - const dateObj = new Date(date) - const now = new Date() - const currentYear = now.getFullYear() - const dateYear = dateObj.getFullYear() - - const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - const month = monthNames[dateObj.getMonth()] - const day = dateObj.getDate() - + const dateObj = new Date(date); + const now = new Date(); + const currentYear = now.getFullYear(); + const dateYear = dateObj.getFullYear(); + + const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + const month = monthNames[dateObj.getMonth()]; + const day = dateObj.getDate(); + const getOrdinalSuffix = (n: number) => { - const s = ["th", "st", "nd", "rd"] - const v = n % 100 - return n + (s[(v - 20) % 10] || s[v] || s[0]!) - } - - const formattedDay = getOrdinalSuffix(day) - + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]!); + }; + + const formattedDay = getOrdinalSuffix(day); + if (dateYear !== currentYear) { - return `${month} ${formattedDay}, ${dateYear}` + return `${month} ${formattedDay}, ${dateYear}`; } - - return `${month} ${formattedDay}` -} + + return `${month} ${formattedDay}`; +}; const formatDocumentType = (type: string) => { // Special case for PDF - if (type.toLowerCase() === "pdf") return "PDF" + if (type.toLowerCase() === "pdf") return "PDF"; // Replace underscores with spaces and capitalize each word return type .split("_") .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" ") -} + .join(" "); +}; const getDocumentIcon = (type: string, className: string) => { const iconProps = { className, style: { color: colors.text.muted }, - } + }; switch (type) { case "google_doc": - return <GoogleDocs {...iconProps} /> + return <GoogleDocs {...iconProps} />; case "google_sheet": - return <GoogleSheets {...iconProps} /> + return <GoogleSheets {...iconProps} />; case "google_slide": - return <GoogleSlides {...iconProps} /> + return <GoogleSlides {...iconProps} />; case "google_drive": - return <GoogleDrive {...iconProps} /> + return <GoogleDrive {...iconProps} />; case "notion": case "notion_doc": - return <NotionDoc {...iconProps} /> + return <NotionDoc {...iconProps} />; case "word": case "microsoft_word": - return <MicrosoftWord {...iconProps} /> + return <MicrosoftWord {...iconProps} />; case "excel": case "microsoft_excel": - return <MicrosoftExcel {...iconProps} /> + return <MicrosoftExcel {...iconProps} />; case "powerpoint": case "microsoft_powerpoint": - return <MicrosoftPowerpoint {...iconProps} /> + return <MicrosoftPowerpoint {...iconProps} />; case "onenote": case "microsoft_onenote": - return <MicrosoftOneNote {...iconProps} /> + return <MicrosoftOneNote {...iconProps} />; case "onedrive": - return <OneDrive {...iconProps} /> + return <OneDrive {...iconProps} />; case "pdf": - return <PDF {...iconProps} /> + return <PDF {...iconProps} />; default: - return <FileText {...iconProps} /> + return <FileText {...iconProps} />; } -} +}; const getSourceUrl = (document: DocumentWithMemories) => { if (document.type === "google_doc" && document.customId) { - return `https://docs.google.com/document/d/${document.customId}` + return `https://docs.google.com/document/d/${document.customId}`; } if (document.type === "google_sheet" && document.customId) { - return `https://docs.google.com/spreadsheets/d/${document.customId}` + return `https://docs.google.com/spreadsheets/d/${document.customId}`; } if (document.type === "google_slide" && document.customId) { - return `https://docs.google.com/presentation/d/${document.customId}` + return `https://docs.google.com/presentation/d/${document.customId}`; } // Fallback to existing URL for all other document types - return document.url -} + return document.url; +}; const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => { return ( @@ -199,8 +214,9 @@ const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => { }} > <Brain - className={`w-4 h-4 flex-shrink-0 transition-all ${memory.isLatest ? "text-blue-400" : "text-blue-400/50" - }`} + className={`w-4 h-4 flex-shrink-0 transition-all ${ + memory.isLatest ? "text-blue-400" : "text-blue-400/50" + }`} /> </div> <div className="flex-1 space-y-2"> @@ -282,29 +298,29 @@ const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => { </div> </div> </button> - ) -}) + ); +}); const DocumentCard = memo( ({ document, onOpenDetails, }: { - document: DocumentWithMemories - onOpenDetails: (document: DocumentWithMemories) => void + document: DocumentWithMemories; + onOpenDetails: (document: DocumentWithMemories) => void; }) => { - const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten) + const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten); const forgottenMemories = document.memoryEntries.filter( (m) => m.isForgotten, - ) + ); return ( <Card className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden border-0 gap-2 md:w-full" onClick={() => { - analytics.documentCardClicked() - onOpenDetails(document) - }} + analytics.documentCardClicked(); + onOpenDetails(document); + }} style={{ backgroundColor: colors.document.primary, }} @@ -313,15 +329,22 @@ const DocumentCard = memo( <div className="flex items-center justify-between gap-2"> <div className="flex items-center gap-1"> {getDocumentIcon(document.type, "w-4 h-4 flex-shrink-0")} - <p className={cn("text-sm font-medium line-clamp-1", document.url ? "max-w-[190px]" : "max-w-[200px]")}>{document.title || "Untitled Document"}</p> + <p + className={cn( + "text-sm font-medium line-clamp-1", + document.url ? "max-w-[190px]" : "max-w-[200px]", + )} + > + {document.title || "Untitled Document"} + </p> </div> {document.url && ( <button className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded" onClick={(e) => { - e.stopPropagation() - const sourceUrl = getSourceUrl(document) - window.open(sourceUrl ?? undefined, "_blank") + e.stopPropagation(); + const sourceUrl = getSourceUrl(document); + window.open(sourceUrl ?? undefined, "_blank"); }} style={{ backgroundColor: "rgba(255, 255, 255, 0.05)", @@ -375,9 +398,9 @@ const DocumentCard = memo( </div> </CardContent> </Card> - ) + ); }, -) +); const DocumentDetailSheet = memo( ({ @@ -386,20 +409,24 @@ const DocumentDetailSheet = memo( onClose, isMobile, }: { - document: DocumentWithMemories | null - isOpen: boolean - onClose: () => void - isMobile: boolean + document: DocumentWithMemories | null; + isOpen: boolean; + onClose: () => void; + isMobile: boolean; }) => { - if (!document) return null + if (!document) return null; - const [isSummaryExpanded, setIsSummaryExpanded] = useState(false) - const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten) + const [isSummaryExpanded, setIsSummaryExpanded] = useState(false); + const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten); const forgottenMemories = document.memoryEntries.filter( (m) => m.isForgotten, - ) + ); - const HeaderContent = ({ TitleComponent }: { TitleComponent: typeof SheetTitle | typeof DrawerTitle }) => ( + const HeaderContent = ({ + TitleComponent, + }: { + TitleComponent: typeof SheetTitle | typeof DrawerTitle; + }) => ( <div className="flex items-start justify-between gap-2"> <div className="flex items-start gap-3 flex-1"> <div @@ -421,17 +448,15 @@ const DocumentDetailSheet = memo( > <span>{formatDocumentType(document.type)}</span> <span>•</span> - <span> - {formatDate(document.createdAt)} - </span> + <span>{formatDate(document.createdAt)}</span> {document.url && ( <> <span>•</span> <button className="flex items-center gap-1 transition-all hover:gap-2" onClick={() => { - const sourceUrl = getSourceUrl(document) - window.open(sourceUrl ?? undefined, "_blank") + const sourceUrl = getSourceUrl(document); + window.open(sourceUrl ?? undefined, "_blank"); }} style={{ color: colors.accent.primary }} type="button" @@ -445,12 +470,12 @@ const DocumentDetailSheet = memo( </div> </div> </div> - ) + ); const SummarySection = () => { - if (!document.summary) return null + if (!document.summary) return null; - const shouldShowToggle = document.summary.length > 200 // Show toggle for longer summaries + const shouldShowToggle = document.summary.length > 200; // Show toggle for longer summaries return ( <div @@ -460,8 +485,8 @@ const DocumentDetailSheet = memo( border: "1px solid rgba(255, 255, 255, 0.08)", }} > - <p - className={`text-sm ${!isSummaryExpanded ? 'line-clamp-3' : ''}`} + <p + className={`text-sm ${!isSummaryExpanded ? "line-clamp-3" : ""}`} style={{ color: colors.text.muted }} > {document.summary} @@ -473,12 +498,12 @@ const DocumentDetailSheet = memo( style={{ color: colors.accent.primary }} type="button" > - {isSummaryExpanded ? 'Show less' : 'Show more'} + {isSummaryExpanded ? "Show less" : "Show more"} </button> )} </div> - ) - } + ); + }; const MemoryContent = () => ( <div className="p-6 space-y-6"> @@ -529,26 +554,25 @@ const DocumentDetailSheet = memo( </div> )} - {activeMemories.length === 0 && - forgottenMemories.length === 0 && ( - <div - className="text-center py-12 rounded-lg" - style={{ - backgroundColor: "rgba(255, 255, 255, 0.02)", - border: "1px solid rgba(255, 255, 255, 0.08)", - }} - > - <Brain - className="w-12 h-12 mx-auto mb-4 opacity-30" - style={{ color: colors.text.muted }} - /> - <p style={{ color: colors.text.muted }}> - No memories found for this document - </p> - </div> - )} + {activeMemories.length === 0 && forgottenMemories.length === 0 && ( + <div + className="text-center py-12 rounded-lg" + style={{ + backgroundColor: "rgba(255, 255, 255, 0.02)", + border: "1px solid rgba(255, 255, 255, 0.08)", + }} + > + <Brain + className="w-12 h-12 mx-auto mb-4 opacity-30" + style={{ color: colors.text.muted }} + /> + <p style={{ color: colors.text.muted }}> + No memories found for this document + </p> + </div> + )} </div> - ) + ); if (isMobile) { return ( @@ -582,7 +606,7 @@ const DocumentDetailSheet = memo( </div> </DrawerContent> </Drawer> - ) + ); } return ( @@ -616,9 +640,9 @@ const DocumentDetailSheet = memo( </div> </SheetContent> </Sheet> - ) + ); }, -) +); export const MemoryListView = ({ children, @@ -629,26 +653,29 @@ export const MemoryListView = ({ hasMore, loadMoreDocuments, }: MemoryListViewProps) => { - const [selectedSpace, _] = useState<string>("all") + const [selectedSpace, _] = useState<string>("all"); const [selectedDocument, setSelectedDocument] = - useState<DocumentWithMemories | null>(null) - const [isDetailOpen, setIsDetailOpen] = useState(false) - const parentRef = useRef<HTMLDivElement>(null) - const containerRef = useRef<HTMLDivElement>(null) - const isMobile = useIsMobile() - - const gap = 14 - + useState<DocumentWithMemories | null>(null); + const [isDetailOpen, setIsDetailOpen] = useState(false); + const parentRef = useRef<HTMLDivElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const isMobile = useIsMobile(); + + const gap = 14; + const { width: containerWidth } = useResizeObserver(containerRef); - const columnWidth = isMobile ? containerWidth : 320 - const columns = Math.max(1, Math.floor((containerWidth + gap) / (columnWidth + gap))) + const columnWidth = isMobile ? containerWidth : 320; + const columns = Math.max( + 1, + Math.floor((containerWidth + gap) / (columnWidth + gap)), + ); // Filter documents based on selected space const filteredDocuments = useMemo(() => { - if (!documents) return [] + if (!documents) return []; if (selectedSpace === "all") { - return documents + return documents; } return documents @@ -659,46 +686,52 @@ export const MemoryListView = ({ (memory.spaceContainerTag ?? memory.spaceId) === selectedSpace, ), })) - .filter((doc) => doc.memoryEntries.length > 0) - }, [documents, selectedSpace]) + .filter((doc) => doc.memoryEntries.length > 0); + }, [documents, selectedSpace]); const handleOpenDetails = useCallback((document: DocumentWithMemories) => { - analytics.memoryDetailOpened() - setSelectedDocument(document) - setIsDetailOpen(true) - }, []) + analytics.memoryDetailOpened(); + setSelectedDocument(document); + setIsDetailOpen(true); + }, []); const handleCloseDetails = useCallback(() => { - setIsDetailOpen(false) - setTimeout(() => setSelectedDocument(null), 300) - }, []) - + setIsDetailOpen(false); + setTimeout(() => setSelectedDocument(null), 300); + }, []); + const virtualItems = useMemo(() => { - const items = [] + const items = []; for (let i = 0; i < filteredDocuments.length; i += columns) { - items.push(filteredDocuments.slice(i, i + columns)) + items.push(filteredDocuments.slice(i, i + columns)); } - return items - }, [filteredDocuments, columns]) + return items; + }, [filteredDocuments, columns]); const virtualizer = useVirtualizer({ count: virtualItems.length, getScrollElement: () => parentRef.current, overscan: 5, estimateSize: () => 200, - }) + }); useEffect(() => { - const [lastItem] = [...virtualizer.getVirtualItems()].reverse() - + const [lastItem] = [...virtualizer.getVirtualItems()].reverse(); + if (!lastItem || !hasMore || isLoadingMore) { - return + return; } if (lastItem.index >= virtualItems.length - 1) { - loadMoreDocuments() + loadMoreDocuments(); } - }, [hasMore, isLoadingMore, loadMoreDocuments, virtualizer.getVirtualItems(), virtualItems.length]) + }, [ + hasMore, + isLoadingMore, + loadMoreDocuments, + virtualizer.getVirtualItems(), + virtualItems.length, + ]); // Always render with consistent structure return ( @@ -738,19 +771,21 @@ export const MemoryListView = ({ {children} </div> ) : ( - <div + <div ref={parentRef} className="h-full overflow-auto mt-20 custom-scrollbar" > <GreetingMessage /> - + <div className="w-full relative" - style={{ height: `${virtualizer.getTotalSize() + (virtualItems.length * gap)}px` }} + style={{ + height: `${virtualizer.getTotalSize() + virtualItems.length * gap}px`, + }} > {virtualizer.getVirtualItems().map((virtualRow) => { - const rowItems = virtualItems[virtualRow.index] - if (!rowItems) return null + const rowItems = virtualItems[virtualRow.index]; + if (!rowItems) return null; return ( <div @@ -758,25 +793,30 @@ export const MemoryListView = ({ data-index={virtualRow.index} ref={virtualizer.measureElement} className="absolute top-0 left-0 w-full" - style={{ transform: `translateY(${virtualRow.start + (virtualRow.index * gap)}px)` }} + style={{ + transform: `translateY(${virtualRow.start + virtualRow.index * gap}px)`, + }} > <div className="grid justify-start" - style={{ gridTemplateColumns: `repeat(${columns}, ${columnWidth}px)`, gap: `${gap}px` }} + style={{ + gridTemplateColumns: `repeat(${columns}, ${columnWidth}px)`, + gap: `${gap}px`, + }} > - {rowItems.map((document) => ( + {rowItems.map((document, columnIndex) => ( <DocumentCard - key={document.id} + key={`${document.id}-${virtualRow.index}-${columnIndex}`} document={document} onOpenDetails={handleOpenDetails} /> ))} </div> </div> - ) + ); })} </div> - + {isLoadingMore && ( <div className="py-8 flex items-center justify-center"> <div className="flex items-center gap-2"> @@ -798,5 +838,5 @@ export const MemoryListView = ({ isMobile={isMobile} /> </> - ) -} + ); +}; diff --git a/apps/web/components/menu.tsx b/apps/web/components/menu.tsx index 622b94b1..0501603f 100644 --- a/apps/web/components/menu.tsx +++ b/apps/web/components/menu.tsx @@ -1,90 +1,85 @@ -"use client" +"use client"; -import { useIsMobile } from "@hooks/use-mobile" +import { useIsMobile } from "@hooks/use-mobile"; import { fetchConsumerProProduct, fetchMemoriesFeature, -} from "@repo/lib/queries" -import { Button } from "@repo/ui/components/button" -import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold" -import { GlassMenuEffect } from "@ui/other/glass-effect" -import { useCustomer } from "autumn-js/react" -import { - MessageSquareMore, - Plus, - User, - X, -} from "lucide-react" -import { AnimatePresence, LayoutGroup, motion } from "motion/react" -import { useEffect, useState } from "react" -import { useMobilePanel } from "@/lib/mobile-panel-context" -import { TOUR_STEP_IDS } from "@/lib/tour-constants" -import { ProjectSelector } from "./project-selector" -import { useTour } from "./tour" -import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory" -import { MCPView } from "./views/mcp" -import { ProfileView } from "./views/profile" -import { useChatOpen } from "@/stores" -import { Drawer } from "vaul" +} from "@repo/lib/queries"; +import { Button } from "@repo/ui/components/button"; +import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold"; +import { GlassMenuEffect } from "@ui/other/glass-effect"; +import { useCustomer } from "autumn-js/react"; +import { MessageSquareMore, Plus, User, X } from "lucide-react"; +import { AnimatePresence, LayoutGroup, motion } from "motion/react"; +import { useEffect, useState } from "react"; +import { Drawer } from "vaul"; +import { useMobilePanel } from "@/lib/mobile-panel-context"; +import { TOUR_STEP_IDS } from "@/lib/tour-constants"; +import { useChatOpen } from "@/stores"; +import { ProjectSelector } from "./project-selector"; +import { useTour } from "./tour"; +import { AddMemoryExpandedView, AddMemoryView } from "./views/add-memory"; +import { MCPView } from "./views/mcp"; +import { ProfileView } from "./views/profile"; const MCPIcon = ({ className }: { className?: string }) => { return ( - <svg + <svg className={className} - fill="currentColor" - fillRule="evenodd" - viewBox="0 0 24 24" + fill="currentColor" + fillRule="evenodd" + viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" > <title>ModelContextProtocol</title> <path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" /> <path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" /> </svg> - ) -} + ); +}; function Menu({ id }: { id?: string }) { - const [isHovered, setIsHovered] = useState(false) + const [isHovered, setIsHovered] = useState(false); const [expandedView, setExpandedView] = useState< "addUrl" | "mcp" | "projects" | "profile" | null - >(null) - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) - const [isCollapsing, setIsCollapsing] = useState(false) - const [showAddMemoryView, setShowAddMemoryView] = useState(false) - const isMobile = useIsMobile() - const { activePanel, setActivePanel } = useMobilePanel() - const { setMenuExpanded } = useTour() - const autumn = useCustomer() - const { setIsOpen } = useChatOpen() + >(null); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isCollapsing, setIsCollapsing] = useState(false); + const [showAddMemoryView, setShowAddMemoryView] = useState(false); + const isMobile = useIsMobile(); + const { activePanel, setActivePanel } = useMobilePanel(); + const { setMenuExpanded } = useTour(); + const autumn = useCustomer(); + const { setIsOpen } = useChatOpen(); - const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any) + const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any); - const memoriesUsed = memoriesCheck?.usage ?? 0 - const memoriesLimit = memoriesCheck?.included_usage ?? 0 + const memoriesUsed = memoriesCheck?.usage ?? 0; + const memoriesLimit = memoriesCheck?.included_usage ?? 0; - const { data: proCheck } = fetchConsumerProProduct(autumn as any) + const { data: proCheck } = fetchConsumerProProduct(autumn as any); useEffect(() => { if (memoriesCheck) { - console.log({ memoriesCheck }) + console.log({ memoriesCheck }); } if (proCheck) { - console.log({ proCheck }) + console.log({ proCheck }); } - }, [memoriesCheck, proCheck]) + }, [memoriesCheck, proCheck]); - const isProUser = proCheck?.allowed ?? false + const isProUser = proCheck?.allowed ?? false; const shouldShowLimitWarning = - !isProUser && memoriesUsed >= memoriesLimit * 0.8 + !isProUser && memoriesUsed >= memoriesLimit * 0.8; // Map menu item keys to tour IDs const menuItemTourIds: Record<string, string> = { addUrl: TOUR_STEP_IDS.MENU_ADD_MEMORY, projects: TOUR_STEP_IDS.MENU_PROJECTS, mcp: TOUR_STEP_IDS.MENU_MCP, - } + }; const menuItems = [ { @@ -111,55 +106,56 @@ function Menu({ id }: { id?: string }) { key: "profile" as const, disabled: false, }, - ] + ]; const handleMenuItemClick = ( key: "chat" | "addUrl" | "mcp" | "projects" | "profile", ) => { if (key === "chat") { - setIsOpen(true) - setIsMobileMenuOpen(false) + setIsOpen(true); + setIsMobileMenuOpen(false); if (isMobile) { - setActivePanel("chat") + setActivePanel("chat"); } } else { if (expandedView === key) { - setIsCollapsing(true) - setExpandedView(null) + setIsCollapsing(true); + setExpandedView(null); } else if (key === "addUrl") { - setShowAddMemoryView(true) - setExpandedView(null) + setShowAddMemoryView(true); + setExpandedView(null); } else { - setExpandedView(key) + setExpandedView(key); } if (isMobile) { - setActivePanel("menu") + setActivePanel("menu"); } } - } + }; // Watch for active panel changes on mobile useEffect(() => { if (isMobile && activePanel !== "menu" && activePanel !== null) { // Another panel became active, close the menu - setIsMobileMenuOpen(false) - setExpandedView(null) + setIsMobileMenuOpen(false); + setExpandedView(null); } - }, [isMobile, activePanel]) + }, [isMobile, activePanel]); // Notify tour provider about expansion state changes useEffect(() => { const isExpanded = isMobile ? isMobileMenuOpen || !!expandedView - : isHovered || !!expandedView - setMenuExpanded(isExpanded) - }, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded]) + : isHovered || !!expandedView; + setMenuExpanded(isExpanded); + }, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded]); // Calculate width based on state - const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56 + const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56; // Dynamic z-index for mobile based on active panel - const mobileZIndex = isMobile && activePanel === "menu" ? "z-[70]" : "z-[100]" + const mobileZIndex = + isMobile && activePanel === "menu" ? "z-[70]" : "z-[100]"; return ( <> @@ -293,11 +289,11 @@ function Menu({ id }: { id?: string }) { {item.text} {/* Show warning indicator for Add Memory when limits approached */} {shouldShowLimitWarning && - item.key === "addUrl" && ( - <span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded"> - {memoriesLimit - memoriesUsed} left - </span> - )} + item.key === "addUrl" && ( + <span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded"> + {memoriesLimit - memoriesUsed} left + </span> + )} </motion.p> </motion.button> {index === 0 && ( @@ -374,8 +370,8 @@ function Menu({ id }: { id?: string }) { <Button className="text-white/70 hover:text-white transition-colors duration-200" onClick={() => { - setIsCollapsing(true) - setExpandedView(null) + setIsCollapsing(true); + setExpandedView(null); }} size="icon" variant="ghost" @@ -408,13 +404,13 @@ function Menu({ id }: { id?: string }) { {/* Mobile Menu with Vaul Drawer */} {isMobile && ( - <Drawer.Root + <Drawer.Root open={isMobileMenuOpen || !!expandedView} onOpenChange={(open) => { if (!open) { - setIsMobileMenuOpen(false) - setExpandedView(null) - setActivePanel(null) + setIsMobileMenuOpen(false); + setExpandedView(null); + setActivePanel(null); } }} > @@ -427,8 +423,8 @@ function Menu({ id }: { id?: string }) { className="w-14 h-14 flex items-center justify-center text-white rounded-full shadow-2xl" initial={{ scale: 0.8, opacity: 0 }} onClick={() => { - setIsMobileMenuOpen(true) - setActivePanel("menu") + setIsMobileMenuOpen(true); + setActivePanel("menu"); }} transition={{ duration: 0.3, @@ -491,113 +487,120 @@ function Menu({ id }: { id?: string }) { key="menu-items-mobile" layout > - <motion.div - animate={{ opacity: 1, y: 0 }} - initial={{ opacity: 0, y: -10 }} - transition={{ delay: 0.08 }} - > - <ProjectSelector /> - </motion.div> + <motion.div + animate={{ opacity: 1, y: 0 }} + initial={{ opacity: 0, y: -10 }} + transition={{ delay: 0.08 }} + > + <ProjectSelector /> + </motion.div> - {/* Menu Items */} - <div className="flex flex-col gap-3"> - {menuItems.map((item, index) => ( - <div key={item.key}> - <motion.button - animate={{ - opacity: 1, - y: 0, - transition: { - delay: 0.1 + index * 0.05, - duration: 0.3, - ease: "easeOut", - }, - }} - className="flex w-full items-center gap-3 px-2 py-2 text-white/90 hover:text-white hover:bg-white/10 rounded-lg cursor-pointer relative" - id={menuItemTourIds[item.key]} - initial={{ opacity: 0, y: 10 }} - layout - onClick={() => { - handleMenuItemClick(item.key) - if (item.key !== "mcp" && item.key !== "profile") { - setIsMobileMenuOpen(false) - } - }} - type="button" - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - <item.icon className="h-5 w-5 drop-shadow-lg flex-shrink-0" /> - <span className="drop-shadow-lg text-sm font-medium flex-1 text-left"> - {item.text} - </span> - {/* Show warning indicator for Add Memory when limits approached */} - {shouldShowLimitWarning && - item.key === "addUrl" && ( - <span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded"> - {memoriesLimit - memoriesUsed} left - </span> - )} - </motion.button> - {/* Add horizontal line after first item */} - {index === 0 && ( - <motion.div + {/* Menu Items */} + <div className="flex flex-col gap-3"> + {menuItems.map((item, index) => ( + <div key={item.key}> + <motion.button animate={{ opacity: 1, - scaleX: 1, + y: 0, + transition: { + delay: 0.1 + index * 0.05, + duration: 0.3, + ease: "easeOut", + }, }} - className="w-full h-px bg-white/20 mt-2 origin-left" - initial={{ opacity: 0, scaleX: 0 }} - transition={{ - duration: 0.3, - delay: 0.15 + index * 0.05, - ease: [0.4, 0, 0.2, 1], + className="flex w-full items-center gap-3 px-2 py-2 text-white/90 hover:text-white hover:bg-white/10 rounded-lg cursor-pointer relative" + id={menuItemTourIds[item.key]} + initial={{ opacity: 0, y: 10 }} + layout + onClick={() => { + handleMenuItemClick(item.key); + if ( + item.key !== "mcp" && + item.key !== "profile" + ) { + setIsMobileMenuOpen(false); + } }} - /> + type="button" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + <item.icon className="h-5 w-5 drop-shadow-lg flex-shrink-0" /> + <span className="drop-shadow-lg text-sm font-medium flex-1 text-left"> + {item.text} + </span> + {/* Show warning indicator for Add Memory when limits approached */} + {shouldShowLimitWarning && + item.key === "addUrl" && ( + <span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded"> + {memoriesLimit - memoriesUsed} left + </span> + )} + </motion.button> + {/* Add horizontal line after first item */} + {index === 0 && ( + <motion.div + animate={{ + opacity: 1, + scaleX: 1, + }} + className="w-full h-px bg-white/20 mt-2 origin-left" + initial={{ opacity: 0, scaleX: 0 }} + transition={{ + duration: 0.3, + delay: 0.15 + index * 0.05, + ease: [0.4, 0, 0.2, 1], + }} + /> + )} + </div> + ))} + </div> + </motion.div> + ) : ( + <motion.div + animate={{ opacity: 1 }} + className="w-full p-2 flex flex-col" + exit={{ opacity: 0 }} + initial={{ opacity: 0 }} + key="expanded-view-mobile" + layout + > + <div className="flex-1"> + <motion.div + className="mb-4 flex items-center justify-between" + layout + > + <HeadingH2Bold className="text-white"> + {expandedView === "addUrl" && "Add Memory"} + {expandedView === "mcp" && + "Model Context Protocol"} + {expandedView === "profile" && "Profile"} + </HeadingH2Bold> + <Button + className="text-white/70 hover:text-white transition-colors duration-200" + onClick={() => { + setIsCollapsing(true); + setExpandedView(null); + }} + size="icon" + variant="ghost" + > + <X className="h-5 w-5" /> + </Button> + </motion.div> + <div className="max-h-[60vh] overflow-y-auto pr-1"> + {expandedView === "addUrl" && ( + <AddMemoryExpandedView /> )} + {expandedView === "mcp" && <MCPView />} + {expandedView === "profile" && <ProfileView />} </div> - ))} - </div> - </motion.div> - ) : ( - <motion.div - animate={{ opacity: 1 }} - className="w-full p-2 flex flex-col" - exit={{ opacity: 0 }} - initial={{ opacity: 0 }} - key="expanded-view-mobile" - layout - > - <div className="flex-1"> - <motion.div className="mb-4 flex items-center justify-between" layout> - <HeadingH2Bold className="text-white"> - {expandedView === "addUrl" && "Add Memory"} - {expandedView === "mcp" && "Model Context Protocol"} - {expandedView === "profile" && "Profile"} - </HeadingH2Bold> - <Button - className="text-white/70 hover:text-white transition-colors duration-200" - onClick={() => { - setIsCollapsing(true) - setExpandedView(null) - }} - size="icon" - variant="ghost" - > - <X className="h-5 w-5" /> - </Button> - </motion.div> - <div className="max-h-[60vh] overflow-y-auto pr-1"> - {expandedView === "addUrl" && ( - <AddMemoryExpandedView /> - )} - {expandedView === "mcp" && <MCPView />} - {expandedView === "profile" && <ProfileView />} </div> - </div> - </motion.div> - )} - </AnimatePresence> + </motion.div> + )} + </AnimatePresence> </div> </div> </Drawer.Content> @@ -612,7 +615,7 @@ function Menu({ id }: { id?: string }) { /> )} </> - ) + ); } -export default Menu +export default Menu; diff --git a/apps/web/components/project-selector.tsx b/apps/web/components/project-selector.tsx index bb62d8ca..a592c474 100644 --- a/apps/web/components/project-selector.tsx +++ b/apps/web/components/project-selector.tsx @@ -1,21 +1,8 @@ -"use client" +"use client"; -import { $fetch } from "@repo/lib/api" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { ChevronDown, FolderIcon, Plus, Trash2, Loader2, MoreVertical, MoreHorizontal } from "lucide-react" -import { motion, AnimatePresence } from "motion/react" -import { useState } from "react" -import { toast } from "sonner" -import { CreateProjectDialog } from "./create-project-dialog" -import { useProject } from "@/stores" -import { useProjectName } from "@/hooks/use-project-name" -import { useProjectMutations } from "@/hooks/use-project-mutations" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@repo/ui/components/dropdown-menu" +import { $fetch } from "@repo/lib/api"; +import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"; +import { Button } from "@repo/ui/components/button"; import { Dialog, DialogContent, @@ -23,96 +10,121 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@repo/ui/components/dialog" -import { Button } from "@repo/ui/components/button" -import { Label } from "@repo/ui/components/label" +} from "@repo/ui/components/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@repo/ui/components/dropdown-menu"; +import { Label } from "@repo/ui/components/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@repo/ui/components/select" -import { DEFAULT_PROJECT_ID } from "@repo/lib/constants" +} from "@repo/ui/components/select"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ChevronDown, + FolderIcon, + Loader2, + MoreHorizontal, + MoreVertical, + Plus, + Trash2, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useProjectMutations } from "@/hooks/use-project-mutations"; +import { useProjectName } from "@/hooks/use-project-name"; +import { useProject } from "@/stores"; +import { CreateProjectDialog } from "./create-project-dialog"; interface Project { - id: string - name: string - containerTag: string - createdAt: string - updatedAt: string - isExperimental?: boolean + id: string; + name: string; + containerTag: string; + createdAt: string; + updatedAt: string; + isExperimental?: boolean; } export function ProjectSelector() { - const queryClient = useQueryClient() - const [isOpen, setIsOpen] = useState(false) - const [showCreateDialog, setShowCreateDialog] = useState(false) - const { selectedProject } = useProject() - const projectName = useProjectName() - const { switchProject, deleteProjectMutation } = useProjectMutations() + const queryClient = useQueryClient(); + const [isOpen, setIsOpen] = useState(false); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const { selectedProject } = useProject(); + const projectName = useProjectName(); + const { switchProject, deleteProjectMutation } = useProjectMutations(); const [deleteDialog, setDeleteDialog] = useState<{ - open: boolean - project: null | { id: string; name: string; containerTag: string } - action: "move" | "delete" - targetProjectId: string + open: boolean; + project: null | { id: string; name: string; containerTag: string }; + action: "move" | "delete"; + targetProjectId: string; }>({ open: false, project: null, action: "move", targetProjectId: DEFAULT_PROJECT_ID, - }) + }); const [expDialog, setExpDialog] = useState<{ - open: boolean - projectId: string + open: boolean; + projectId: string; }>({ open: false, projectId: "", - }) + }); const { data: projects = [], isLoading } = useQuery({ queryKey: ["projects"], queryFn: async () => { - const response = await $fetch("@get/projects") + const response = await $fetch("@get/projects"); if (response.error) { - throw new Error(response.error?.message || "Failed to load projects") + throw new Error(response.error?.message || "Failed to load projects"); } - return response.data?.projects || [] + return response.data?.projects || []; }, staleTime: 30 * 1000, - }) + }); const enableExperimentalMutation = useMutation({ mutationFn: async (projectId: string) => { - const response = await $fetch(`@post/projects/${projectId}/enable-experimental`) + const response = await $fetch( + `@post/projects/${projectId}/enable-experimental`, + ); if (response.error) { - throw new Error(response.error?.message || "Failed to enable experimental mode") + throw new Error( + response.error?.message || "Failed to enable experimental mode", + ); } - return response.data + return response.data; }, onSuccess: () => { - toast.success("Experimental mode enabled for project") - queryClient.invalidateQueries({ queryKey: ["projects"] }) - setExpDialog({ open: false, projectId: "" }) + toast.success("Experimental mode enabled for project"); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + setExpDialog({ open: false, projectId: "" }); }, onError: (error) => { toast.error("Failed to enable experimental mode", { description: error instanceof Error ? error.message : "Unknown error", - }) + }); }, - }) + }); const handleProjectSelect = (containerTag: string) => { - switchProject(containerTag) - setIsOpen(false) - } + switchProject(containerTag); + setIsOpen(false); + }; const handleCreateNewProject = () => { - setIsOpen(false) - setShowCreateDialog(true) - } + setIsOpen(false); + setShowCreateDialog(true); + }; return ( <div className="relative"> @@ -164,7 +176,9 @@ export function ProjectSelector() { > <div className="flex items-center gap-2"> <FolderIcon className="h-3.5 w-3.5 text-white/70" /> - <span className="text-xs font-medium text-white">Default</span> + <span className="text-xs font-medium text-white"> + Default + </span> </div> </motion.div> @@ -183,9 +197,11 @@ export function ProjectSelector() { animate={{ opacity: 1, x: 0 }} transition={{ delay: index * 0.03 }} > - <div + <div className="flex items-center gap-2 flex-1 cursor-pointer" - onClick={() => handleProjectSelect(project.containerTag)} + onClick={() => + handleProjectSelect(project.containerTag) + } > <FolderIcon className="h-3.5 w-3.5 text-white/70" /> <span className="text-xs font-medium text-white truncate max-w-32"> @@ -214,12 +230,12 @@ export function ProjectSelector() { <DropdownMenuItem className="text-blue-400 hover:text-blue-300 cursor-pointer text-xs" onClick={(e) => { - e.stopPropagation() + e.stopPropagation(); setExpDialog({ open: true, projectId: project.id, - }) - setIsOpen(false) + }); + setIsOpen(false); }} > <div className="h-3 w-3 mr-2 rounded border border-blue-400" /> @@ -238,7 +254,7 @@ export function ProjectSelector() { <DropdownMenuItem className="text-red-400 hover:text-red-300 cursor-pointer text-xs" onClick={(e) => { - e.stopPropagation() + e.stopPropagation(); setDeleteDialog({ open: true, project: { @@ -248,8 +264,8 @@ export function ProjectSelector() { }, action: "move", targetProjectId: "", - }) - setIsOpen(false) + }); + setIsOpen(false); }} > <Trash2 className="h-3 w-3 mr-2" /> @@ -270,7 +286,9 @@ export function ProjectSelector() { transition={{ delay: (projects.length + 1) * 0.03 }} > <Plus className="h-3.5 w-3.5 text-white/70" /> - <span className="text-xs font-medium text-white/80">New Project</span> + <span className="text-xs font-medium text-white/80"> + New Project + </span> </motion.div> </div> </motion.div> @@ -278,9 +296,9 @@ export function ProjectSelector() { )} </AnimatePresence> - <CreateProjectDialog - open={showCreateDialog} - onOpenChange={setShowCreateDialog} + <CreateProjectDialog + open={showCreateDialog} + onOpenChange={setShowCreateDialog} /> {/* Delete Project Dialog */} @@ -301,8 +319,8 @@ export function ProjectSelector() { <DialogHeader> <DialogTitle>Delete Project</DialogTitle> <DialogDescription className="text-white/60"> - Are you sure you want to delete "{deleteDialog.project.name}"? - Choose what to do with the documents in this project. + Are you sure you want to delete "{deleteDialog.project.name} + "? Choose what to do with the documents in this project. </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> @@ -432,10 +450,11 @@ export function ProjectSelector() { whileTap={{ scale: 0.95 }} > <Button - className={`${deleteDialog.action === "delete" + className={`${ + deleteDialog.action === "delete" ? "bg-red-600 hover:bg-red-700" : "bg-white/10 hover:bg-white/20" - } text-white border-white/20`} + } text-white border-white/20`} disabled={ deleteProjectMutation.isPending || (deleteDialog.action === "move" && @@ -443,23 +462,26 @@ export function ProjectSelector() { } onClick={() => { if (deleteDialog.project) { - deleteProjectMutation.mutate({ - projectId: deleteDialog.project.id, - action: deleteDialog.action, - targetProjectId: - deleteDialog.action === "move" - ? deleteDialog.targetProjectId - : undefined, - }, { - onSuccess: () => { - setDeleteDialog({ - open: false, - project: null, - action: "move", - targetProjectId: "", - }) - } - }) + deleteProjectMutation.mutate( + { + projectId: deleteDialog.project.id, + action: deleteDialog.action, + targetProjectId: + deleteDialog.action === "move" + ? deleteDialog.targetProjectId + : undefined, + }, + { + onSuccess: () => { + setDeleteDialog({ + open: false, + project: null, + action: "move", + targetProjectId: "", + }); + }, + }, + ); } }} type="button" @@ -562,5 +584,5 @@ export function ProjectSelector() { )} </AnimatePresence> </div> - ) + ); } diff --git a/apps/web/components/referral-upgrade-modal.tsx b/apps/web/components/referral-upgrade-modal.tsx index 2ad1b18a..029bd2ae 100644 --- a/apps/web/components/referral-upgrade-modal.tsx +++ b/apps/web/components/referral-upgrade-modal.tsx @@ -1,23 +1,25 @@ -"use client" +"use client"; -import { useAuth } from "@lib/auth-context" -import { - fetchMemoriesFeature, - fetchSubscriptionStatus, -} from "@lib/queries" -import { Button } from "@repo/ui/components/button" +import { useAuth } from "@lib/auth-context"; +import { fetchMemoriesFeature, fetchSubscriptionStatus } from "@lib/queries"; +import { Button } from "@repo/ui/components/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, - DialogTitle -} from "@repo/ui/components/dialog" -import { Input } from "@repo/ui/components/input" -import { Label } from "@repo/ui/components/label" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/tabs" -import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold" -import { useCustomer } from "autumn-js/react" + DialogTitle, +} from "@repo/ui/components/dialog"; +import { Input } from "@repo/ui/components/input"; +import { Label } from "@repo/ui/components/label"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@repo/ui/components/tabs"; +import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"; +import { useCustomer } from "autumn-js/react"; import { CheckCircle, Copy, @@ -25,26 +27,29 @@ import { Gift, LoaderIcon, Share2, - Users -} from "lucide-react" -import { motion } from "motion/react" -import Link from "next/link" -import { useState } from "react" + Users, +} from "lucide-react"; +import { motion } from "motion/react"; +import Link from "next/link"; +import { useState } from "react"; interface ReferralUpgradeModalProps { - isOpen: boolean - onClose: () => void + isOpen: boolean; + onClose: () => void; } -export function ReferralUpgradeModal({ isOpen, onClose }: ReferralUpgradeModalProps) { - const { user } = useAuth() - const autumn = useCustomer() - const [isLoading, setIsLoading] = useState(false) - const [copied, setCopied] = useState(false) +export function ReferralUpgradeModal({ + isOpen, + onClose, +}: ReferralUpgradeModalProps) { + const { user } = useAuth(); + const autumn = useCustomer(); + const [isLoading, setIsLoading] = useState(false); + const [copied, setCopied] = useState(false); - const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any) - const memoriesUsed = memoriesCheck?.usage ?? 0 - const memoriesLimit = memoriesCheck?.included_usage ?? 0 + const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any); + const memoriesUsed = memoriesCheck?.usage ?? 0; + const memoriesLimit = memoriesCheck?.included_usage ?? 0; // Fetch subscription status const { @@ -52,53 +57,53 @@ export function ReferralUpgradeModal({ isOpen, onClose }: ReferralUpgradeModalPr consumer_pro: null, }, isLoading: isCheckingStatus, - } = fetchSubscriptionStatus(autumn as any) + } = fetchSubscriptionStatus(autumn as any); - const isPro = status.consumer_pro + const isPro = status.consumer_pro; // Handle upgrade const handleUpgrade = async () => { - setIsLoading(true) + setIsLoading(true); try { await autumn.attach({ productId: "consumer_pro", successUrl: "https://app.supermemory.ai/", - }) - window.location.reload() + }); + window.location.reload(); } catch (error) { - console.error(error) - setIsLoading(false) + console.error(error); + setIsLoading(false); } - } + }; // Generate referral link (you'll need to implement this based on your referral system) - const referralLink = `https://app.supermemory.ai/ref/${user?.id || 'user'}` + const referralLink = `https://app.supermemory.ai/ref/${user?.id || "user"}`; const handleCopyReferralLink = async () => { try { - await navigator.clipboard.writeText(referralLink) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + await navigator.clipboard.writeText(referralLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); } catch (error) { - console.error('Failed to copy:', error) + console.error("Failed to copy:", error); } - } + }; const handleShare = async () => { if (navigator.share) { try { await navigator.share({ - title: 'Join Supermemory', - text: 'Check out Supermemory - the best way to organize and search your digital memories!', + title: "Join Supermemory", + text: "Check out Supermemory - the best way to organize and search your digital memories!", url: referralLink, - }) + }); } catch (error) { - console.error('Error sharing:', error) + console.error("Error sharing:", error); } } else { - handleCopyReferralLink() + handleCopyReferralLink(); } - } + }; if (user?.isAnonymous) { return ( @@ -127,7 +132,7 @@ export function ReferralUpgradeModal({ isOpen, onClose }: ReferralUpgradeModalPr </motion.div> </DialogContent> </Dialog> - ) + ); } return ( @@ -149,14 +154,20 @@ export function ReferralUpgradeModal({ isOpen, onClose }: ReferralUpgradeModalPr <div className="bg-white/5 rounded-lg p-4 mb-6"> <div className="flex justify-between items-center mb-2"> <span className="text-sm text-white/70">Current Usage</span> - <span className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`}> + <span + className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`} + > {memoriesUsed} / {memoriesLimit} memories </span> </div> <div className="w-full bg-white/10 rounded-full h-2"> <div className={`h-2 rounded-full transition-all ${ - memoriesUsed >= memoriesLimit ? "bg-red-500" : isPro ? "bg-green-500" : "bg-blue-500" + memoriesUsed >= memoriesLimit + ? "bg-red-500" + : isPro + ? "bg-green-500" + : "bg-blue-500" }`} style={{ width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`, @@ -173,7 +184,10 @@ export function ReferralUpgradeModal({ isOpen, onClose }: ReferralUpgradeModalPr Refer Friends </TabsTrigger> {!isPro && ( - <TabsTrigger value="upgrade" className="flex items-center gap-2"> + <TabsTrigger + value="upgrade" + className="flex items-center gap-2" + > <CreditCard className="w-4 h-4" /> Upgrade Plan </TabsTrigger> @@ -286,5 +300,5 @@ export function ReferralUpgradeModal({ isOpen, onClose }: ReferralUpgradeModalPr </motion.div> </DialogContent> </Dialog> - ) + ); } diff --git a/apps/web/components/spinner.tsx b/apps/web/components/spinner.tsx index aad15e33..6e14eb5b 100644 --- a/apps/web/components/spinner.tsx +++ b/apps/web/components/spinner.tsx @@ -2,7 +2,5 @@ import { cn } from "@lib/utils"; import { Loader2 } from "lucide-react"; export function Spinner({ className }: { className?: string }) { - return ( - <Loader2 className={cn("size-4 animate-spin", className)} /> - ) -}
\ No newline at end of file + return <Loader2 className={cn("size-4 animate-spin", className)} />; +} diff --git a/apps/web/components/text-shimmer.tsx b/apps/web/components/text-shimmer.tsx index bef0e7a9..1825d08c 100644 --- a/apps/web/components/text-shimmer.tsx +++ b/apps/web/components/text-shimmer.tsx @@ -1,57 +1,57 @@ -'use client'; -import React, { useMemo, type JSX } from 'react'; -import { motion } from 'motion/react'; -import { cn } from '@lib/utils'; +"use client"; +import { cn } from "@lib/utils"; +import { motion } from "motion/react"; +import React, { type JSX, useMemo } from "react"; export type TextShimmerProps = { - children: string; - as?: React.ElementType; - className?: string; - duration?: number; - spread?: number; + children: string; + as?: React.ElementType; + className?: string; + duration?: number; + spread?: number; }; function TextShimmerComponent({ - children, - as: Component = 'p', - className, - duration = 2, - spread = 2, + children, + as: Component = "p", + className, + duration = 2, + spread = 2, }: TextShimmerProps) { - const MotionComponent = motion.create( - Component as keyof JSX.IntrinsicElements - ); + const MotionComponent = motion.create( + Component as keyof JSX.IntrinsicElements, + ); - const dynamicSpread = useMemo(() => { - return children.length * spread; - }, [children, spread]); + const dynamicSpread = useMemo(() => { + return children.length * spread; + }, [children, spread]); - return ( - <MotionComponent - className={cn( - 'relative inline-block bg-[length:250%_100%,auto] bg-clip-text', - 'text-transparent [--base-color:#a1a1aa] [--base-gradient-color:#000]', - '[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]', - 'dark:[--base-color:#71717a] dark:[--base-gradient-color:#ffffff] dark:[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]', - className - )} - initial={{ backgroundPosition: '100% center' }} - animate={{ backgroundPosition: '0% center' }} - transition={{ - repeat: Infinity, - duration, - ease: 'linear', - }} - style={ - { - '--spread': `${dynamicSpread}px`, - backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`, - } as React.CSSProperties - } - > - {children} - </MotionComponent> - ); + return ( + <MotionComponent + className={cn( + "relative inline-block bg-[length:250%_100%,auto] bg-clip-text", + "text-transparent [--base-color:#a1a1aa] [--base-gradient-color:#000]", + "[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]", + "dark:[--base-color:#71717a] dark:[--base-gradient-color:#ffffff] dark:[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--base-gradient-color),#0000_calc(50%+var(--spread)))]", + className, + )} + initial={{ backgroundPosition: "100% center" }} + animate={{ backgroundPosition: "0% center" }} + transition={{ + repeat: Infinity, + duration, + ease: "linear", + }} + style={ + { + "--spread": `${dynamicSpread}px`, + backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`, + } as React.CSSProperties + } + > + {children} + </MotionComponent> + ); } export const TextShimmer = React.memo(TextShimmerComponent); diff --git a/apps/web/components/tour.tsx b/apps/web/components/tour.tsx index 981effbc..33919efe 100644 --- a/apps/web/components/tour.tsx +++ b/apps/web/components/tour.tsx @@ -1,54 +1,54 @@ -"use client" +"use client"; -import { Button } from "@repo/ui/components/button" -import { GlassMenuEffect } from "@repo/ui/other/glass-effect" -import { AnimatePresence, motion } from "motion/react" -import * as React from "react" -import { analytics } from "@/lib/analytics" +import { Button } from "@repo/ui/components/button"; +import { GlassMenuEffect } from "@repo/ui/other/glass-effect"; +import { AnimatePresence, motion } from "motion/react"; +import * as React from "react"; +import { analytics } from "@/lib/analytics"; // Types export interface TourStep { - content: React.ReactNode - selectorId: string - position?: "top" | "bottom" | "left" | "right" | "center" - onClickWithinArea?: () => void + content: React.ReactNode; + selectorId: string; + position?: "top" | "bottom" | "left" | "right" | "center"; + onClickWithinArea?: () => void; } interface TourContextType { - currentStep: number - totalSteps: number - nextStep: () => void - previousStep: () => void - endTour: () => void - isActive: boolean - isPaused: boolean - startTour: () => void - setSteps: (steps: TourStep[]) => void - steps: TourStep[] - isTourCompleted: boolean - setIsTourCompleted: (completed: boolean) => void + currentStep: number; + totalSteps: number; + nextStep: () => void; + previousStep: () => void; + endTour: () => void; + isActive: boolean; + isPaused: boolean; + startTour: () => void; + setSteps: (steps: TourStep[]) => void; + steps: TourStep[]; + isTourCompleted: boolean; + setIsTourCompleted: (completed: boolean) => void; // Expansion state tracking - setMenuExpanded: (expanded: boolean) => void - setChatExpanded: (expanded: boolean) => void + setMenuExpanded: (expanded: boolean) => void; + setChatExpanded: (expanded: boolean) => void; } // Context -const TourContext = React.createContext<TourContextType | undefined>(undefined) +const TourContext = React.createContext<TourContextType | undefined>(undefined); export function useTour() { - const context = React.useContext(TourContext) + const context = React.useContext(TourContext); if (!context) { - throw new Error("useTour must be used within a TourProvider") + throw new Error("useTour must be used within a TourProvider"); } - return context + return context; } // Provider interface TourProviderProps { - children: React.ReactNode - onComplete?: () => void - className?: string - isTourCompleted?: boolean + children: React.ReactNode; + onComplete?: () => void; + className?: string; + isTourCompleted?: boolean; } export function TourProvider({ @@ -57,60 +57,61 @@ export function TourProvider({ className, isTourCompleted: initialCompleted = false, }: TourProviderProps) { - const [currentStep, setCurrentStep] = React.useState(-1) - const [steps, setSteps] = React.useState<TourStep[]>([]) - const [isActive, setIsActive] = React.useState(false) - const [isTourCompleted, setIsTourCompleted] = React.useState(initialCompleted) + const [currentStep, setCurrentStep] = React.useState(-1); + const [steps, setSteps] = React.useState<TourStep[]>([]); + const [isActive, setIsActive] = React.useState(false); + const [isTourCompleted, setIsTourCompleted] = + React.useState(initialCompleted); // Track expansion states - const [isMenuExpanded, setIsMenuExpanded] = React.useState(false) - const [isChatExpanded, setIsChatExpanded] = React.useState(false) + const [isMenuExpanded, setIsMenuExpanded] = React.useState(false); + const [isChatExpanded, setIsChatExpanded] = React.useState(false); // Calculate if tour should be paused const isPaused = React.useMemo(() => { - return isActive && (isMenuExpanded || isChatExpanded) - }, [isActive, isMenuExpanded, isChatExpanded]) + return isActive && (isMenuExpanded || isChatExpanded); + }, [isActive, isMenuExpanded, isChatExpanded]); const startTour = React.useCallback(() => { - console.debug("Starting tour with", steps.length, "steps") - analytics.tourStarted() - setCurrentStep(0) - setIsActive(true) - }, [steps]) + console.debug("Starting tour with", steps.length, "steps"); + analytics.tourStarted(); + setCurrentStep(0); + setIsActive(true); + }, [steps]); const endTour = React.useCallback(() => { - setCurrentStep(-1) - setIsActive(false) - setIsTourCompleted(true) // Mark tour as completed when ended/skipped - analytics.tourSkipped() + setCurrentStep(-1); + setIsActive(false); + setIsTourCompleted(true); // Mark tour as completed when ended/skipped + analytics.tourSkipped(); if (onComplete) { - onComplete() + onComplete(); } - }, [onComplete]) + }, [onComplete]); const nextStep = React.useCallback(() => { if (currentStep < steps.length - 1) { - setCurrentStep(currentStep + 1) + setCurrentStep(currentStep + 1); } else { - analytics.tourCompleted() - endTour() - setIsTourCompleted(true) + analytics.tourCompleted(); + endTour(); + setIsTourCompleted(true); } - }, [currentStep, steps.length, endTour]) + }, [currentStep, steps.length, endTour]); const previousStep = React.useCallback(() => { if (currentStep > 0) { - setCurrentStep(currentStep - 1) + setCurrentStep(currentStep - 1); } - }, [currentStep]) + }, [currentStep]); const setMenuExpanded = React.useCallback((expanded: boolean) => { - setIsMenuExpanded(expanded) - }, []) + setIsMenuExpanded(expanded); + }, []); const setChatExpanded = React.useCallback((expanded: boolean) => { - setIsChatExpanded(expanded) - }, []) + setIsChatExpanded(expanded); + }, []); const value = React.useMemo( () => ({ @@ -142,7 +143,7 @@ export function TourProvider({ setMenuExpanded, setChatExpanded, ], - ) + ); return ( <TourContext.Provider value={value}> @@ -164,7 +165,7 @@ export function TourProvider({ </> )} </TourContext.Provider> - ) + ); } // Tour Highlight Component @@ -173,33 +174,33 @@ function TourHighlight({ steps, className, }: { - currentStepIndex: number - steps: TourStep[] - className?: string + currentStepIndex: number; + steps: TourStep[]; + className?: string; }) { - const { nextStep, previousStep, endTour } = useTour() - const [elementRect, setElementRect] = React.useState<DOMRect | null>(null) + const { nextStep, previousStep, endTour } = useTour(); + const [elementRect, setElementRect] = React.useState<DOMRect | null>(null); // Get current step safely const step = currentStepIndex >= 0 && currentStepIndex < steps.length ? steps[currentStepIndex] - : null + : null; React.useEffect(() => { - if (!step) return + if (!step) return; // Use requestAnimationFrame to ensure DOM is ready const rafId = requestAnimationFrame(() => { - const element = document.getElementById(step.selectorId) + const element = document.getElementById(step.selectorId); console.debug( "Looking for element with ID:", step.selectorId, "Found:", !!element, - ) + ); if (element) { - const rect = element.getBoundingClientRect() + const rect = element.getBoundingClientRect(); console.debug("Element rect:", { id: step.selectorId, top: rect.top, @@ -208,34 +209,34 @@ function TourHighlight({ height: rect.height, bottom: rect.bottom, right: rect.right, - }) - setElementRect(rect) + }); + setElementRect(rect); } - }) + }); // Add click listener for onClickWithinArea - let clickHandler: ((e: MouseEvent) => void) | null = null + let clickHandler: ((e: MouseEvent) => void) | null = null; if (step.onClickWithinArea) { - const element = document.getElementById(step.selectorId) + const element = document.getElementById(step.selectorId); if (element) { clickHandler = (e: MouseEvent) => { if (element.contains(e.target as Node)) { - step.onClickWithinArea?.() + step.onClickWithinArea?.(); } - } - document.addEventListener("click", clickHandler) + }; + document.addEventListener("click", clickHandler); } } return () => { - cancelAnimationFrame(rafId) + cancelAnimationFrame(rafId); if (clickHandler) { - document.removeEventListener("click", clickHandler) + document.removeEventListener("click", clickHandler); } - } - }, [step]) + }; + }, [step]); - if (!step) return null + if (!step) return null; // Keep the wrapper mounted but animate the content return ( @@ -277,12 +278,12 @@ function TourHighlight({ ? elementRect.bottom + 8 : step.position === "top" ? elementRect.top - 200 - : elementRect.top + elementRect.height / 2 - 100 + : elementRect.top + elementRect.height / 2 - 100; // Ensure tooltip stays within viewport - const maxTop = window.innerHeight - 250 // Leave space for tooltip height - const minTop = 10 - return Math.max(minTop, Math.min(baseTop, maxTop)) + const maxTop = window.innerHeight - 250; // Leave space for tooltip height + const minTop = 10; + return Math.max(minTop, Math.min(baseTop, maxTop)); })(), left: (() => { const baseLeft = @@ -290,12 +291,12 @@ function TourHighlight({ ? elementRect.right + 8 : step.position === "left" ? elementRect.left - 300 - : elementRect.left + elementRect.width / 2 - 150 + : elementRect.left + elementRect.width / 2 - 150; // Ensure tooltip stays within viewport - const maxLeft = window.innerWidth - 300 // Tooltip width - const minLeft = 10 - return Math.max(minLeft, Math.min(baseLeft, maxLeft)) + const maxLeft = window.innerWidth - 300; // Tooltip width + const minLeft = 10; + return Math.max(minLeft, Math.min(baseLeft, maxLeft)); })(), }} > @@ -342,31 +343,31 @@ function TourHighlight({ </motion.div> )} </AnimatePresence> - ) + ); } // Tour Alert Dialog interface TourAlertDialogProps { - open: boolean - onOpenChange: (open: boolean) => void + open: boolean; + onOpenChange: (open: boolean) => void; } export function TourAlertDialog({ open, onOpenChange }: TourAlertDialogProps) { - const { startTour, setIsTourCompleted } = useTour() + const { startTour, setIsTourCompleted } = useTour(); const handleStart = () => { - console.debug("TourAlertDialog: Starting tour") - onOpenChange(false) - startTour() - } + console.debug("TourAlertDialog: Starting tour"); + onOpenChange(false); + startTour(); + }; const handleSkip = () => { - analytics.tourSkipped() - setIsTourCompleted(true) // Mark tour as completed when skipped - onOpenChange(false) - } + analytics.tourSkipped(); + setIsTourCompleted(true); // Mark tour as completed when skipped + onOpenChange(false); + }; - if (!open) return null + if (!open) return null; return ( <AnimatePresence> @@ -409,5 +410,5 @@ export function TourAlertDialog({ open, onOpenChange }: TourAlertDialogProps) { </div> </motion.div> </AnimatePresence> - ) + ); } diff --git a/apps/web/components/views/add-memory.tsx b/apps/web/components/views/add-memory.tsx deleted file mode 100644 index 744faa35..00000000 --- a/apps/web/components/views/add-memory.tsx +++ /dev/null @@ -1,1425 +0,0 @@ -import { $fetch } from "@lib/api" -import { - fetchConsumerProProduct, - fetchMemoriesFeature, -} from "@repo/lib/queries" -import { Button } from "@repo/ui/components/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@repo/ui/components/dialog" -import { Input } from "@repo/ui/components/input" -import { Label } from "@repo/ui/components/label" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@repo/ui/components/select" -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@repo/ui/components/tabs" -import { Textarea } from "@repo/ui/components/textarea" -import { useForm } from "@tanstack/react-form" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { - Dropzone, - DropzoneContent, - DropzoneEmptyState, -} from "@ui/components/shadcn-io/dropzone" -import { useCustomer } from "autumn-js/react" -import { - Brain, - FileIcon, - Link as LinkIcon, - Loader2, - PlugIcon, - Plus, - UploadIcon, -} from "lucide-react" -import { AnimatePresence, motion } from "motion/react" -import Link from "next/link" -import { useEffect, useState } from "react" -import { toast } from "sonner" -import { z } from "zod" -import { useProject } from "@/stores" -import { ConnectionsTabContent } from "./connections-tab-content" -import { analytics } from "@/lib/analytics" - -// // Processing status component -// function ProcessingStatus({ status }: { status: string }) { -// const statusConfig = { -// queued: { color: "text-yellow-400", label: "Queued", icon: "⏳" }, -// extracting: { color: "text-blue-400", label: "Extracting", icon: "📤" }, -// chunking: { color: "text-indigo-400", label: "Chunking", icon: "✂️" }, -// embedding: { color: "text-purple-400", label: "Embedding", icon: "🧠" }, -// indexing: { color: "text-pink-400", label: "Indexing", icon: "📝" }, -// unknown: { color: "text-gray-400", label: "Processing", icon: "⚙️" }, -// } - -// const config = -// statusConfig[status as keyof typeof statusConfig] || statusConfig.unknown - -// return ( -// <div className={`flex items-center gap-1 text-xs ${config.color}`}> -// <span>{config.icon}</span> -// <span>{config.label}</span> -// </div> -// ) -// } - -export function AddMemoryView({ - onClose, - initialTab = "note", -}: { - onClose?: () => void - initialTab?: "note" | "link" | "file" | "connect" -}) { - const queryClient = useQueryClient() - const { selectedProject, setSelectedProject } = useProject() - const [showAddDialog, setShowAddDialog] = useState(true) - const [selectedFiles, setSelectedFiles] = useState<File[]>([]) - const [activeTab, setActiveTab] = useState< - "note" | "link" | "file" | "connect" - >(initialTab) - const autumn = useCustomer() - const [showCreateProjectDialog, setShowCreateProjectDialog] = useState(false) - const [newProjectName, setNewProjectName] = useState("") - - // Check memory limits - const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any) - - const memoriesUsed = memoriesCheck?.usage ?? 0 - const memoriesLimit = memoriesCheck?.included_usage ?? 0 - - // Check if user is pro - const { data: proCheck } = fetchConsumerProProduct(autumn as any) - const isProUser = proCheck?.allowed ?? false - - const canAddMemory = memoriesUsed < memoriesLimit - - // Fetch projects for the dropdown - 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, - }) - - // Create project mutation - const createProjectMutation = useMutation({ - mutationFn: async (name: string) => { - const response = await $fetch("@post/projects", { - body: { name }, - }) - - if (response.error) { - throw new Error(response.error?.message || "Failed to create project") - } - - return response.data - }, - onSuccess: (data) => { - analytics.projectCreated() - toast.success("Project created successfully!") - setShowCreateProjectDialog(false) - setNewProjectName("") - queryClient.invalidateQueries({ queryKey: ["projects"] }) - // Set the newly created project as selected - if (data?.containerTag) { - setSelectedProject(data.containerTag) - // Update form values - addContentForm.setFieldValue("project", data.containerTag) - fileUploadForm.setFieldValue("project", data.containerTag) - } - }, - onError: (error) => { - toast.error("Failed to create project", { - description: error instanceof Error ? error.message : "Unknown error", - }) - }, - }) - - const addContentForm = useForm({ - defaultValues: { - content: "", - project: selectedProject || "sm_project_default", - }, - onSubmit: async ({ value, formApi }) => { - addContentMutation.mutate({ - content: value.content, - project: value.project, - contentType: activeTab as "note" | "link", - }) - formApi.reset() - }, - validators: { - onChange: z.object({ - content: z.string().min(1, "Content is required"), - project: z.string(), - }), - }, - }) - - // Re-validate content field when tab changes between note/link - // biome-ignore lint/correctness/useExhaustiveDependencies: It is what it is - useEffect(() => { - // Trigger validation of the content field when switching between note/link - if (activeTab === "note" || activeTab === "link") { - const currentValue = addContentForm.getFieldValue("content") - if (currentValue) { - addContentForm.validateField("content", "change") - } - } - }, [activeTab]) - - // Form for file upload metadata - const fileUploadForm = useForm({ - defaultValues: { - title: "", - description: "", - project: selectedProject || "sm_project_default", - }, - onSubmit: async ({ value, formApi }) => { - if (selectedFiles.length === 0) { - toast.error("Please select a file to upload") - return - } - - for (const file of selectedFiles) { - fileUploadMutation.mutate({ - file, - title: value.title || undefined, - description: value.description || undefined, - project: value.project, - }) - } - - formApi.reset() - setSelectedFiles([]) - }, - }) - - const handleUpgrade = async () => { - try { - await autumn.attach({ - productId: "consumer_pro", - successUrl: "https://app.supermemory.ai/", - }) - window.location.reload() - } catch (error) { - console.error(error) - } - } - - const addContentMutation = useMutation({ - mutationFn: async ({ - content, - project, - contentType, - }: { - content: string - project: string - contentType: "note" | "link" - }) => { - // close the modal - onClose?.() - - const processingPromise = (async () => { - // First, create the memory - const response = await $fetch("@post/memories", { - body: { - content: content, - containerTags: [project], - metadata: { - sm_source: "consumer", // Use "consumer" source to bypass limits - }, - }, - }) - - if (response.error) { - throw new Error( - response.error?.message || `Failed to add ${contentType}`, - ) - } - - const memoryId = response.data.id - - // Polling function to check status - const pollForCompletion = async (): Promise<any> => { - let attempts = 0 - const maxAttempts = 60 // Maximum 5 minutes (60 attempts * 5 seconds) - - while (attempts < maxAttempts) { - try { - const memory = await $fetch<{ status: string; content: string }>( - "@get/memories/" + memoryId, - ) - - if (memory.error) { - throw new Error( - memory.error?.message || "Failed to fetch memory status", - ) - } - - // Check if processing is complete - // Adjust this condition based on your API response structure - if ( - memory.data?.status === "done" || - // Sometimes the memory might be ready when it has content and no processing status - memory.data?.content - ) { - return memory.data - } - - // If still processing, wait and try again - await new Promise((resolve) => setTimeout(resolve, 5000)) // Wait 5 seconds - attempts++ - } catch (error) { - console.error("Error polling memory status:", error) - // Don't throw immediately, retry a few times - if (attempts >= 3) { - throw new Error("Failed to check processing status") - } - await new Promise((resolve) => setTimeout(resolve, 5000)) - attempts++ - } - } - - // If we've exceeded max attempts, throw an error - throw new Error("Memory processing timed out. Please check back later.") - } - - // Wait for completion - const completedMemory = await pollForCompletion() - return completedMemory - })() - - toast.promise(processingPromise, { - loading: "Processing...", - success: `${contentType === "link" ? "Link" : "Note"} created successfully!`, - error: (err) => `Failed to add ${contentType}: ${err instanceof Error ? err.message : "Unknown error"}`, - }) - - return processingPromise - }, - onMutate: async ({ content, project, contentType }) => { - console.log("🚀 onMutate starting...") - - // Cancel any outgoing refetches - await queryClient.cancelQueries({ queryKey: ["documents-with-memories", project] }) - console.log("✅ Cancelled queries") - - // Snapshot the previous value - const previousMemories = queryClient.getQueryData([ - "documents-with-memories", - project, - ]) - console.log("📸 Previous memories:", previousMemories) - - // Create optimistic memory - const optimisticMemory = { - id: `temp-${Date.now()}`, - content: contentType === "link" ? "" : content, - url: contentType === "link" ? content : null, - title: - contentType === "link" ? "Processing..." : content.substring(0, 100), - description: contentType === "link" ? "Extracting content..." : "Processing content...", - containerTags: [project], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - status: "queued", - type: contentType, - metadata: { - processingStage: "queued", - processingMessage: "Added to processing queue" - }, - memoryEntries: [], - isOptimistic: true, - } - console.log("🎯 Created optimistic memory:", optimisticMemory) - - // Optimistically update to include the new memory - queryClient.setQueryData(["documents-with-memories", project], (old: any) => { - console.log("🔄 Old data:", old) - const newData = old - ? { - ...old, - documents: [optimisticMemory, ...(old.documents || [])], - totalCount: (old.totalCount || 0) + 1, - } - : { documents: [optimisticMemory], totalCount: 1 } - console.log("✨ New data:", newData) - return newData - }) - - console.log("✅ onMutate completed") - return { previousMemories, optimisticId: optimisticMemory.id } - }, - // If the mutation fails, roll back to the previous value - onError: (error, variables, context) => { - if (context?.previousMemories) { - queryClient.setQueryData( - ["documents-with-memories", variables.project], - context.previousMemories, - ) - } - }, - onSuccess: (_data, variables) => { - analytics.memoryAdded({ - type: variables.contentType === "link" ? "link" : "note", - project_id: variables.project, - content_length: variables.content.length, - }) - - queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project] }) - - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project] }) - }, 30000) // 30 seconds - - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ["documents-with-memories", variables.project] }) - }, 120000) // 2 minutes - - setShowAddDialog(false) - onClose?.() - }, - }) - - const fileUploadMutation = useMutation({ - mutationFn: async ({ - file, - title, - description, - project, - }: { - file: File - title?: string - description?: string - project: string - }) => { - // TEMPORARILY DISABLED: Limit check disabled - // Check if user can add more memories - // if (!canAddMemory && !isProUser) { - // throw new Error( - // `Free plan limit reached (${memoriesLimit} memories). Upgrade to Pro for up to 500 memories.`, - // ); - // } - - const formData = new FormData() - formData.append("file", file) - formData.append("containerTags", JSON.stringify([project])) - - const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memories/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 we have metadata, we can update the document after creation - if (title || description) { - await $fetch(`@patch/memories/${data.id}`, { - body: { - metadata: { - ...(title && { title }), - ...(description && { description }), - sm_source: "consumer", // Use "consumer" source to bypass limits - }, - }, - }) - } - - return data - }, - // Optimistic update - onMutate: async ({ file, title, description, project }) => { - // Cancel any outgoing refetches - await queryClient.cancelQueries({ queryKey: ["documents-with-memories", project] }) - - // Snapshot the previous value - const previousMemories = queryClient.getQueryData([ - "documents-with-memories", - project, - ]) - - // Create optimistic memory for the file - 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: [], - } - - // Optimistically update to include the new memory - queryClient.setQueryData(["documents-with-memories", project], (old: any) => { - if (!old) return { documents: [optimisticMemory], totalCount: 1 } - return { - ...old, - documents: [optimisticMemory, ...(old.documents || [])], - totalCount: (old.totalCount || 0) + 1, - } - }) - - // Return a context object with the snapshotted value - return { previousMemories } - }, - // If the mutation fails, roll back to the previous value - 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) => { - analytics.memoryAdded({ - type: "file", - project_id: variables.project, - file_size: variables.file.size, - file_type: variables.file.type, - }) - toast.success("File uploaded successfully!", { - description: "Your file is being processed", - }) - setShowAddDialog(false) - onClose?.() - }, - // Always refetch after error or success - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] }) - }, - }) - - return ( - <AnimatePresence mode="wait"> - {showAddDialog && ( - <Dialog - key="add-memory-dialog" - onOpenChange={(open) => { - setShowAddDialog(open) - if (!open) onClose?.() - }} - open={showAddDialog} - > - <DialogContent className="sm:max-w-3xl bg-[#0f1419] backdrop-blur-xl border-white/10 text-white z-[80]"> - <motion.div - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.95 }} - initial={{ opacity: 0, scale: 0.95 }} - > - <DialogHeader> - <DialogTitle>Add to Memory</DialogTitle> - <DialogDescription className="text-white/60"> - Save any webpage, article, or file to your memory - </DialogDescription> - { - <motion.div - animate={{ opacity: 1, y: 0 }} - className="mt-2" - initial={{ opacity: 0, y: -10 }} - > - <div className="text-xs text-white/50"> - {memoriesUsed} of {memoriesLimit} memories used - {!isProUser && memoriesUsed >= memoriesLimit * 0.8 && ( - <span className="text-yellow-400 ml-2"> - • {memoriesLimit - memoriesUsed} remaining - </span> - )} - </div> - {!canAddMemory && !isProUser && ( - <motion.div - animate={{ opacity: 1, height: "auto" }} - className="mt-2 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg" - initial={{ opacity: 0, height: 0 }} - > - <p className="text-sm text-yellow-400"> - You've reached the free plan limit. - <Button - asChild - className="text-yellow-400 hover:text-yellow-300 px-2" - onClick={handleUpgrade} - size="sm" - variant="link" - > - Upgrade to Pro - </Button> - for up to 5000 memories. - </p> - </motion.div> - )} - </motion.div> - } - </DialogHeader> - - <Tabs - className="mt-4" - onValueChange={(v) => - setActiveTab(v as "note" | "link" | "file" | "connect") - } - value={activeTab} - > - <TabsList className="grid w-full grid-cols-4 bg-white/5"> - <TabsTrigger - className="data-[state=active]:bg-white/10" - value="note" - > - <Brain className="h-4 w-4 mr-2" /> - Note - </TabsTrigger> - <TabsTrigger - className="data-[state=active]:bg-white/10" - value="link" - > - <LinkIcon className="h-4 w-4 mr-2" /> - Link - </TabsTrigger> - <TabsTrigger - className="data-[state=active]:bg-white/10" - value="file" - > - <FileIcon className="h-4 w-4 mr-2" /> - File - </TabsTrigger> - <TabsTrigger - className="data-[state=active]:bg-white/10" - value="connect" - > - <PlugIcon className="h-4 w-4 mr-2" /> - Connect - </TabsTrigger> - </TabsList> - - <TabsContent className="space-y-4 mt-4" value="note"> - <form - onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - addContentForm.handleSubmit() - }} - > - <div className="grid gap-4"> - {/* Note Input */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.1 }} - > - <label - className="text-sm font-medium" - htmlFor="note-content" - > - Note - </label> - <addContentForm.Field - name="content" - validators={{ - onChange: ({ value }) => { - if (!value || value.trim() === "") { - return "Note is required" - } - return undefined - }, - }} - > - {({ state, handleChange, handleBlur }) => ( - <> - <Textarea - className={`bg-white/5 border-white/10 text-white min-h-32 max-h-64 overflow-y-auto resize-none ${addContentMutation.isPending - ? "opacity-50" - : "" - }`} - disabled={addContentMutation.isPending} - id="note-content" - onBlur={handleBlur} - onChange={(e) => handleChange(e.target.value)} - placeholder="Write your note here..." - value={state.value} - /> - {state.meta.errors.length > 0 && ( - <motion.p - animate={{ opacity: 1, height: "auto" }} - className="text-sm text-red-400 mt-1" - exit={{ opacity: 0, height: 0 }} - initial={{ opacity: 0, height: 0 }} - > - {state.meta.errors - .map((error) => - typeof error === "string" - ? error - : (error?.message ?? - `Error: ${JSON.stringify(error)}`), - ) - .join(", ")} - </motion.p> - )} - </> - )} - </addContentForm.Field> - </motion.div> - - {/* Project Selection */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className={`flex flex-col gap-2 ${addContentMutation.isPending ? "opacity-50" : "" - }`} - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.15 }} - > - <label - className="text-sm font-medium" - htmlFor="note-project" - > - Project - </label> - <addContentForm.Field name="project"> - {({ state, handleChange }) => ( - <Select - disabled={ - isLoadingProjects || - addContentMutation.isPending - } - onValueChange={(value) => { - if (value === "create-new-project") { - setShowCreateProjectDialog(true) - } else { - handleChange(value) - } - }} - value={state.value} - > - <SelectTrigger - className="bg-white/5 border-white/10 text-white" - id="note-project" - > - <SelectValue placeholder="Select a project" /> - </SelectTrigger> - <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10"> - <SelectItem - className="text-white hover:bg-white/10" - key="default" - value="sm_project_default" - > - Default Project - </SelectItem> - {projects - .filter( - (p) => - p.containerTag !== "sm_project_default" && - p.id, - ) - .map((project) => ( - <SelectItem - className="text-white hover:bg-white/10" - key={project.id || project.containerTag} - value={project.containerTag} - > - {project.name} - </SelectItem> - ))} - <SelectItem - className="text-white hover:bg-white/10 border-t border-white/10 mt-1" - key="create-new" - value="create-new-project" - > - <div className="flex items-center gap-2"> - <Plus className="h-4 w-4" /> - <span>Create new project</span> - </div> - </SelectItem> - </SelectContent> - </Select> - )} - </addContentForm.Field> - <p className="text-xs text-white/50 mt-1"> - Choose which project to save this note to - </p> - </motion.div> - </div> - <DialogFooter className="mt-6"> - <motion.div - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - <Button - className="bg-white/5 hover:bg-white/10 border-white/10 text-white" - onClick={() => { - setShowAddDialog(false) - onClose?.() - addContentForm.reset() - }} - type="button" - variant="outline" - > - Cancel - </Button> - </motion.div> - <motion.div - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" - disabled={ - addContentMutation.isPending || - !addContentForm.state.canSubmit - } - type="submit" - > - {addContentMutation.isPending ? ( - <> - <Loader2 className="h-4 w-4 animate-spin mr-2" /> - Adding... - </> - ) : ( - <> - <Plus className="h-4 w-4 mr-2" /> - Add Note - </> - )} - </Button> - </motion.div> - </DialogFooter> - </form> - </TabsContent> - - <TabsContent className="space-y-4 mt-4" value="link"> - <form - onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - addContentForm.handleSubmit() - }} - > - <div className="grid gap-4"> - {/* Link Input */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.1 }} - > - <label - className="text-sm font-medium" - htmlFor="link-content" - > - Link - </label> - <addContentForm.Field - name="content" - validators={{ - onChange: ({ value }) => { - if (!value || value.trim() === "") { - return "Link is required" - } - try { - new URL(value) - return undefined - } catch { - return "Please enter a valid link" - } - }, - }} - > - {({ state, handleChange, handleBlur }) => ( - <> - <Input - className={`bg-white/5 border-white/10 text-white ${addContentMutation.isPending - ? "opacity-50" - : "" - }`} - disabled={addContentMutation.isPending} - id="link-content" - onBlur={handleBlur} - onChange={(e) => handleChange(e.target.value)} - placeholder="https://example.com/article" - value={state.value} - /> - {state.meta.errors.length > 0 && ( - <motion.p - animate={{ opacity: 1, height: "auto" }} - className="text-sm text-red-400 mt-1" - exit={{ opacity: 0, height: 0 }} - initial={{ opacity: 0, height: 0 }} - > - {state.meta.errors - .map((error) => - typeof error === "string" - ? error - : (error?.message ?? - `Error: ${JSON.stringify(error)}`), - ) - .join(", ")} - </motion.p> - )} - </> - )} - </addContentForm.Field> - </motion.div> - - {/* Project Selection */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className={`flex flex-col gap-2 ${addContentMutation.isPending ? "opacity-50" : "" - }`} - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.15 }} - > - <label - className="text-sm font-medium" - htmlFor="link-project" - > - Project - </label> - <addContentForm.Field name="project"> - {({ state, handleChange }) => ( - <Select - disabled={ - isLoadingProjects || - addContentMutation.isPending - } - onValueChange={(value) => { - if (value === "create-new-project") { - setShowCreateProjectDialog(true) - } else { - handleChange(value) - } - }} - value={state.value} - > - <SelectTrigger - className="bg-white/5 border-white/10 text-white" - id="link-project" - > - <SelectValue placeholder="Select a project" /> - </SelectTrigger> - <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10"> - <SelectItem - className="text-white hover:bg-white/10" - key="default" - value="sm_project_default" - > - Default Project - </SelectItem> - {projects - .filter( - (p) => - p.containerTag !== "sm_project_default" && - p.id, - ) - .map((project) => ( - <SelectItem - className="text-white hover:bg-white/10" - key={project.id || project.containerTag} - value={project.containerTag} - > - {project.name} - </SelectItem> - ))} - <SelectItem - className="text-white hover:bg-white/10 border-t border-white/10 mt-1" - key="create-new" - value="create-new-project" - > - <div className="flex items-center gap-2"> - <Plus className="h-4 w-4" /> - <span>Create new project</span> - </div> - </SelectItem> - </SelectContent> - </Select> - )} - </addContentForm.Field> - <p className="text-xs text-white/50 mt-1"> - Choose which project to save this link to - </p> - </motion.div> - </div> - <DialogFooter className="mt-6"> - <motion.div - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - <Button - className="bg-white/5 hover:bg-white/10 border-white/10 text-white" - onClick={() => { - setShowAddDialog(false) - onClose?.() - addContentForm.reset() - }} - type="button" - variant="outline" - > - Cancel - </Button> - </motion.div> - <motion.div - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" - disabled={ - addContentMutation.isPending || - !addContentForm.state.canSubmit - } - type="submit" - > - {addContentMutation.isPending ? ( - <> - <Loader2 className="h-4 w-4 animate-spin mr-2" /> - Adding... - </> - ) : ( - <> - <Plus className="h-4 w-4 mr-2" /> - Add Link - </> - )} - </Button> - </motion.div> - </DialogFooter> - </form> - </TabsContent> - - <TabsContent className="space-y-4 mt-4" value="file"> - <form - onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - fileUploadForm.handleSubmit() - }} - > - <div className="grid gap-4"> - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.1 }} - > - <label className="text-sm font-medium" htmlFor="file"> - File - </label> - <Dropzone - accept={{ - "application/pdf": [".pdf"], - "application/msword": [".doc"], - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - [".docx"], - "text/plain": [".txt"], - "text/markdown": [".md"], - "text/csv": [".csv"], - "application/json": [".json"], - "image/*": [ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ], - }} - className="bg-white/5 border-white/10 hover:bg-white/10 min-h-40" - maxFiles={10} - maxSize={10 * 1024 * 1024} // 10MB - onDrop={(acceptedFiles) => - setSelectedFiles(acceptedFiles) - } - src={selectedFiles} - > - <DropzoneEmptyState /> - <DropzoneContent className="overflow-auto" /> - </Dropzone> - </motion.div> - - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.15 }} - > - <label - className="text-sm font-medium" - htmlFor="file-title" - > - Title (optional) - </label> - <fileUploadForm.Field name="title"> - {({ state, handleChange, handleBlur }) => ( - <Input - className="bg-white/5 border-white/10 text-white" - id="file-title" - onBlur={handleBlur} - onChange={(e) => handleChange(e.target.value)} - placeholder="Give this file a title" - value={state.value} - /> - )} - </fileUploadForm.Field> - </motion.div> - - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.2 }} - > - <label - className="text-sm font-medium" - htmlFor="file-description" - > - Description (optional) - </label> - <fileUploadForm.Field name="description"> - {({ state, handleChange, handleBlur }) => ( - <Textarea - className="bg-white/5 border-white/10 text-white min-h-20 max-h-40 overflow-y-auto resize-none" - id="file-description" - onBlur={handleBlur} - onChange={(e) => handleChange(e.target.value)} - placeholder="Add notes or context about this file" - value={state.value} - /> - )} - </fileUploadForm.Field> - </motion.div> - - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.25 }} - > - <label - className="text-sm font-medium" - htmlFor="file-project" - > - Project - </label> - <fileUploadForm.Field name="project"> - {({ state, handleChange }) => ( - <Select - disabled={isLoadingProjects} - onValueChange={(value) => { - if (value === "create-new-project") { - setShowCreateProjectDialog(true) - } else { - handleChange(value) - } - }} - value={state.value} - > - <SelectTrigger - className="bg-white/5 border-white/10 text-white" - id="file-project" - > - <SelectValue placeholder="Select a project" /> - </SelectTrigger> - <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10"> - <SelectItem - className="text-white hover:bg-white/10" - key="default" - value="sm_project_default" - > - Default Project - </SelectItem> - {projects - .filter( - (p) => - p.containerTag !== "sm_project_default" && - p.id, - ) - .map((project) => ( - <SelectItem - className="text-white hover:bg-white/10" - key={project.id || project.containerTag} - value={project.containerTag} - > - {project.name} - </SelectItem> - ))} - <SelectItem - className="text-white hover:bg-white/10 border-t border-white/10 mt-1" - key="create-new" - value="create-new-project" - > - <div className="flex items-center gap-2"> - <Plus className="h-4 w-4" /> - <span>Create new project</span> - </div> - </SelectItem> - </SelectContent> - </Select> - )} - </fileUploadForm.Field> - <p className="text-xs text-white/50 mt-1"> - Choose which project to save this file to - </p> - </motion.div> - </div> - <DialogFooter className="mt-6"> - <motion.div - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - <Button - className="bg-white/5 hover:bg-white/10 border-white/10 text-white" - onClick={() => { - setShowAddDialog(false) - onClose?.() - fileUploadForm.reset() - setSelectedFiles([]) - }} - type="button" - variant="outline" - > - Cancel - </Button> - </motion.div> - <motion.div - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" - disabled={ - fileUploadMutation.isPending || - selectedFiles.length === 0 - } - type="submit" - > - {fileUploadMutation.isPending ? ( - <> - <Loader2 className="h-4 w-4 animate-spin mr-2" /> - Uploading... - </> - ) : ( - <> - <UploadIcon className="h-4 w-4 mr-2" /> - Upload File - </> - )} - </Button> - </motion.div> - </DialogFooter> - </form> - </TabsContent> - - <TabsContent className="space-y-4 mt-4" value="connect"> - <ConnectionsTabContent /> - </TabsContent> - </Tabs> - </motion.div> - </DialogContent> - </Dialog> - )} - - {/* Create Project Dialog */} - {showCreateProjectDialog && ( - <Dialog - key="create-project-dialog" - onOpenChange={setShowCreateProjectDialog} - open={showCreateProjectDialog} - > - <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white z-[80]"> - <motion.div - animate={{ opacity: 1, scale: 1 }} - initial={{ opacity: 0, scale: 0.95 }} - > - <DialogHeader> - <DialogTitle>Create New Project</DialogTitle> - <DialogDescription className="text-white/60"> - Give your project a unique name - </DialogDescription> - </DialogHeader> - <div className="grid gap-4 py-4"> - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.1 }} - > - <Label htmlFor="projectName">Project Name</Label> - <Input - className="bg-white/5 border-white/10 text-white" - id="projectName" - onChange={(e) => setNewProjectName(e.target.value)} - placeholder="My Awesome Project" - value={newProjectName} - /> - <p className="text-xs text-white/50"> - This will help you organize your memories - </p> - </motion.div> - </div> - <DialogFooter> - <motion.div - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - <Button - className="bg-white/5 hover:bg-white/10 border-white/10 text-white" - onClick={() => { - setShowCreateProjectDialog(false) - setNewProjectName("") - }} - type="button" - variant="outline" - > - Cancel - </Button> - </motion.div> - <motion.div - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" - disabled={ - createProjectMutation.isPending || !newProjectName.trim() - } - onClick={() => createProjectMutation.mutate(newProjectName)} - type="button" - > - {createProjectMutation.isPending ? ( - <> - <Loader2 className="h-4 w-4 animate-spin mr-2" /> - Creating... - </> - ) : ( - "Create Project" - )} - </Button> - </motion.div> - </DialogFooter> - </motion.div> - </DialogContent> - </Dialog> - )} - </AnimatePresence> - ) -} - -export function AddMemoryExpandedView() { - const [showDialog, setShowDialog] = useState(false) - const [selectedTab, setSelectedTab] = useState< - "note" | "link" | "file" | "connect" - >("note") - - const handleOpenDialog = (tab: "note" | "link" | "file" | "connect") => { - setSelectedTab(tab) - setShowDialog(true) - } - - return ( - <> - <motion.div - animate={{ opacity: 1, y: 0 }} - className="space-y-6" - initial={{ opacity: 0, y: 10 }} - > - <p className="text-sm text-white/70"> - Save any webpage, article, or file to your memory - </p> - - <div className="flex flex-wrap gap-2"> - <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> - <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" - onClick={() => handleOpenDialog("note")} - size="sm" - variant="outline" - > - <Brain className="h-4 w-4 mr-2" /> - Note - </Button> - </motion.div> - - <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> - <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" - onClick={() => handleOpenDialog("link")} - size="sm" - variant="outline" - > - <LinkIcon className="h-4 w-4 mr-2" /> - Link - </Button> - </motion.div> - - <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> - <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" - onClick={() => handleOpenDialog("file")} - size="sm" - variant="outline" - > - <FileIcon className="h-4 w-4 mr-2" /> - File - </Button> - </motion.div> - - <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> - <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" - onClick={() => handleOpenDialog("connect")} - size="sm" - variant="outline" - > - <PlugIcon className="h-4 w-4 mr-2" /> - Connect - </Button> - </motion.div> - </div> - </motion.div> - - {showDialog && ( - <AddMemoryView - initialTab={selectedTab} - onClose={() => setShowDialog(false)} - /> - )} - </> - ) -} diff --git a/apps/web/components/views/add-memory/action-buttons.tsx b/apps/web/components/views/add-memory/action-buttons.tsx new file mode 100644 index 00000000..6dc49304 --- /dev/null +++ b/apps/web/components/views/add-memory/action-buttons.tsx @@ -0,0 +1,67 @@ +import { Button } from '@repo/ui/components/button'; +import { Loader2, type LucideIcon } from 'lucide-react'; +import { motion } from 'motion/react'; + +interface ActionButtonsProps { + onCancel: () => void; + onSubmit?: () => void; + submitText: string; + submitIcon?: LucideIcon; + isSubmitting?: boolean; + isSubmitDisabled?: boolean; + submitType?: 'button' | 'submit'; + className?: string; +} + +export function ActionButtons({ + onCancel, + onSubmit, + submitText, + submitIcon: SubmitIcon, + isSubmitting = false, + isSubmitDisabled = false, + submitType = 'submit', + className = '', +}: ActionButtonsProps) { + return ( + <div className={`flex gap-3 order-1 sm:order-2 justify-end ${className}`}> + <Button + className="hover:bg-white/10 text-white border-none flex-1 sm:flex-initial" + onClick={onCancel} + type="button" + variant="ghost" + > + Cancel + </Button> + + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + className="flex-1 sm:flex-initial" + > + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20 w-full" + disabled={isSubmitting || isSubmitDisabled} + onClick={submitType === 'button' ? onSubmit : undefined} + type={submitType} + > + {isSubmitting ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + {submitText.includes('Add') + ? 'Adding...' + : submitText.includes('Upload') + ? 'Uploading...' + : 'Processing...'} + </> + ) : ( + <> + {SubmitIcon && <SubmitIcon className="h-4 w-4 mr-2" />} + {submitText} + </> + )} + </Button> + </motion.div> + </div> + ); +} diff --git a/apps/web/components/views/add-memory/index.tsx b/apps/web/components/views/add-memory/index.tsx new file mode 100644 index 00000000..ff1fa077 --- /dev/null +++ b/apps/web/components/views/add-memory/index.tsx @@ -0,0 +1,1205 @@ +import { $fetch } from "@lib/api"; +import { + fetchConsumerProProduct, + fetchMemoriesFeature, +} from "@repo/lib/queries"; +import { Button } from "@repo/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog"; +import { Input } from "@repo/ui/components/input"; +import { Label } from "@repo/ui/components/label"; +import { Textarea } from "@repo/ui/components/textarea"; +import { useForm } from "@tanstack/react-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Dropzone, + DropzoneContent, + DropzoneEmptyState, +} from "@ui/components/shadcn-io/dropzone"; +import { useCustomer } from "autumn-js/react"; +import { + Brain, + FileIcon, + Link as LinkIcon, + Loader2, + PlugIcon, + Plus, + UploadIcon, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { z } from "zod"; +import { analytics } from "@/lib/analytics"; +import { useProject } from "@/stores"; +import { ConnectionsTabContent } from "../connections-tab-content"; +import { ActionButtons } from "./action-buttons"; +import { MemoryUsageRing } from "./memory-usage-ring"; +import { ProjectSelection } from "./project-selection"; +import { TabButton } from "./tab-button"; +import dynamic from "next/dynamic"; + +const TextEditor = dynamic(() => import("./text-editor").then(mod => ({ default: mod.TextEditor })), { + loading: () => ( + <div className="bg-white/5 border border-white/10 rounded-md"> + <div className="flex-1 min-h-48 max-h-64 overflow-y-auto flex items-center justify-center text-white/70"> + Loading editor... + </div> + <div className="p-1 flex items-center gap-2 bg-white/5 backdrop-blur-sm rounded-b-md"> + <div className="flex items-center gap-1 opacity-50"> + <div className="h-8 w-8 bg-white/10 rounded-sm animate-pulse" /> + <div className="h-8 w-8 bg-white/10 rounded-sm animate-pulse" /> + <div className="h-8 w-8 bg-white/10 rounded-sm animate-pulse" /> + </div> + </div> + </div> + ), + ssr: false, +}); + +// // Processing status component +// function ProcessingStatus({ status }: { status: string }) { +// const statusConfig = { +// queued: { color: "text-yellow-400", label: "Queued", icon: "⏳" }, +// extracting: { color: "text-blue-400", label: "Extracting", icon: "📤" }, +// chunking: { color: "text-indigo-400", label: "Chunking", icon: "✂️" }, +// embedding: { color: "text-purple-400", label: "Embedding", icon: "🧠" }, +// indexing: { color: "text-pink-400", label: "Indexing", icon: "📝" }, +// unknown: { color: "text-gray-400", label: "Processing", icon: "⚙️" }, +// } + +// const config = +// statusConfig[status as keyof typeof statusConfig] || statusConfig.unknown + +// return ( +// <div className={`flex items-center gap-1 text-xs ${config.color}`}> +// <span>{config.icon}</span> +// <span>{config.label}</span> +// </div> +// ) +// } + +export function AddMemoryView({ + onClose, + initialTab = "note", +}: { + onClose?: () => void; + initialTab?: "note" | "link" | "file" | "connect"; +}) { + const queryClient = useQueryClient(); + const { selectedProject, setSelectedProject } = useProject(); + const [showAddDialog, setShowAddDialog] = useState(true); + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); + const [activeTab, setActiveTab] = useState< + "note" | "link" | "file" | "connect" + >(initialTab); + const autumn = useCustomer(); + const [showCreateProjectDialog, setShowCreateProjectDialog] = useState(false); + const [newProjectName, setNewProjectName] = useState(""); + + // Check memory limits + const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any); + + const memoriesUsed = memoriesCheck?.usage ?? 0; + const memoriesLimit = memoriesCheck?.included_usage ?? 0; + + // Check if user is pro + const { data: proCheck } = fetchConsumerProProduct(autumn as any); + const isProUser = proCheck?.allowed ?? false; + + const canAddMemory = memoriesUsed < memoriesLimit; + + // Fetch projects for the dropdown + 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, + }); + + // Create project mutation + const createProjectMutation = useMutation({ + mutationFn: async (name: string) => { + const response = await $fetch("@post/projects", { + body: { name }, + }); + + if (response.error) { + throw new Error(response.error?.message || "Failed to create project"); + } + + return response.data; + }, + onSuccess: (data) => { + analytics.projectCreated(); + toast.success("Project created successfully!"); + setShowCreateProjectDialog(false); + setNewProjectName(""); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + // Set the newly created project as selected + if (data?.containerTag) { + setSelectedProject(data.containerTag); + // Update form values + addContentForm.setFieldValue("project", data.containerTag); + fileUploadForm.setFieldValue("project", data.containerTag); + } + }, + onError: (error) => { + toast.error("Failed to create project", { + description: error instanceof Error ? error.message : "Unknown error", + }); + }, + }); + + const addContentForm = useForm({ + defaultValues: { + content: "", + project: selectedProject || "sm_project_default", + }, + onSubmit: async ({ value, formApi }) => { + addContentMutation.mutate({ + content: value.content, + project: value.project, + contentType: activeTab as "note" | "link", + }); + formApi.reset(); + }, + validators: { + onChange: z.object({ + content: z.string().min(1, "Content is required"), + project: z.string(), + }), + }, + }); + + // Re-validate content field when tab changes between note/link + // biome-ignore lint/correctness/useExhaustiveDependencies: It is what it is + useEffect(() => { + // Trigger validation of the content field when switching between note/link + if (activeTab === "note" || activeTab === "link") { + const currentValue = addContentForm.getFieldValue("content"); + if (currentValue) { + addContentForm.validateField("content", "change"); + } + } + }, [activeTab]); + + // Form for file upload metadata + const fileUploadForm = useForm({ + defaultValues: { + title: "", + description: "", + project: selectedProject || "sm_project_default", + }, + onSubmit: async ({ value, formApi }) => { + if (selectedFiles.length === 0) { + toast.error("Please select a file to upload"); + return; + } + + for (const file of selectedFiles) { + fileUploadMutation.mutate({ + file, + title: value.title || undefined, + description: value.description || undefined, + project: value.project, + }); + } + + formApi.reset(); + setSelectedFiles([]); + }, + }); + + const handleUpgrade = async () => { + try { + await autumn.attach({ + productId: "consumer_pro", + successUrl: "https://app.supermemory.ai/", + }); + window.location.reload(); + } catch (error) { + console.error(error); + } + }; + + const addContentMutation = useMutation({ + mutationFn: async ({ + content, + project, + contentType, + }: { + content: string; + project: string; + contentType: "note" | "link"; + }) => { + // close the modal + onClose?.(); + + const processingPromise = (async () => { + // First, create the memory + const response = await $fetch("@post/memories", { + body: { + content: content, + containerTags: [project], + metadata: { + sm_source: "consumer", // Use "consumer" source to bypass limits + }, + }, + }); + + if (response.error) { + throw new Error( + response.error?.message || `Failed to add ${contentType}`, + ); + } + + const memoryId = response.data.id; + + // Polling function to check status + const pollForCompletion = async (): Promise<any> => { + let attempts = 0; + const maxAttempts = 60; // Maximum 5 minutes (60 attempts * 5 seconds) + + while (attempts < maxAttempts) { + try { + const memory = await $fetch<{ status: string; content: string }>( + "@get/memories/" + memoryId, + ); + + if (memory.error) { + throw new Error( + memory.error?.message || "Failed to fetch memory status", + ); + } + + // Check if processing is complete + // Adjust this condition based on your API response structure + if ( + memory.data?.status === "done" || + // Sometimes the memory might be ready when it has content and no processing status + memory.data?.content + ) { + return memory.data; + } + + // If still processing, wait and try again + await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds + attempts++; + } catch (error) { + console.error("Error polling memory status:", error); + // Don't throw immediately, retry a few times + if (attempts >= 3) { + throw new Error("Failed to check processing status"); + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + attempts++; + } + } + + // If we've exceeded max attempts, throw an error + throw new Error( + "Memory processing timed out. Please check back later.", + ); + }; + + // Wait for completion + const completedMemory = await pollForCompletion(); + return completedMemory; + })(); + + toast.promise(processingPromise, { + loading: "Processing...", + success: `${contentType === "link" ? "Link" : "Note"} created successfully!`, + error: (err) => + `Failed to add ${contentType}: ${err instanceof Error ? err.message : "Unknown error"}`, + }); + + return processingPromise; + }, + onMutate: async ({ content, project, contentType }) => { + console.log("🚀 onMutate starting..."); + + // Cancel any outgoing refetches + await queryClient.cancelQueries({ + queryKey: ["documents-with-memories", project], + }); + console.log("✅ Cancelled queries"); + + // Snapshot the previous value + const previousMemories = queryClient.getQueryData([ + "documents-with-memories", + project, + ]); + console.log("📸 Previous memories:", previousMemories); + + // Create optimistic memory + const optimisticMemory = { + id: `temp-${Date.now()}`, + content: contentType === "link" ? "" : content, + url: contentType === "link" ? content : null, + title: + contentType === "link" ? "Processing..." : content.substring(0, 100), + description: + contentType === "link" + ? "Extracting content..." + : "Processing content...", + containerTags: [project], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: "queued", + type: contentType, + metadata: { + processingStage: "queued", + processingMessage: "Added to processing queue", + }, + memoryEntries: [], + isOptimistic: true, + }; + console.log("🎯 Created optimistic memory:", optimisticMemory); + + // Optimistically update to include the new memory + queryClient.setQueryData( + ["documents-with-memories", project], + (old: any) => { + console.log("🔄 Old data:", old); + const newData = old + ? { + ...old, + documents: [optimisticMemory, ...(old.documents || [])], + totalCount: (old.totalCount || 0) + 1, + } + : { documents: [optimisticMemory], totalCount: 1 }; + console.log("✨ New data:", newData); + return newData; + }, + ); + + console.log("✅ onMutate completed"); + return { previousMemories, optimisticId: optimisticMemory.id }; + }, + // If the mutation fails, roll back to the previous value + onError: (error, variables, context) => { + if (context?.previousMemories) { + queryClient.setQueryData( + ["documents-with-memories", variables.project], + context.previousMemories, + ); + } + }, + onSuccess: (_data, variables) => { + analytics.memoryAdded({ + type: variables.contentType === "link" ? "link" : "note", + project_id: variables.project, + content_length: variables.content.length, + }); + + queryClient.invalidateQueries({ + queryKey: ["documents-with-memories", variables.project], + }); + + setTimeout(() => { + queryClient.invalidateQueries({ + queryKey: ["documents-with-memories", variables.project], + }); + }, 30000); // 30 seconds + + setTimeout(() => { + queryClient.invalidateQueries({ + queryKey: ["documents-with-memories", variables.project], + }); + }, 120000); // 2 minutes + + setShowAddDialog(false); + onClose?.(); + }, + }); + + const fileUploadMutation = useMutation({ + mutationFn: async ({ + file, + title, + description, + project, + }: { + file: File; + title?: string; + description?: string; + project: string; + }) => { + // TEMPORARILY DISABLED: Limit check disabled + // Check if user can add more memories + // if (!canAddMemory && !isProUser) { + // throw new Error( + // `Free plan limit reached (${memoriesLimit} memories). Upgrade to Pro for up to 500 memories.`, + // ); + // } + + const formData = new FormData(); + formData.append("file", file); + formData.append("containerTags", JSON.stringify([project])); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memories/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 we have metadata, we can update the document after creation + if (title || description) { + await $fetch(`@patch/memories/${data.id}`, { + body: { + metadata: { + ...(title && { title }), + ...(description && { description }), + sm_source: "consumer", // Use "consumer" source to bypass limits + }, + }, + }); + } + + return data; + }, + // Optimistic update + onMutate: async ({ file, title, description, project }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ + queryKey: ["documents-with-memories", project], + }); + + // Snapshot the previous value + const previousMemories = queryClient.getQueryData([ + "documents-with-memories", + project, + ]); + + // Create optimistic memory for the file + 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: [], + }; + + // Optimistically update to include the new memory + queryClient.setQueryData( + ["documents-with-memories", project], + (old: any) => { + if (!old) return { documents: [optimisticMemory], totalCount: 1 }; + return { + ...old, + documents: [optimisticMemory, ...(old.documents || [])], + totalCount: (old.totalCount || 0) + 1, + }; + }, + ); + + // Return a context object with the snapshotted value + return { previousMemories }; + }, + // If the mutation fails, roll back to the previous value + 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) => { + analytics.memoryAdded({ + type: "file", + project_id: variables.project, + file_size: variables.file.size, + file_type: variables.file.type, + }); + toast.success("File uploaded successfully!", { + description: "Your file is being processed", + }); + setShowAddDialog(false); + onClose?.(); + }, + // Always refetch after error or success + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] }); + }, + }); + + return ( + <AnimatePresence mode="wait"> + {showAddDialog && ( + <Dialog + key="add-memory-dialog" + onOpenChange={(open) => { + setShowAddDialog(open); + if (!open) onClose?.(); + }} + open={showAddDialog} + > + <DialogContent + className="w-[95vw] max-w-3xl sm:max-w-3xl bg-[#0f1419] backdrop-blur-xl border-white/10 text-white z-[80] max-h-[90vh] overflow-y-auto" + showCloseButton={false} + > + <motion.div + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> + <div className="flex-1"> + <DialogTitle className="text-base"> + Add to Memory + </DialogTitle> + <DialogDescription className="text-white/50"> + Save any webpage, article, or file to your memory + </DialogDescription> + </div> + <div className="sm:ml-4 order-first sm:order-last"> + <div className="bg-white/5 p-1 h-10 sm:h-8 rounded-md flex overflow-x-auto"> + <TabButton + icon={Brain} + label="Note" + isActive={activeTab === "note"} + onClick={() => setActiveTab("note")} + /> + <TabButton + icon={LinkIcon} + label="Link" + isActive={activeTab === "link"} + onClick={() => setActiveTab("link")} + /> + <TabButton + icon={FileIcon} + label="File" + isActive={activeTab === "file"} + onClick={() => setActiveTab("file")} + /> + <TabButton + icon={PlugIcon} + label="Connect" + isActive={activeTab === "connect"} + onClick={() => setActiveTab("connect")} + /> + </div> + </div> + </div> + </DialogHeader> + + <div className="mt-4"> + {activeTab === "note" && ( + <div className="space-y-4"> + <form + onSubmit={(e) => { + e.preventDefault(); + e.stopPropagation(); + addContentForm.handleSubmit(); + }} + > + <div className="grid gap-4"> + {/* Note Input */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <addContentForm.Field + name="content" + validators={{ + onChange: ({ value }) => { + if (!value || value.trim() === "") { + return "Note is required"; + } + return undefined; + }, + }} + > + {({ state, handleChange, handleBlur }) => ( + <> + <div + className={`bg-white/5 border border-white/10 rounded-md ${ + addContentMutation.isPending + ? "opacity-50" + : "" + }`} + > + <TextEditor + value={state.value} + onChange={handleChange} + onBlur={handleBlur} + placeholder="Write your note here..." + disabled={addContentMutation.isPending} + className="text-white" + /> + </div> + {state.meta.errors.length > 0 && ( + <motion.p + animate={{ opacity: 1, height: "auto" }} + className="text-sm text-red-400 mt-1" + exit={{ opacity: 0, height: 0 }} + initial={{ opacity: 0, height: 0 }} + > + {state.meta.errors + .map((error) => + typeof error === "string" + ? error + : (error?.message ?? + `Error: ${JSON.stringify(error)}`), + ) + .join(", ")} + </motion.p> + )} + </> + )} + </addContentForm.Field> + </motion.div> + </div> + <div className="mt-6 flex flex-col sm:flex-row sm:justify-between sm:items-end w-full gap-4"> + <div className="flex flex-col sm:flex-row sm:items-end gap-4 order-2 sm:order-1"> + {/* Project Selection */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className={`flex flex-col gap-2 flex-1 sm:flex-initial ${ + addContentMutation.isPending ? "opacity-50" : "" + }`} + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <addContentForm.Field name="project"> + {({ state, handleChange }) => ( + <ProjectSelection + projects={projects} + selectedProject={state.value} + onProjectChange={handleChange} + onCreateProject={() => + setShowCreateProjectDialog(true) + } + disabled={addContentMutation.isPending} + isLoading={isLoadingProjects} + id="note-project" + /> + )} + </addContentForm.Field> + </motion.div> + + <MemoryUsageRing + memoriesUsed={memoriesUsed} + memoriesLimit={memoriesLimit} + /> + </div> + + <ActionButtons + onCancel={() => { + setShowAddDialog(false); + onClose?.(); + addContentForm.reset(); + }} + submitText="Add Note" + submitIcon={Plus} + isSubmitting={addContentMutation.isPending} + isSubmitDisabled={!addContentForm.state.canSubmit} + /> + </div> + </form> + </div> + )} + + {activeTab === "link" && ( + <div className="space-y-4"> + <form + onSubmit={(e) => { + e.preventDefault(); + e.stopPropagation(); + addContentForm.handleSubmit(); + }} + > + <div className="grid gap-4"> + {/* Link Input */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <label + className="text-sm font-medium" + htmlFor="link-content" + > + Link + </label> + <addContentForm.Field + name="content" + validators={{ + onChange: ({ value }) => { + if (!value || value.trim() === "") { + return "Link is required"; + } + try { + new URL(value); + return undefined; + } catch { + return "Please enter a valid link"; + } + }, + }} + > + {({ state, handleChange, handleBlur }) => ( + <> + <Input + className={`bg-white/5 border-white/10 text-white ${ + addContentMutation.isPending + ? "opacity-50" + : "" + }`} + disabled={addContentMutation.isPending} + id="link-content" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="https://example.com/article" + value={state.value} + /> + {state.meta.errors.length > 0 && ( + <motion.p + animate={{ opacity: 1, height: "auto" }} + className="text-sm text-red-400 mt-1" + exit={{ opacity: 0, height: 0 }} + initial={{ opacity: 0, height: 0 }} + > + {state.meta.errors + .map((error) => + typeof error === "string" + ? error + : (error?.message ?? + `Error: ${JSON.stringify(error)}`), + ) + .join(", ")} + </motion.p> + )} + </> + )} + </addContentForm.Field> + </motion.div> + </div> + <div className="mt-6 flex justify-between items-end w-full"> + <div className="flex items-end gap-4"> + {/* Left side - Project Selection */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className={`flex flex-col gap-2 ${ + addContentMutation.isPending ? "opacity-50" : "" + }`} + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <addContentForm.Field name="project"> + {({ state, handleChange }) => ( + <ProjectSelection + projects={projects} + selectedProject={state.value} + onProjectChange={handleChange} + onCreateProject={() => + setShowCreateProjectDialog(true) + } + disabled={addContentMutation.isPending} + isLoading={isLoadingProjects} + id="link-project-2" + /> + )} + </addContentForm.Field> + </motion.div> + + <MemoryUsageRing + memoriesUsed={memoriesUsed} + memoriesLimit={memoriesLimit} + /> + </div> + + <ActionButtons + onCancel={() => { + setShowAddDialog(false); + onClose?.(); + addContentForm.reset(); + }} + submitText="Add Link" + submitIcon={Plus} + isSubmitting={addContentMutation.isPending} + isSubmitDisabled={!addContentForm.state.canSubmit} + /> + </div> + </form> + </div> + )} + + {activeTab === "file" && ( + <div className="space-y-4"> + <form + onSubmit={(e) => { + e.preventDefault(); + e.stopPropagation(); + fileUploadForm.handleSubmit(); + }} + > + <div className="grid gap-4"> + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <label className="text-sm font-medium" htmlFor="file"> + File + </label> + <Dropzone + accept={{ + "application/pdf": [".pdf"], + "application/msword": [".doc"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + [".docx"], + "text/plain": [".txt"], + "text/markdown": [".md"], + "text/csv": [".csv"], + "application/json": [".json"], + "image/*": [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ], + }} + className="bg-white/5 border-white/10 hover:bg-white/10 min-h-40" + maxFiles={10} + maxSize={10 * 1024 * 1024} // 10MB + onDrop={(acceptedFiles) => + setSelectedFiles(acceptedFiles) + } + src={selectedFiles} + > + <DropzoneEmptyState /> + <DropzoneContent className="overflow-auto" /> + </Dropzone> + </motion.div> + + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <label + className="text-sm font-medium" + htmlFor="file-title" + > + Title (optional) + </label> + <fileUploadForm.Field name="title"> + {({ state, handleChange, handleBlur }) => ( + <Input + className="bg-white/5 border-white/10 text-white" + id="file-title" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="Give this file a title" + value={state.value} + /> + )} + </fileUploadForm.Field> + </motion.div> + + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.2 }} + > + <label + className="text-sm font-medium" + htmlFor="file-description" + > + Description (optional) + </label> + <fileUploadForm.Field name="description"> + {({ state, handleChange, handleBlur }) => ( + <Textarea + className="bg-white/5 border-white/10 text-white min-h-20 max-h-40 overflow-y-auto resize-none" + id="file-description" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="Add notes or context about this file" + value={state.value} + /> + )} + </fileUploadForm.Field> + </motion.div> + </div> + <div className="mt-6 flex flex-col sm:flex-row sm:justify-between sm:items-end w-full gap-4"> + <div className="flex items-end gap-4"> + {/* Left side - Project Selection */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className={`flex flex-col gap-2 flex-1 sm:flex-initial ${ + fileUploadMutation.isPending ? "opacity-50" : "" + }`} + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.25 }} + > + <fileUploadForm.Field name="project"> + {({ state, handleChange }) => ( + <ProjectSelection + projects={projects} + selectedProject={state.value} + onProjectChange={handleChange} + onCreateProject={() => + setShowCreateProjectDialog(true) + } + disabled={fileUploadMutation.isPending} + isLoading={isLoadingProjects} + id="file-project" + /> + )} + </fileUploadForm.Field> + </motion.div> + + <MemoryUsageRing + memoriesUsed={memoriesUsed} + memoriesLimit={memoriesLimit} + /> + </div> + + <ActionButtons + onCancel={() => { + setShowAddDialog(false); + onClose?.(); + fileUploadForm.reset(); + setSelectedFiles([]); + }} + submitText="Upload File" + submitIcon={UploadIcon} + isSubmitting={fileUploadMutation.isPending} + isSubmitDisabled={selectedFiles.length === 0} + /> + </div> + </form> + </div> + )} + + {activeTab === "connect" && ( + <div className="space-y-4"> + <ConnectionsTabContent /> + </div> + )} + </div> + </motion.div> + </DialogContent> + </Dialog> + )} + + {/* Create Project Dialog */} + {showCreateProjectDialog && ( + <Dialog + key="create-project-dialog" + onOpenChange={setShowCreateProjectDialog} + open={showCreateProjectDialog} + > + <DialogContent className="w-[95vw] max-w-2xl sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white z-[80] max-h-[90vh] overflow-y-auto"> + <motion.div + animate={{ opacity: 1, scale: 1 }} + initial={{ opacity: 0, scale: 0.95 }} + > + <DialogHeader> + <DialogTitle>Create New Project</DialogTitle> + <DialogDescription className="text-white/60"> + Give your project a unique name + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <Label htmlFor="projectName">Project Name</Label> + <Input + className="bg-white/5 border-white/10 text-white" + id="projectName" + onChange={(e) => setNewProjectName(e.target.value)} + placeholder="My Awesome Project" + value={newProjectName} + /> + <p className="text-xs text-white/50"> + This will help you organize your memories + </p> + </motion.div> + </div> + <DialogFooter className="flex-col sm:flex-row gap-3 sm:gap-0"> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + className="w-full sm:w-auto" + > + <Button + className="bg-white/5 hover:bg-white/10 border-white/10 text-white w-full sm:w-auto" + onClick={() => { + setShowCreateProjectDialog(false); + setNewProjectName(""); + }} + type="button" + variant="outline" + > + Cancel + </Button> + </motion.div> + <motion.div + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + className="w-full sm:w-auto" + > + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20 w-full sm:w-auto" + disabled={ + createProjectMutation.isPending || !newProjectName.trim() + } + onClick={() => createProjectMutation.mutate(newProjectName)} + type="button" + > + {createProjectMutation.isPending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + Creating... + </> + ) : ( + "Create Project" + )} + </Button> + </motion.div> + </DialogFooter> + </motion.div> + </DialogContent> + </Dialog> + )} + </AnimatePresence> + ); +} + +export function AddMemoryExpandedView() { + const [showDialog, setShowDialog] = useState(false); + const [selectedTab, setSelectedTab] = useState< + "note" | "link" | "file" | "connect" + >("note"); + + const handleOpenDialog = (tab: "note" | "link" | "file" | "connect") => { + setSelectedTab(tab); + setShowDialog(true); + }; + + return ( + <> + <motion.div + animate={{ opacity: 1, y: 0 }} + className="space-y-6" + initial={{ opacity: 0, y: 10 }} + > + <p className="text-sm text-white/70"> + Save any webpage, article, or file to your memory + </p> + + <div className="flex flex-wrap gap-2"> + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={() => handleOpenDialog("note")} + size="sm" + variant="outline" + > + <Brain className="h-4 w-4 mr-2" /> + Note + </Button> + </motion.div> + + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={() => handleOpenDialog("link")} + size="sm" + variant="outline" + > + <LinkIcon className="h-4 w-4 mr-2" /> + Link + </Button> + </motion.div> + + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={() => handleOpenDialog("file")} + size="sm" + variant="outline" + > + <FileIcon className="h-4 w-4 mr-2" /> + File + </Button> + </motion.div> + + <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> + <Button + className="bg-white/10 hover:bg-white/20 text-white border-white/20" + onClick={() => handleOpenDialog("connect")} + size="sm" + variant="outline" + > + <PlugIcon className="h-4 w-4 mr-2" /> + Connect + </Button> + </motion.div> + </div> + </motion.div> + + {showDialog && ( + <AddMemoryView + initialTab={selectedTab} + onClose={() => setShowDialog(false)} + /> + )} + </> + ); +} diff --git a/apps/web/components/views/add-memory/memory-usage-ring.tsx b/apps/web/components/views/add-memory/memory-usage-ring.tsx new file mode 100644 index 00000000..f6fb9836 --- /dev/null +++ b/apps/web/components/views/add-memory/memory-usage-ring.tsx @@ -0,0 +1,53 @@ +interface MemoryUsageRingProps { + memoriesUsed: number; + memoriesLimit: number; + className?: string; +} + +export function MemoryUsageRing({ + memoriesUsed, + memoriesLimit, + className = "", +}: MemoryUsageRingProps) { + const usagePercentage = memoriesUsed / memoriesLimit; + const strokeColor = + memoriesUsed >= memoriesLimit * 0.8 ? "rgb(251 191 36)" : "rgb(34 197 94)"; + const circumference = 2 * Math.PI * 10; + + return ( + <div + className={`relative group cursor-help self-center sm:self-end mb-1 hidden sm:block ${className}`} + title={`${memoriesUsed} of ${memoriesLimit} memories used`} + > + <svg className="w-6 h-6 transform -rotate-90" viewBox="0 0 24 24"> + {/* Background circle */} + <circle + cx="12" + cy="12" + r="10" + stroke="rgb(255 255 255 / 0.1)" + strokeWidth="2" + fill="none" + /> + {/* Progress circle */} + <circle + cx="12" + cy="12" + r="10" + stroke={strokeColor} + strokeWidth="2" + fill="none" + strokeDasharray={`${circumference}`} + strokeDashoffset={`${circumference * (1 - usagePercentage)}`} + className="transition-all duration-300" + strokeLinecap="round" + /> + </svg> + + {/* Tooltip on hover */} + <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-black/90 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap"> + {memoriesUsed} / {memoriesLimit} + </div> + </div> + ); +} diff --git a/apps/web/components/views/add-memory/project-selection.tsx b/apps/web/components/views/add-memory/project-selection.tsx new file mode 100644 index 00000000..f23768a3 --- /dev/null +++ b/apps/web/components/views/add-memory/project-selection.tsx @@ -0,0 +1,94 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@repo/ui/components/select'; +import { Plus } from 'lucide-react'; + +interface Project { + id?: string; + containerTag: string; + name: string; +} + +interface ProjectSelectionProps { + projects: Project[]; + selectedProject: string; + onProjectChange: (value: string) => void; + onCreateProject: () => void; + disabled?: boolean; + isLoading?: boolean; + className?: string; + id?: string; +} + +export function ProjectSelection({ + projects, + selectedProject, + onProjectChange, + onCreateProject, + disabled = false, + isLoading = false, + className = '', + id = 'project-select', +}: ProjectSelectionProps) { + const handleValueChange = (value: string) => { + if (value === 'create-new-project') { + onCreateProject(); + } else { + onProjectChange(value); + } + }; + + return ( + <Select + key={`${id}-${selectedProject}`} + disabled={isLoading || disabled} + onValueChange={handleValueChange} + value={selectedProject} + > + <SelectTrigger + className={`bg-white/5 border-white/10 text-white ${className}`} + id={id} + > + <SelectValue placeholder="Select a project" /> + </SelectTrigger> + <SelectContent + className="bg-black/90 backdrop-blur-xl border-white/10 z-[90]" + position="popper" + sideOffset={5} + > + <SelectItem + className="text-white hover:bg-white/10" + key="default" + value="sm_project_default" + > + Default Project + </SelectItem> + {projects + .filter((p) => p.containerTag !== 'sm_project_default' && p.id) + .map((project) => ( + <SelectItem + className="text-white hover:bg-white/10" + key={project.id || project.containerTag} + value={project.containerTag} + > + {project.name} + </SelectItem> + ))} + <SelectItem + className="text-white hover:bg-white/10 border-t border-white/10 mt-1" + key="create-new" + value="create-new-project" + > + <div className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + <span>Create new project</span> + </div> + </SelectItem> + </SelectContent> + </Select> + ); +} diff --git a/apps/web/components/views/add-memory/tab-button.tsx b/apps/web/components/views/add-memory/tab-button.tsx new file mode 100644 index 00000000..72dfbbd7 --- /dev/null +++ b/apps/web/components/views/add-memory/tab-button.tsx @@ -0,0 +1,28 @@ +import type { LucideIcon } from 'lucide-react'; + +interface TabButtonProps { + icon: LucideIcon; + label: string; + isActive: boolean; + onClick: () => void; +} + +export function TabButton({ + icon: Icon, + label, + isActive, + onClick, +}: TabButtonProps) { + return ( + <button + className={`flex items-center gap-1.5 text-xs sm:text-xs px-4 sm:px-3 py-2 sm:py-1 h-8 sm:h-6 rounded-sm transition-colors whitespace-nowrap min-w-0 ${ + isActive ? 'bg-white/10' : 'hover:bg-white/5' + }`} + onClick={onClick} + type="button" + > + <Icon className="h-4 w-4 sm:h-3 sm:w-3" /> + {label} + </button> + ); +} diff --git a/apps/web/components/views/add-memory/text-editor.tsx b/apps/web/components/views/add-memory/text-editor.tsx new file mode 100644 index 00000000..5a07b8f7 --- /dev/null +++ b/apps/web/components/views/add-memory/text-editor.tsx @@ -0,0 +1,545 @@ +"use client"; + +import { cn } from "@lib/utils"; +import { Button } from "@repo/ui/components/button"; +import isHotkey from "is-hotkey"; +import { + Bold, + Code, + Heading1, + Heading2, + Heading3, + Italic, + List, + Quote, +} from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { + type BaseEditor, + createEditor, + type Descendant, + Editor, + Transforms, +} from "slate"; +import type { ReactEditor as ReactEditorType } from "slate-react"; +import { + Editable, + ReactEditor, + type RenderElementProps, + type RenderLeafProps, + Slate, + withReact, +} from "slate-react"; + +type CustomEditor = BaseEditor & ReactEditorType; + +type ParagraphElement = { + type: "paragraph"; + children: CustomText[]; +}; + +type HeadingElement = { + type: "heading"; + level: number; + children: CustomText[]; +}; + +type ListItemElement = { + type: "list-item"; + children: CustomText[]; +}; + +type BlockQuoteElement = { + type: "block-quote"; + children: CustomText[]; +}; + +type CustomElement = + | ParagraphElement + | HeadingElement + | ListItemElement + | BlockQuoteElement; + +type FormattedText = { + text: string; + bold?: true; + italic?: true; + code?: true; +}; + +type CustomText = FormattedText; + +declare module "slate" { + interface CustomTypes { + Editor: CustomEditor; + Element: CustomElement; + Text: CustomText; + } +} + +// Hotkey mappings +const HOTKEYS: Record<string, keyof CustomText> = { + "mod+b": "bold", + "mod+i": "italic", + "mod+`": "code", +}; + +interface TextEditorProps { + value?: string; + onChange?: (value: string) => void; + onBlur?: () => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +const initialValue: Descendant[] = [ + { + type: "paragraph", + children: [{ text: "" }], + }, +]; + +const serialize = (nodes: Descendant[]): string => { + return nodes.map((n) => serializeNode(n)).join("\n"); +}; + +const serializeNode = (node: CustomElement | CustomText): string => { + if ("text" in node) { + let text = node.text; + if (node.bold) text = `**${text}**`; + if (node.italic) text = `*${text}*`; + if (node.code) text = `\`${text}\``; + return text; + } + + const children = node.children + ? node.children.map(serializeNode).join("") + : ""; + + switch (node.type) { + case "paragraph": + return children; + case "heading": + return `${"#".repeat(node.level || 1)} ${children}`; + case "list-item": + return `- ${children}`; + case "block-quote": + return `> ${children}`; + default: + return children; + } +}; + +const deserialize = (text: string): Descendant[] => { + if (!text.trim()) { + return initialValue; + } + + const lines = text.split("\n"); + const nodes: Descendant[] = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine.startsWith("# ")) { + nodes.push({ + type: "heading", + level: 1, + children: [{ text: trimmedLine.slice(2) }], + }); + } else if (trimmedLine.startsWith("## ")) { + nodes.push({ + type: "heading", + level: 2, + children: [{ text: trimmedLine.slice(3) }], + }); + } else if (trimmedLine.startsWith("### ")) { + nodes.push({ + type: "heading", + level: 3, + children: [{ text: trimmedLine.slice(4) }], + }); + } else if (trimmedLine.startsWith("- ")) { + nodes.push({ + type: "list-item", + children: [{ text: trimmedLine.slice(2) }], + }); + } else if (trimmedLine.startsWith("> ")) { + nodes.push({ + type: "block-quote", + children: [{ text: trimmedLine.slice(2) }], + }); + } else { + nodes.push({ + type: "paragraph", + children: [{ text: line }], + }); + } + } + + return nodes.length > 0 ? nodes : initialValue; +}; + +const isMarkActive = (editor: CustomEditor, format: keyof CustomText) => { + const marks = Editor.marks(editor); + return marks ? marks[format as keyof typeof marks] === true : false; +}; + +const toggleMark = (editor: CustomEditor, format: keyof CustomText) => { + const isActive = isMarkActive(editor, format); + + if (isActive) { + Editor.removeMark(editor, format); + } else { + Editor.addMark(editor, format, true); + } + + // Focus back to editor after toggling + ReactEditor.focus(editor); +}; + +const isBlockActive = ( + editor: CustomEditor, + format: string, + level?: number, +) => { + const { selection } = editor; + if (!selection) return false; + + const [match] = Array.from( + Editor.nodes(editor, { + at: Editor.unhangRange(editor, selection), + match: (n) => + !Editor.isEditor(n) && + (n as CustomElement).type === format && + (level === undefined || (n as HeadingElement).level === level), + }), + ); + + return !!match; +}; + +const toggleBlock = (editor: CustomEditor, format: string, level?: number) => { + const isActive = isBlockActive(editor, format, level); + const newProperties: any = { + type: isActive ? "paragraph" : format, + }; + + if (format === "heading" && level && !isActive) { + newProperties.level = level; + } + + Transforms.setNodes(editor, newProperties); + + // Focus back to editor after toggling + ReactEditor.focus(editor); +}; + +export function TextEditor({ + value = "", + onChange, + onBlur, + placeholder = "Start writing...", + disabled = false, + className, +}: TextEditorProps) { + const editor = useMemo(() => withReact(createEditor()) as CustomEditor, []); + const [editorValue, setEditorValue] = useState<Descendant[]>(() => + deserialize(value), + ); + const [selection, setSelection] = useState(editor.selection); + + const renderElement = useCallback((props: RenderElementProps) => { + switch (props.element.type) { + case "heading": { + const element = props.element as HeadingElement; + const HeadingTag = `h${element.level || 1}` as + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6"; + return ( + <HeadingTag + {...props.attributes} + className={cn( + "font-bold", + element.level === 1 && "text-2xl mb-4", + element.level === 2 && "text-xl mb-3", + element.level === 3 && "text-lg mb-2", + )} + > + {props.children} + </HeadingTag> + ); + } + case "list-item": + return ( + <li {...props.attributes} className="ml-4 list-disc"> + {props.children} + </li> + ); + case "block-quote": + return ( + <blockquote + {...props.attributes} + className="border-l-4 border-white/20 pl-4 italic text-white/80" + > + {props.children} + </blockquote> + ); + default: + return ( + <p {...props.attributes} className="mb-2"> + {props.children} + </p> + ); + } + }, []); + + const renderLeaf = useCallback((props: RenderLeafProps) => { + let { attributes, children, leaf } = props; + + if (leaf.bold) { + children = <strong>{children}</strong>; + } + + if (leaf.italic) { + children = <em>{children}</em>; + } + + if (leaf.code) { + children = ( + <code className="bg-white/10 px-1 rounded text-sm">{children}</code> + ); + } + + return <span {...attributes}>{children}</span>; + }, []); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Handle hotkeys for formatting + for (const hotkey in HOTKEYS) { + if (isHotkey(hotkey, event)) { + event.preventDefault(); + const mark = HOTKEYS[hotkey]; + if (mark) { + toggleMark(editor, mark); + } + return; + } + } + + // Handle block formatting hotkeys + if (isHotkey("mod+shift+1", event)) { + event.preventDefault(); + toggleBlock(editor, "heading", 1); + return; + } + if (isHotkey("mod+shift+2", event)) { + event.preventDefault(); + toggleBlock(editor, "heading", 2); + return; + } + if (isHotkey("mod+shift+3", event)) { + event.preventDefault(); + toggleBlock(editor, "heading", 3); + return; + } + if (isHotkey("mod+shift+8", event)) { + event.preventDefault(); + toggleBlock(editor, "list-item"); + return; + } + if (isHotkey("mod+shift+.", event)) { + event.preventDefault(); + toggleBlock(editor, "block-quote"); + return; + } + }, + [editor], + ); + + const handleSlateChange = useCallback( + (newValue: Descendant[]) => { + setEditorValue(newValue); + const serializedValue = serialize(newValue); + onChange?.(serializedValue); + }, + [onChange], + ); + + // Memoized active states that update when selection changes + const activeStates = useMemo( + () => ({ + bold: isMarkActive(editor, "bold"), + italic: isMarkActive(editor, "italic"), + code: isMarkActive(editor, "code"), + heading1: isBlockActive(editor, "heading", 1), + heading2: isBlockActive(editor, "heading", 2), + heading3: isBlockActive(editor, "heading", 3), + listItem: isBlockActive(editor, "list-item"), + blockQuote: isBlockActive(editor, "block-quote"), + }), + [editor, selection], + ); + + const ToolbarButton = ({ + icon: Icon, + isActive, + onMouseDown, + title, + }: { + icon: React.ComponentType<{ className?: string }>; + isActive: boolean; + onMouseDown: (event: React.MouseEvent) => void; + title: string; + }) => ( + <Button + variant="ghost" + size="sm" + className={cn( + "h-8 w-8 !p-0 text-white/70 transition-all duration-200 rounded-sm", + "hover:bg-white/15 hover:text-white hover:scale-105", + "active:scale-95", + isActive && "bg-white/20 text-white", + )} + onMouseDown={onMouseDown} + title={title} + type="button" + > + <Icon + className={cn( + "h-4 w-4 transition-transform duration-200", + isActive && "scale-110", + )} + /> + </Button> + ); + + return ( + <div className={cn("flex flex-col", className)}> + <div className="flex-1 min-h-48 max-h-64 overflow-y-auto"> + <Slate + editor={editor} + initialValue={editorValue} + onValueChange={handleSlateChange} + onSelectionChange={() => setSelection(editor.selection)} + > + <Editable + renderElement={renderElement} + renderLeaf={renderLeaf} + placeholder={placeholder} + renderPlaceholder={({ children, attributes }) => { + return ( + <div {...attributes} className="mt-2"> + {children} + </div> + ); + }} + onKeyDown={handleKeyDown} + onBlur={onBlur} + readOnly={disabled} + className={cn( + "outline-none w-full h-full text-white placeholder:text-white/50", + disabled && "opacity-50 cursor-not-allowed", + )} + style={{ + minHeight: "11rem", + maxHeight: "15rem", + padding: "12px", + }} + /> + </Slate> + </div> + + {/* Toolbar */} + <div className="p-1 flex items-center gap-2 bg-white/5 backdrop-blur-sm rounded-b-md"> + <div className="flex items-center gap-1"> + {/* Text formatting */} + <ToolbarButton + icon={Bold} + isActive={activeStates.bold} + onMouseDown={(event) => { + event.preventDefault(); + toggleMark(editor, "bold"); + }} + title="Bold (Ctrl/Cmd+B)" + /> + <ToolbarButton + icon={Italic} + isActive={activeStates.italic} + onMouseDown={(event) => { + event.preventDefault(); + toggleMark(editor, "italic"); + }} + title="Italic (Ctrl/Cmd+I)" + /> + <ToolbarButton + icon={Code} + isActive={activeStates.code} + onMouseDown={(event) => { + event.preventDefault(); + toggleMark(editor, "code"); + }} + title="Code (Ctrl/Cmd+`)" + /> + </div> + + <div className="w-px h-6 bg-white/30 mx-2" /> + + <div className="flex items-center gap-1"> + {/* Block formatting */} + <ToolbarButton + icon={Heading1} + isActive={activeStates.heading1} + onMouseDown={(event) => { + event.preventDefault(); + toggleBlock(editor, "heading", 1); + }} + title="Heading 1 (Ctrl/Cmd+Shift+1)" + /> + <ToolbarButton + icon={Heading2} + isActive={activeStates.heading2} + onMouseDown={(event) => { + event.preventDefault(); + toggleBlock(editor, "heading", 2); + }} + title="Heading 2 (Ctrl/Cmd+Shift+2)" + /> + <ToolbarButton + icon={Heading3} + isActive={activeStates.heading3} + onMouseDown={(event) => { + event.preventDefault(); + toggleBlock(editor, "heading", 3); + }} + title="Heading 3" + /> + <ToolbarButton + icon={List} + isActive={activeStates.listItem} + onMouseDown={(event) => { + event.preventDefault(); + toggleBlock(editor, "list-item"); + }} + title="Bullet List" + /> + <ToolbarButton + icon={Quote} + isActive={activeStates.blockQuote} + onMouseDown={(event) => { + event.preventDefault(); + toggleBlock(editor, "block-quote"); + }} + title="Quote" + /> + </div> + </div> + </div> + ); +} diff --git a/apps/web/components/views/billing.tsx b/apps/web/components/views/billing.tsx index b0c436bb..033a3915 100644 --- a/apps/web/components/views/billing.tsx +++ b/apps/web/components/views/billing.tsx @@ -1,35 +1,35 @@ -import { useAuth } from "@lib/auth-context" +import { useAuth } from "@lib/auth-context"; import { fetchConnectionsFeature, fetchMemoriesFeature, fetchSubscriptionStatus, -} from "@lib/queries" -import { Button } from "@ui/components/button" -import { HeadingH3Bold } from "@ui/text/heading/heading-h3-bold" -import { useCustomer } from "autumn-js/react" -import { CheckCircle, LoaderIcon, X } from "lucide-react" -import { motion } from "motion/react" -import Link from "next/link" -import { useState, useEffect } from "react" -import { analytics } from "@/lib/analytics" +} from "@lib/queries"; +import { Button } from "@ui/components/button"; +import { HeadingH3Bold } from "@ui/text/heading/heading-h3-bold"; +import { useCustomer } from "autumn-js/react"; +import { CheckCircle, LoaderIcon, X } from "lucide-react"; +import { motion } from "motion/react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { analytics } from "@/lib/analytics"; export function BillingView() { - const autumn = useCustomer() - const { user } = useAuth() - const [isLoading, setIsLoading] = useState(false) + const autumn = useCustomer(); + const { user } = useAuth(); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { - analytics.billingViewed() - }, []) + analytics.billingViewed(); + }, []); - const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any) + const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any); - const memoriesUsed = memoriesCheck?.usage ?? 0 - const memoriesLimit = memoriesCheck?.included_usage ?? 0 + const memoriesUsed = memoriesCheck?.usage ?? 0; + const memoriesLimit = memoriesCheck?.included_usage ?? 0; - const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any) + const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any); - const connectionsUsed = connectionsCheck?.usage ?? 0 + const connectionsUsed = connectionsCheck?.usage ?? 0; // Fetch subscription status with React Query const { @@ -37,34 +37,34 @@ export function BillingView() { consumer_pro: null, }, isLoading: isCheckingStatus, - } = fetchSubscriptionStatus(autumn as any) + } = fetchSubscriptionStatus(autumn as any); // Handle upgrade const handleUpgrade = async () => { - analytics.upgradeInitiated() - setIsLoading(true) + analytics.upgradeInitiated(); + setIsLoading(true); try { await autumn.attach({ productId: "consumer_pro", successUrl: "https://app.supermemory.ai/", - }) - analytics.upgradeCompleted() - window.location.reload() + }); + analytics.upgradeCompleted(); + window.location.reload(); } catch (error) { - console.error(error) - setIsLoading(false) + console.error(error); + setIsLoading(false); } - } + }; // Handle manage billing const handleManageBilling = async () => { - analytics.billingPortalOpened() + analytics.billingPortalOpened(); await autumn.openBillingPortal({ returnUrl: "https://app.supermemory.ai", - }) - } + }); + }; - const isPro = status.consumer_pro + const isPro = status.consumer_pro; if (user?.isAnonymous) { return ( @@ -85,7 +85,7 @@ export function BillingView() { </Button> </motion.div> </motion.div> - ) + ); } if (isPro) { @@ -147,7 +147,7 @@ export function BillingView() { </Button> </motion.div> </motion.div> - ) + ); } return ( @@ -257,5 +257,5 @@ export function BillingView() { </p> </div> </motion.div> - ) + ); } diff --git a/apps/web/components/views/chat/chat-messages.tsx b/apps/web/components/views/chat/chat-messages.tsx index 0517ac8d..ab228ce3 100644 --- a/apps/web/components/views/chat/chat-messages.tsx +++ b/apps/web/components/views/chat/chat-messages.tsx @@ -1,322 +1,446 @@ -'use client' - -import { useChat, useCompletion } from '@ai-sdk/react' -import { DefaultChatTransport } from 'ai' -import { useEffect, useRef, useState } from 'react' -import { useProject, usePersistentChat } from '@/stores' -import { Button } from '@ui/components/button' -import { Input } from '@ui/components/input' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import { Spinner } from '../../spinner' -import { X, ArrowUp, Check, RotateCcw, Copy } from 'lucide-react' -import { useGraphHighlights } from '@/stores/highlights' -import { cn } from '@lib/utils' -import { TextShimmer } from '@/components/text-shimmer' -import { toast } from 'sonner' +"use client"; + +import { useChat, useCompletion } from "@ai-sdk/react"; +import { cn } from "@lib/utils"; +import { Button } from "@ui/components/button"; +import { Input } from "@ui/components/input"; +import { DefaultChatTransport } from "ai"; +import { ArrowUp, Check, Copy, RotateCcw, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { toast } from "sonner"; +import { TextShimmer } from "@/components/text-shimmer"; +import { usePersistentChat, useProject } from "@/stores"; +import { useGraphHighlights } from "@/stores/highlights"; +import { Spinner } from "../../spinner"; function useStickyAutoScroll(triggerKeys: ReadonlyArray<unknown>) { - const scrollContainerRef = useRef<HTMLDivElement>(null) - const bottomRef = useRef<HTMLDivElement>(null) - const [isAutoScroll, setIsAutoScroll] = useState(true) - const [isFarFromBottom, setIsFarFromBottom] = useState(false) - - function scrollToBottom(behavior: ScrollBehavior = 'auto') { - const node = bottomRef.current - if (node) node.scrollIntoView({ behavior, block: 'end' }) - } - - useEffect(function observeBottomVisibility() { - const container = scrollContainerRef.current - const sentinel = bottomRef.current - if (!container || !sentinel) return - - const observer = new IntersectionObserver((entries) => { - if (!entries || entries.length === 0) return - const isIntersecting = entries.some((e) => e.isIntersecting) - setIsAutoScroll(isIntersecting) - }, { root: container, rootMargin: '0px 0px 80px 0px', threshold: 0 }) - observer.observe(sentinel) - return () => observer.disconnect() - }, []) - - useEffect(function observeContentResize() { - const container = scrollContainerRef.current - if (!container) return - const resizeObserver = new ResizeObserver(() => { - if (isAutoScroll) scrollToBottom('auto') - const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight - setIsFarFromBottom(distanceFromBottom > 100) - }) - resizeObserver.observe(container) - return () => resizeObserver.disconnect() - }, [isAutoScroll]) - - function enableAutoScroll() { - setIsAutoScroll(true) - } - - useEffect(function autoScrollOnNewContent() { - if (isAutoScroll) scrollToBottom('auto') - }, [isAutoScroll, ...triggerKeys]) - - function recomputeDistanceFromBottom() { - const container = scrollContainerRef.current - if (!container) return - const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight - setIsFarFromBottom(distanceFromBottom > 100) - } - - useEffect(() => { - recomputeDistanceFromBottom() - }, [...triggerKeys]) - - function onScroll() { - recomputeDistanceFromBottom() - } - - return { scrollContainerRef, bottomRef, isAutoScroll, isFarFromBottom, onScroll, enableAutoScroll, scrollToBottom } as const + const scrollContainerRef = useRef<HTMLDivElement>(null); + const bottomRef = useRef<HTMLDivElement>(null); + const [isAutoScroll, setIsAutoScroll] = useState(true); + const [isFarFromBottom, setIsFarFromBottom] = useState(false); + + function scrollToBottom(behavior: ScrollBehavior = "auto") { + const node = bottomRef.current; + if (node) node.scrollIntoView({ behavior, block: "end" }); + } + + useEffect(function observeBottomVisibility() { + const container = scrollContainerRef.current; + const sentinel = bottomRef.current; + if (!container || !sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (!entries || entries.length === 0) return; + const isIntersecting = entries.some((e) => e.isIntersecting); + setIsAutoScroll(isIntersecting); + }, + { root: container, rootMargin: "0px 0px 80px 0px", threshold: 0 }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, []); + + useEffect( + function observeContentResize() { + const container = scrollContainerRef.current; + if (!container) return; + const resizeObserver = new ResizeObserver(() => { + if (isAutoScroll) scrollToBottom("auto"); + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + setIsFarFromBottom(distanceFromBottom > 100); + }); + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, + [isAutoScroll], + ); + + function enableAutoScroll() { + setIsAutoScroll(true); + } + + useEffect( + function autoScrollOnNewContent() { + if (isAutoScroll) scrollToBottom("auto"); + }, + [isAutoScroll, ...triggerKeys], + ); + + function recomputeDistanceFromBottom() { + const container = scrollContainerRef.current; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + setIsFarFromBottom(distanceFromBottom > 100); + } + + useEffect(() => { + recomputeDistanceFromBottom(); + }, [...triggerKeys]); + + function onScroll() { + recomputeDistanceFromBottom(); + } + + return { + scrollContainerRef, + bottomRef, + isAutoScroll, + isFarFromBottom, + onScroll, + enableAutoScroll, + scrollToBottom, + } as const; } export function ChatMessages() { - const { selectedProject } = useProject() - const { currentChatId, setCurrentChatId, setConversation, getCurrentConversation, setConversationTitle, getCurrentChat } = usePersistentChat() - - const activeChatIdRef = useRef<string | null>(null) - const shouldGenerateTitleRef = useRef<boolean>(false) - - const { setDocumentIds } = useGraphHighlights() - - const { messages, sendMessage, status, stop, setMessages, id, regenerate } = useChat({ - id: currentChatId ?? undefined, - transport: new DefaultChatTransport({ - api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat`, - credentials: 'include', - body: { metadata: { projectId: selectedProject } }, - }), - maxSteps: 2, - onFinish: (result) => { - const activeId = activeChatIdRef.current - if (!activeId) return - if (result.message.role !== 'assistant') return - - if (shouldGenerateTitleRef.current) { - const textPart = result.message.parts.find((p: any) => p?.type === 'text') as any - const text = textPart?.text?.trim() - if (text) { - shouldGenerateTitleRef.current = false - complete(text) - } - } - }, - }) - - useEffect(() => { - activeChatIdRef.current = (currentChatId ?? id) ?? null - }, [currentChatId, id]) - - useEffect(() => { - if (id && id !== currentChatId) { - setCurrentChatId(id) - } - }, [id, currentChatId, setCurrentChatId]) - - useEffect(() => { - const msgs = getCurrentConversation() - setMessages(msgs ?? []) - setInput('') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentChatId]) - - useEffect(() => { - const activeId = currentChatId ?? id - if (activeId && messages.length > 0) { - setConversation(activeId, messages) - } - }, [messages, currentChatId, id, setConversation]) - - const [input, setInput] = useState('') - const { complete } = useCompletion({ - api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/title`, - credentials: 'include', - onFinish: (_, completion) => { - const activeId = activeChatIdRef.current - if (!completion || !activeId) return - setConversationTitle(activeId, completion.trim()) - } - }) - - // Update graph highlights from the most recent tool-searchMemories output - useEffect(() => { - try { - const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant') - if (!lastAssistant) return - const lastSearchPart = [...(lastAssistant.parts as any[])] - .reverse() - .find((p) => p?.type === 'tool-searchMemories' && p?.state === 'output-available') - if (!lastSearchPart) return - const output = (lastSearchPart as any).output - const ids = Array.isArray(output?.results) - ? (output.results as any[]).map((r) => r?.documentId).filter(Boolean) as string[] - : [] - if (ids.length > 0) { - setDocumentIds(ids) - } - } catch { } - }, [messages, setDocumentIds]) - - useEffect(() => { - const currentSummary = getCurrentChat() - const hasTitle = Boolean(currentSummary?.title && currentSummary.title.trim().length > 0) - shouldGenerateTitleRef.current = !hasTitle - }, [currentChatId, id, getCurrentChat]) - const { scrollContainerRef, bottomRef, isFarFromBottom, onScroll, enableAutoScroll, scrollToBottom } = useStickyAutoScroll([messages, status]) - - return ( - <> - <div className="relative grow"> - <div ref={scrollContainerRef} onScroll={onScroll} className="flex flex-col gap-2 absolute inset-0 overflow-y-auto px-4 pt-4 pb-7 scroll-pb-7"> - {messages.map((message) => ( - <div key={message.id} className={cn('flex flex-col', message.role === 'user' ? 'items-end' : 'items-start')}> - <div className="flex flex-col gap-2 max-w-4/5 bg-white/10 py-3 px-4 rounded-lg"> - {message.parts - .filter((part) => ['text', 'tool-searchMemories', 'tool-addMemory'].includes(part.type)) - .map((part, index) => { - switch (part.type) { - case 'text': - return ( - <div key={index} className="prose prose-sm prose-invert max-w-none"> - <ReactMarkdown remarkPlugins={[remarkGfm]}>{(part as any).text}</ReactMarkdown> - </div> - ) - case 'tool-searchMemories': - switch (part.state) { - case 'input-available': - case 'input-streaming': - return ( - <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> - <Spinner className="size-4" /> Searching memories... - </div> - ) - case 'output-error': - return ( - <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> - <X className="size-4" /> Error recalling memories - </div> - ) - case 'output-available': - { - const output = (part as any).output - const foundCount = typeof output === 'object' && output !== null && 'count' in output ? Number(output.count) || 0 : 0 - const ids = Array.isArray(output?.results) ? (output.results as any[]).map((r) => r?.documentId).filter(Boolean) as string[] : [] - return ( - <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> - <Check className="size-4" /> Found {foundCount} memories - </div> - ) - } - - } - case 'tool-addMemory': - switch (part.state) { - case 'input-available': - return ( - <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> - <Spinner className="size-4" /> Adding memory... - </div> - ) - case 'output-error': - return ( - <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> - <X className="size-4" /> Error adding memory - </div> - ) - case 'output-available': - return ( - <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> - <Check className="size-4" /> Memory added - </div> - ) - case 'input-streaming': - return ( - <div key={index} className="text-sm flex items-center gap-2 text-muted-foreground"> - <Spinner className="size-4" /> Adding memory... - </div> - ) - } - } - - return null - })} - </div> - {message.role === 'assistant' && ( - <div className='flex items-center gap-0.5 mt-0.5'> - <Button variant="ghost" size="icon" className='size-7 text-muted-foreground hover:text-foreground' onClick={() => { - navigator.clipboard.writeText(message.parts.filter((p) => p.type === 'text')?.map((p) => (p as any).text).join('\n') ?? '') - toast.success('Copied to clipboard') - }}> - <Copy className="size-3.5" /> - </Button> - <Button variant="ghost" size="icon" className='size-6 text-muted-foreground hover:text-foreground' onClick={() => regenerate({ messageId: message.id })}> - <RotateCcw className="size-3.5" /> - </Button> - - </div> - )} - </div> - ))} - {status === 'submitted' && ( - <div className="flex text-muted-foreground justify-start gap-2 px-4 py-3 items-center w-full"> - <Spinner className="size-4" /> - <TextShimmer className='text-sm' duration={1.5}>Thinking...</TextShimmer> - </div> - )} - <div ref={bottomRef} /> - </div> - - <Button - type="button" - onClick={() => { - enableAutoScroll() - scrollToBottom('smooth') - }} - className={cn( - 'rounded-full w-fit mx-auto shadow-md z-10 absolute inset-x-0 bottom-4 flex justify-center', - 'transition-all duration-200 ease-out', - isFarFromBottom ? 'opacity-100 scale-100 pointer-events-auto' : 'opacity-0 scale-95 pointer-events-none', - )} - variant="default" - size="sm" - > - Scroll to bottom - </Button> - </div> - - <form - className="flex gap-2 px-4 pb-4 pt-1 relative" - onSubmit={(e) => { - e.preventDefault() - if (status === 'submitted') return - if (status === 'streaming') { - stop() - return - } - if (input.trim()) { - enableAutoScroll() - scrollToBottom('auto') - sendMessage({ text: input }) - setInput('') - } - }} - > - <div className="absolute top-0 left-0 -mt-7 w-full h-7 bg-gradient-to-t from-background to-transparent" /> - <Input className="w-full" value={input} onChange={(e) => setInput(e.target.value)} disabled={status === 'submitted'} placeholder="Say something..." /> - <Button type="submit" disabled={status === 'submitted'}> - {status === 'ready' ? <ArrowUp className="size-4" /> : status === 'submitted' ? <Spinner className="size-4" /> : <X className="size-4" />} - </Button> - </form> - </> - ) + const { selectedProject } = useProject(); + const { + currentChatId, + setCurrentChatId, + setConversation, + getCurrentConversation, + setConversationTitle, + getCurrentChat, + } = usePersistentChat(); + + const activeChatIdRef = useRef<string | null>(null); + const shouldGenerateTitleRef = useRef<boolean>(false); + + const { setDocumentIds } = useGraphHighlights(); + + const { messages, sendMessage, status, stop, setMessages, id, regenerate } = + useChat({ + id: currentChatId ?? undefined, + transport: new DefaultChatTransport({ + api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat`, + credentials: "include", + body: { metadata: { projectId: selectedProject } }, + }), + maxSteps: 2, + onFinish: (result) => { + const activeId = activeChatIdRef.current; + if (!activeId) return; + if (result.message.role !== "assistant") return; + + if (shouldGenerateTitleRef.current) { + const textPart = result.message.parts.find( + (p: any) => p?.type === "text", + ) as any; + const text = textPart?.text?.trim(); + if (text) { + shouldGenerateTitleRef.current = false; + complete(text); + } + } + }, + }); + + useEffect(() => { + activeChatIdRef.current = currentChatId ?? id ?? null; + }, [currentChatId, id]); + + useEffect(() => { + if (id && id !== currentChatId) { + setCurrentChatId(id); + } + }, [id, currentChatId, setCurrentChatId]); + + useEffect(() => { + const msgs = getCurrentConversation(); + setMessages(msgs ?? []); + setInput(""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentChatId]); + + useEffect(() => { + const activeId = currentChatId ?? id; + if (activeId && messages.length > 0) { + setConversation(activeId, messages); + } + }, [messages, currentChatId, id, setConversation]); + + const [input, setInput] = useState(""); + const { complete } = useCompletion({ + api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/title`, + credentials: "include", + onFinish: (_, completion) => { + const activeId = activeChatIdRef.current; + if (!completion || !activeId) return; + setConversationTitle(activeId, completion.trim()); + }, + }); + + // Update graph highlights from the most recent tool-searchMemories output + useEffect(() => { + try { + const lastAssistant = [...messages] + .reverse() + .find((m) => m.role === "assistant"); + if (!lastAssistant) return; + const lastSearchPart = [...(lastAssistant.parts as any[])] + .reverse() + .find( + (p) => + p?.type === "tool-searchMemories" && + p?.state === "output-available", + ); + if (!lastSearchPart) return; + const output = (lastSearchPart as any).output; + const ids = Array.isArray(output?.results) + ? ((output.results as any[]) + .map((r) => r?.documentId) + .filter(Boolean) as string[]) + : []; + if (ids.length > 0) { + setDocumentIds(ids); + } + } catch {} + }, [messages, setDocumentIds]); + + useEffect(() => { + const currentSummary = getCurrentChat(); + const hasTitle = Boolean( + currentSummary?.title && currentSummary.title.trim().length > 0, + ); + shouldGenerateTitleRef.current = !hasTitle; + }, [currentChatId, id, getCurrentChat]); + const { + scrollContainerRef, + bottomRef, + isFarFromBottom, + onScroll, + enableAutoScroll, + scrollToBottom, + } = useStickyAutoScroll([messages, status]); + + return ( + <> + <div className="relative grow"> + <div + ref={scrollContainerRef} + onScroll={onScroll} + className="flex flex-col gap-2 absolute inset-0 overflow-y-auto px-4 pt-4 pb-7 scroll-pb-7" + > + {messages.map((message) => ( + <div + key={message.id} + className={cn( + "flex flex-col", + message.role === "user" ? "items-end" : "items-start", + )} + > + <div className="flex flex-col gap-2 max-w-4/5 bg-white/10 py-3 px-4 rounded-lg"> + {message.parts + .filter((part) => + ["text", "tool-searchMemories", "tool-addMemory"].includes( + part.type, + ), + ) + .map((part, index) => { + switch (part.type) { + case "text": + return ( + <div + key={index} + className="prose prose-sm prose-invert max-w-none" + > + <ReactMarkdown remarkPlugins={[remarkGfm]}> + {(part as any).text} + </ReactMarkdown> + </div> + ); + case "tool-searchMemories": + switch (part.state) { + case "input-available": + case "input-streaming": + return ( + <div + key={index} + className="text-sm flex items-center gap-2 text-muted-foreground" + > + <Spinner className="size-4" /> Searching + memories... + </div> + ); + case "output-error": + return ( + <div + key={index} + className="text-sm flex items-center gap-2 text-muted-foreground" + > + <X className="size-4" /> Error recalling + memories + </div> + ); + case "output-available": { + const output = (part as any).output; + const foundCount = + typeof output === "object" && + output !== null && + "count" in output + ? Number(output.count) || 0 + : 0; + const ids = Array.isArray(output?.results) + ? ((output.results as any[]) + .map((r) => r?.documentId) + .filter(Boolean) as string[]) + : []; + return ( + <div + key={index} + className="text-sm flex items-center gap-2 text-muted-foreground" + > + <Check className="size-4" /> Found {foundCount}{" "} + memories + </div> + ); + } + } + case "tool-addMemory": + switch (part.state) { + case "input-available": + return ( + <div + key={index} + className="text-sm flex items-center gap-2 text-muted-foreground" + > + <Spinner className="size-4" /> Adding memory... + </div> + ); + case "output-error": + return ( + <div + key={index} + className="text-sm flex items-center gap-2 text-muted-foreground" + > + <X className="size-4" /> Error adding memory + </div> + ); + case "output-available": + return ( + <div + key={index} + className="text-sm flex items-center gap-2 text-muted-foreground" + > + <Check className="size-4" /> Memory added + </div> + ); + case "input-streaming": + return ( + <div + key={index} + className="text-sm flex items-center gap-2 text-muted-foreground" + > + <Spinner className="size-4" /> Adding memory... + </div> + ); + } + } + + return null; + })} + </div> + {message.role === "assistant" && ( + <div className="flex items-center gap-0.5 mt-0.5"> + <Button + variant="ghost" + size="icon" + className="size-7 text-muted-foreground hover:text-foreground" + onClick={() => { + navigator.clipboard.writeText( + message.parts + .filter((p) => p.type === "text") + ?.map((p) => (p as any).text) + .join("\n") ?? "", + ); + toast.success("Copied to clipboard"); + }} + > + <Copy className="size-3.5" /> + </Button> + <Button + variant="ghost" + size="icon" + className="size-6 text-muted-foreground hover:text-foreground" + onClick={() => regenerate({ messageId: message.id })} + > + <RotateCcw className="size-3.5" /> + </Button> + </div> + )} + </div> + ))} + {status === "submitted" && ( + <div className="flex text-muted-foreground justify-start gap-2 px-4 py-3 items-center w-full"> + <Spinner className="size-4" /> + <TextShimmer className="text-sm" duration={1.5}> + Thinking... + </TextShimmer> + </div> + )} + <div ref={bottomRef} /> + </div> + + <Button + type="button" + onClick={() => { + enableAutoScroll(); + scrollToBottom("smooth"); + }} + className={cn( + "rounded-full w-fit mx-auto shadow-md z-10 absolute inset-x-0 bottom-4 flex justify-center", + "transition-all duration-200 ease-out", + isFarFromBottom + ? "opacity-100 scale-100 pointer-events-auto" + : "opacity-0 scale-95 pointer-events-none", + )} + variant="default" + size="sm" + > + Scroll to bottom + </Button> + </div> + + <form + className="flex gap-2 px-4 pb-4 pt-1 relative" + onSubmit={(e) => { + e.preventDefault(); + if (status === "submitted") return; + if (status === "streaming") { + stop(); + return; + } + if (input.trim()) { + enableAutoScroll(); + scrollToBottom("auto"); + sendMessage({ text: input }); + setInput(""); + } + }} + > + <div className="absolute top-0 left-0 -mt-7 w-full h-7 bg-gradient-to-t from-background to-transparent" /> + <Input + className="w-full" + value={input} + onChange={(e) => setInput(e.target.value)} + disabled={status === "submitted"} + placeholder="Say something..." + /> + <Button type="submit" disabled={status === "submitted"}> + {status === "ready" ? ( + <ArrowUp className="size-4" /> + ) : status === "submitted" ? ( + <Spinner className="size-4" /> + ) : ( + <X className="size-4" /> + )} + </Button> + </form> + </> + ); } - - diff --git a/apps/web/components/views/chat/index.tsx b/apps/web/components/views/chat/index.tsx index a547f590..2b7befa1 100644 --- a/apps/web/components/views/chat/index.tsx +++ b/apps/web/components/views/chat/index.tsx @@ -1,124 +1,156 @@ -'use client'; +"use client"; -import { useChatOpen, useProject } from '@/stores'; -import { usePersistentChat } from '@/stores'; -import { Button } from '@ui/components/button'; -import { Plus, X, Trash2, HistoryIcon } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@ui/components/dialog"; -import { ScrollArea } from '@ui/components/scroll-area'; -import { cn } from '@lib/utils'; -import { ChatMessages } from './chat-messages'; -import { formatDistanceToNow } from 'date-fns'; -import { analytics } from '@/lib/analytics'; +import { cn } from "@lib/utils"; +import { Button } from "@ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ui/components/dialog"; +import { ScrollArea } from "@ui/components/scroll-area"; +import { formatDistanceToNow } from "date-fns"; +import { HistoryIcon, Plus, Trash2, X } from "lucide-react"; +import { useMemo, useState } from "react"; +import { analytics } from "@/lib/analytics"; +import { useChatOpen, usePersistentChat, useProject } from "@/stores"; +import { ChatMessages } from "./chat-messages"; export function ChatRewrite() { - const { setIsOpen } = useChatOpen(); - const { selectedProject } = useProject(); - const { conversations, currentChatId, setCurrentChatId, getCurrentChat } = usePersistentChat(); + const { setIsOpen } = useChatOpen(); + const { selectedProject } = useProject(); + const { conversations, currentChatId, setCurrentChatId, getCurrentChat } = + usePersistentChat(); - const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); - const sorted = useMemo(() => { - return [...conversations].sort((a, b) => (a.lastUpdated < b.lastUpdated ? 1 : -1)); - }, [conversations]); + const sorted = useMemo(() => { + return [...conversations].sort((a, b) => + a.lastUpdated < b.lastUpdated ? 1 : -1, + ); + }, [conversations]); - function handleNewChat() { - analytics.newChatStarted(); - const newId = crypto.randomUUID(); - setCurrentChatId(newId); - setIsDialogOpen(false); - } + function handleNewChat() { + analytics.newChatStarted(); + const newId = crypto.randomUUID(); + setCurrentChatId(newId); + setIsDialogOpen(false); + } - function formatRelativeTime(isoString: string): string { - return formatDistanceToNow(new Date(isoString), { addSuffix: true }); - } + function formatRelativeTime(isoString: string): string { + return formatDistanceToNow(new Date(isoString), { addSuffix: true }); + } - return ( - <div className='flex flex-col h-full overflow-y-hidden border-l bg-background'> - <div className="border-b px-4 py-3 flex justify-between items-center"> - <h3 className="text-lg font-semibold line-clamp-1 text-ellipsis overflow-hidden">{getCurrentChat()?.title ?? "New Chat"}</h3> - <div className="flex items-center gap-2"> - <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> - <DialogTrigger asChild> - <Button variant="outline" size="icon" onClick={() => analytics.chatHistoryViewed()}> - <HistoryIcon className='size-4 text-muted-foreground' /> - </Button> - </DialogTrigger> - <DialogContent className="sm:max-w-lg"> - <DialogHeader className="pb-4 border-b rounded-t-lg"> - <DialogTitle className=""> - Conversations - </DialogTitle> - <DialogDescription> - Project <span className="font-mono font-medium">{selectedProject}</span> - </DialogDescription> - </DialogHeader> + return ( + <div className="flex flex-col h-full overflow-y-hidden border-l bg-background"> + <div className="border-b px-4 py-3 flex justify-between items-center"> + <h3 className="text-lg font-semibold line-clamp-1 text-ellipsis overflow-hidden"> + {getCurrentChat()?.title ?? "New Chat"} + </h3> + <div className="flex items-center gap-2"> + <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> + <DialogTrigger asChild> + <Button + variant="outline" + size="icon" + onClick={() => analytics.chatHistoryViewed()} + > + <HistoryIcon className="size-4 text-muted-foreground" /> + </Button> + </DialogTrigger> + <DialogContent className="sm:max-w-lg"> + <DialogHeader className="pb-4 border-b rounded-t-lg"> + <DialogTitle className="">Conversations</DialogTitle> + <DialogDescription> + Project{" "} + <span className="font-mono font-medium"> + {selectedProject} + </span> + </DialogDescription> + </DialogHeader> - <ScrollArea className="max-h-96"> - <div className="flex flex-col gap-1"> - {sorted.map((c) => { - const isActive = c.id === currentChatId; - return ( - <div - key={c.id} - role="button" - tabIndex={0} - onClick={() => { - setCurrentChatId(c.id); - setIsDialogOpen(false); - }} - className={cn( - 'flex items-center justify-between rounded-md px-3 py-2 outline-none', - 'transition-colors', - isActive ? 'bg-primary/10' : 'hover:bg-muted' - )} - aria-current={isActive ? 'true' : undefined} - > - <div className="min-w-0"> - <div className="flex items-center gap-2"> - <span className={cn('text-sm font-medium truncate', isActive ? 'text-foreground' : undefined)}> - {c.title || 'Untitled Chat'} - </span> - </div> - <div className="text-xs text-muted-foreground"> - Last updated {formatRelativeTime(c.lastUpdated)} - </div> - </div> - <Button - type="button" - variant="ghost" - size="icon" - onClick={(e) => { - e.stopPropagation(); - analytics.chatDeleted(); - }} - aria-label="Delete conversation" - > - <Trash2 className="size-4 text-muted-foreground" /> - </Button> - </div> - ); - })} - {sorted.length === 0 && ( - <div className="text-xs text-muted-foreground px-3 py-2">No conversations yet</div> - )} - </div> - </ScrollArea> - <Button variant="outline" size="lg" className="w-full border-dashed" onClick={handleNewChat}> - <Plus className='size-4 mr-1' /> New Conversation - </Button> - </DialogContent> - </Dialog> - <Button variant="outline" size="icon" onClick={handleNewChat}> - <Plus className='size-4 text-muted-foreground' /> - </Button> - <Button variant="outline" size="icon" onClick={() => setIsOpen(false)}> - <X className='size-4 text-muted-foreground' /> - </Button> - </div> - </div> - <ChatMessages /> - </div > - ); -}
\ No newline at end of file + <ScrollArea className="max-h-96"> + <div className="flex flex-col gap-1"> + {sorted.map((c) => { + const isActive = c.id === currentChatId; + return ( + <div + key={c.id} + role="button" + tabIndex={0} + onClick={() => { + setCurrentChatId(c.id); + setIsDialogOpen(false); + }} + className={cn( + "flex items-center justify-between rounded-md px-3 py-2 outline-none", + "transition-colors", + isActive ? "bg-primary/10" : "hover:bg-muted", + )} + aria-current={isActive ? "true" : undefined} + > + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <span + className={cn( + "text-sm font-medium truncate", + isActive ? "text-foreground" : undefined, + )} + > + {c.title || "Untitled Chat"} + </span> + </div> + <div className="text-xs text-muted-foreground"> + Last updated {formatRelativeTime(c.lastUpdated)} + </div> + </div> + <Button + type="button" + variant="ghost" + size="icon" + onClick={(e) => { + e.stopPropagation(); + analytics.chatDeleted(); + }} + aria-label="Delete conversation" + > + <Trash2 className="size-4 text-muted-foreground" /> + </Button> + </div> + ); + })} + {sorted.length === 0 && ( + <div className="text-xs text-muted-foreground px-3 py-2"> + No conversations yet + </div> + )} + </div> + </ScrollArea> + <Button + variant="outline" + size="lg" + className="w-full border-dashed" + onClick={handleNewChat} + > + <Plus className="size-4 mr-1" /> New Conversation + </Button> + </DialogContent> + </Dialog> + <Button variant="outline" size="icon" onClick={handleNewChat}> + <Plus className="size-4 text-muted-foreground" /> + </Button> + <Button + variant="outline" + size="icon" + onClick={() => setIsOpen(false)} + > + <X className="size-4 text-muted-foreground" /> + </Button> + </div> + </div> + <ChatMessages /> + </div> + ); +} diff --git a/apps/web/components/views/connections-tab-content.tsx b/apps/web/components/views/connections-tab-content.tsx index a94ed708..64aa35ef 100644 --- a/apps/web/components/views/connections-tab-content.tsx +++ b/apps/web/components/views/connections-tab-content.tsx @@ -1,342 +1,342 @@ -'use client'; +"use client"; -import { $fetch } from '@lib/api'; +import { $fetch } from "@lib/api"; import { - fetchConnectionsFeature, - fetchConsumerProProduct, -} from '@repo/lib/queries'; -import { Button } from '@repo/ui/components/button'; + fetchConnectionsFeature, + fetchConsumerProProduct, +} from "@repo/lib/queries"; +import { Button } from "@repo/ui/components/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@repo/ui/components/dialog'; -import { Skeleton } from '@repo/ui/components/skeleton'; -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 { Trash2 } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import Link from 'next/link'; -import { useEffect } from 'react'; -import { toast } from 'sonner'; -import type { z } from 'zod'; -import { useProject } from '@/stores'; -import { analytics } from '@/lib/analytics'; + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog"; +import { Skeleton } from "@repo/ui/components/skeleton"; +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 { Trash2 } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import Link from "next/link"; +import { useEffect } from "react"; +import { toast } from "sonner"; +import type { z } from "zod"; +import { analytics } from "@/lib/analytics"; +import { useProject } from "@/stores"; // Define types type Connection = z.infer<typeof ConnectionResponseSchema>; // Connector configurations const CONNECTORS = { - '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, - }, + "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; type ConnectorProvider = keyof typeof CONNECTORS; export function ConnectionsTabContent() { - const queryClient = useQueryClient(); - const { selectedProject } = useProject(); - const autumn = useCustomer(); + const queryClient = useQueryClient(); + const { selectedProject } = useProject(); + const autumn = useCustomer(); - const handleUpgrade = async () => { - try { - await autumn.attach({ - productId: 'consumer_pro', - successUrl: 'https://app.supermemory.ai/', - }); - window.location.reload(); - } catch (error) { - console.error(error); - } - }; + const handleUpgrade = async () => { + try { + await autumn.attach({ + productId: "consumer_pro", + successUrl: "https://app.supermemory.ai/", + }); + window.location.reload(); + } catch (error) { + console.error(error); + } + }; - const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any); - const connectionsUsed = connectionsCheck?.balance ?? 0; - const connectionsLimit = connectionsCheck?.included_usage ?? 0; + const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any); + const connectionsUsed = connectionsCheck?.balance ?? 0; + const connectionsLimit = connectionsCheck?.included_usage ?? 0; - const { data: proCheck } = fetchConsumerProProduct(autumn as any); - const isProUser = proCheck?.allowed ?? false; + const { data: proCheck } = fetchConsumerProProduct(autumn as any); + const isProUser = proCheck?.allowed ?? false; - const canAddConnection = connectionsUsed < connectionsLimit; + const canAddConnection = connectionsUsed < connectionsLimit; - // Fetch connections - const { - data: connections = [], - isLoading, - error, - } = useQuery({ - queryKey: ['connections'], - queryFn: async () => { - const response = await $fetch('@post/connections/list', { - body: { - containerTags: [], - }, - }); + // Fetch connections + const { + data: connections = [], + isLoading, + error, + } = 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' - ); - } + if (response.error) { + throw new Error( + response.error?.message || "Failed to load connections", + ); + } - return response.data as Connection[]; - }, - staleTime: 30 * 1000, - refetchInterval: 60 * 1000, - }); + return response.data as Connection[]; + }, + staleTime: 30 * 1000, + refetchInterval: 60 * 1000, + }); - // Show error toast if connections fail to load - useEffect(() => { - if (error) { - toast.error('Failed to load connections', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } - }, [error]); + // Show error toast if connections fail to load + useEffect(() => { + if (error) { + toast.error("Failed to load connections", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }, [error]); - // Add connection mutation - const addConnectionMutation = useMutation({ - mutationFn: async (provider: ConnectorProvider) => { - // Check if user can add connections - if (!canAddConnection && !isProUser) { - throw new Error( - "Free plan doesn't include connections. Upgrade to Pro for unlimited connections." - ); - } + // Add connection mutation + const addConnectionMutation = useMutation({ + mutationFn: async (provider: ConnectorProvider) => { + // Check if user can add connections + 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], - }, - }); + 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; - } + // 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, provider) => { - analytics.connectionAdded(provider); - analytics.connectionAuthStarted(); - if (data?.authLink) { - window.location.href = data.authLink; - } - }, - onError: (error, provider) => { - analytics.connectionAuthFailed(); - toast.error(`Failed to connect ${provider}`, { - description: error instanceof Error ? error.message : 'Unknown error', - }); - }, - }); + throw new Error(response.error?.message || "Failed to connect"); + }, + onSuccess: (data, provider) => { + analytics.connectionAdded(provider); + analytics.connectionAuthStarted(); + if (data?.authLink) { + window.location.href = data.authLink; + } + }, + onError: (error, provider) => { + analytics.connectionAuthFailed(); + toast.error(`Failed to connect ${provider}`, { + description: error instanceof Error ? error.message : "Unknown error", + }); + }, + }); - // 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', - }); - }, - }); + // 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", + }); + }, + }); - const getProviderIcon = (provider: string) => { - const connector = CONNECTORS[provider as ConnectorProvider]; - if (connector) { - const Icon = connector.icon; - return <Icon className="h-10 w-10" />; - } - return <span className="text-2xl">📎</span>; - }; + const getProviderIcon = (provider: string) => { + const connector = CONNECTORS[provider as ConnectorProvider]; + if (connector) { + const Icon = connector.icon; + return <Icon className="h-10 w-10" />; + } + return <span className="text-2xl">📎</span>; + }; - return ( - <div className="space-y-4"> - <div className="mb-4"> - <p className="text-sm text-white/70"> - Connect your favorite services to import documents - </p> - {!isProUser && ( - <p className="text-xs text-white/50 mt-1"> - Connections require a Pro subscription - </p> - )} - </div> + return ( + <div className="space-y-4"> + <div className="mb-4"> + <p className="text-sm text-white/70"> + Connect your favorite services to import documents + </p> + {!isProUser && ( + <p className="text-xs text-white/50 mt-1"> + Connections require a Pro subscription + </p> + )} + </div> - {/* Show upgrade prompt for free users */} - {!isProUser && ( - <motion.div - animate={{ opacity: 1, y: 0 }} - className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg" - initial={{ opacity: 0, y: -10 }} - > - <p className="text-sm text-yellow-400 mb-2"> - 🔌 Connections are a Pro feature - </p> - <p className="text-xs text-white/60 mb-3"> - Connect Google Drive, Notion, OneDrive and more to automatically - sync your documents. - </p> - <Button - asChild - className="bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border-yellow-500/30" - onClick={handleUpgrade} - size="sm" - variant="secondary" - > - Upgrade to Pro - </Button> - </motion.div> - )} + {/* Show upgrade prompt for free users */} + {!isProUser && ( + <motion.div + animate={{ opacity: 1, y: 0 }} + className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg" + initial={{ opacity: 0, y: -10 }} + > + <p className="text-sm text-yellow-400 mb-2"> + 🔌 Connections are a Pro feature + </p> + <p className="text-xs text-white/60 mb-3"> + Connect Google Drive, Notion, OneDrive and more to automatically + sync your documents. + </p> + <Button + asChild + className="bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border-yellow-500/30" + onClick={handleUpgrade} + size="sm" + variant="secondary" + > + Upgrade to Pro + </Button> + </motion.div> + )} - {isLoading ? ( - <div className="space-y-3"> - {[...Array(2)].map((_, i) => ( - <motion.div - animate={{ opacity: 1 }} - className="p-4 bg-white/5 rounded-lg" - initial={{ opacity: 0 }} - key={`skeleton-${Date.now()}-${i}`} - transition={{ delay: i * 0.1 }} - > - <Skeleton className="h-12 w-full bg-white/10" /> - </motion.div> - ))} - </div> - ) : connections.length === 0 ? ( - <motion.div - animate={{ opacity: 1, scale: 1 }} - className="text-center py-4" - initial={{ opacity: 0, scale: 0.9 }} - transition={{ type: 'spring', damping: 20 }} - > - <p className="text-white/50 mb-2">No connections yet</p> - <p className="text-xs text-white/40"> - Choose a service below to connect - </p> - </motion.div> - ) : ( - <motion.div className="space-y-2"> - <AnimatePresence> - {connections.map((connection, index) => ( - <motion.div - animate={{ opacity: 1, x: 0 }} - className="flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors" - exit={{ opacity: 0, x: 20 }} - initial={{ opacity: 0, x: -20 }} - key={connection.id} - layout - transition={{ delay: index * 0.05 }} - > - <div className="flex items-center gap-3"> - <motion.div - animate={{ rotate: 0, opacity: 1 }} - initial={{ rotate: -180, opacity: 0 }} - transition={{ delay: index * 0.05 + 0.2 }} - > - {getProviderIcon(connection.provider)} - </motion.div> - <div> - <p className="font-medium text-white capitalize"> - {connection.provider.replace('-', ' ')} - </p> - {connection.email && ( - <p className="text-sm text-white/60"> - {connection.email} - </p> - )} - </div> - </div> - <motion.div - whileHover={{ scale: 1.1 }} - whileTap={{ scale: 0.9 }} - > - <Button - className="text-white/50 hover:text-red-400" - disabled={deleteConnectionMutation.isPending} - onClick={() => - deleteConnectionMutation.mutate(connection.id) - } - size="icon" - variant="ghost" - > - <Trash2 className="h-4 w-4" /> - </Button> - </motion.div> - </motion.div> - ))} - </AnimatePresence> - </motion.div> - )} + {isLoading ? ( + <div className="space-y-3"> + {[...Array(2)].map((_, i) => ( + <motion.div + animate={{ opacity: 1 }} + className="p-4 bg-white/5 rounded-lg" + initial={{ opacity: 0 }} + key={`skeleton-${Date.now()}-${i}`} + transition={{ delay: i * 0.1 }} + > + <Skeleton className="h-12 w-full bg-white/10" /> + </motion.div> + ))} + </div> + ) : connections.length === 0 ? ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="text-center py-4" + initial={{ opacity: 0, scale: 0.9 }} + transition={{ type: "spring", damping: 20 }} + > + <p className="text-white/50 mb-2">No connections yet</p> + <p className="text-xs text-white/40"> + Choose a service below to connect + </p> + </motion.div> + ) : ( + <motion.div className="space-y-2"> + <AnimatePresence> + {connections.map((connection, index) => ( + <motion.div + animate={{ opacity: 1, x: 0 }} + className="flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors" + exit={{ opacity: 0, x: 20 }} + initial={{ opacity: 0, x: -20 }} + key={connection.id} + layout + transition={{ delay: index * 0.05 }} + > + <div className="flex items-center gap-3"> + <motion.div + animate={{ rotate: 0, opacity: 1 }} + initial={{ rotate: -180, opacity: 0 }} + transition={{ delay: index * 0.05 + 0.2 }} + > + {getProviderIcon(connection.provider)} + </motion.div> + <div> + <p className="font-medium text-white capitalize"> + {connection.provider.replace("-", " ")} + </p> + {connection.email && ( + <p className="text-sm text-white/60"> + {connection.email} + </p> + )} + </div> + </div> + <motion.div + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + > + <Button + className="text-white/50 hover:text-red-400" + disabled={deleteConnectionMutation.isPending} + onClick={() => + deleteConnectionMutation.mutate(connection.id) + } + size="icon" + variant="ghost" + > + <Trash2 className="h-4 w-4" /> + </Button> + </motion.div> + </motion.div> + ))} + </AnimatePresence> + </motion.div> + )} - {/* Available Connections Section */} - <div className="mt-6"> - <h3 className="text-lg font-medium text-white mb-4"> - Available Connections - </h3> - <div className="grid gap-3"> - {Object.entries(CONNECTORS).map(([provider, config], index) => { - const Icon = config.icon; - return ( - <motion.div - animate={{ opacity: 1, y: 0 }} - initial={{ opacity: 0, y: 20 }} - key={provider} - transition={{ delay: index * 0.05 }} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - <Button - className="justify-start h-auto p-4 bg-white/5 hover:bg-white/10 border-white/10 text-white w-full" - disabled={addConnectionMutation.isPending} - onClick={() => { - addConnectionMutation.mutate(provider as ConnectorProvider); - }} - variant="outline" - > - <Icon className="h-8 w-8 mr-3" /> - <div className="text-left"> - <div className="font-medium">{config.title}</div> - <div className="text-sm text-white/60 mt-0.5"> - {config.description} - </div> - </div> - </Button> - </motion.div> - ); - })} - </div> - </div> - </div> - ); + {/* Available Connections Section */} + <div className="mt-6"> + <h3 className="text-lg font-medium text-white mb-4"> + Available Connections + </h3> + <div className="grid gap-3"> + {Object.entries(CONNECTORS).map(([provider, config], index) => { + const Icon = config.icon; + return ( + <motion.div + animate={{ opacity: 1, y: 0 }} + initial={{ opacity: 0, y: 20 }} + key={provider} + transition={{ delay: index * 0.05 }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + <Button + className="justify-start h-auto p-4 bg-white/5 hover:bg-white/10 border-white/10 text-white w-full" + disabled={addConnectionMutation.isPending} + onClick={() => { + addConnectionMutation.mutate(provider as ConnectorProvider); + }} + variant="outline" + > + <Icon className="h-8 w-8 mr-3" /> + <div className="text-left"> + <div className="font-medium">{config.title}</div> + <div className="text-sm text-white/60 mt-0.5"> + {config.description} + </div> + </div> + </Button> + </motion.div> + ); + })} + </div> + </div> + </div> + ); } diff --git a/apps/web/components/views/mcp/index.tsx b/apps/web/components/views/mcp/index.tsx index 41ef97b8..d9ffbd1a 100644 --- a/apps/web/components/views/mcp/index.tsx +++ b/apps/web/components/views/mcp/index.tsx @@ -1,9 +1,9 @@ -import { $fetch } from "@lib/api" -import { authClient } from "@lib/auth" -import { useAuth } from "@lib/auth-context" -import { useForm } from "@tanstack/react-form" -import { useMutation } from "@tanstack/react-query" -import { Button } from "@ui/components/button" +import { $fetch } from "@lib/api"; +import { authClient } from "@lib/auth"; +import { useAuth } from "@lib/auth-context"; +import { useForm } from "@tanstack/react-form"; +import { useMutation } from "@tanstack/react-query"; +import { Button } from "@ui/components/button"; import { Dialog, DialogContent, @@ -11,18 +11,18 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from "@ui/components/dialog" -import { Input } from "@ui/components/input" -import { CopyableCell } from "@ui/copyable-cell" -import { Loader2 } from "lucide-react" -import { AnimatePresence, motion } from "motion/react" -import Image from "next/image" -import { generateSlug } from "random-word-slugs" -import { useState, useEffect } from "react" -import { toast } from "sonner" -import { z } from "zod/v4" -import { InstallationDialogContent } from "./installation-dialog-content" -import { analytics } from "@/lib/analytics" +} from "@ui/components/dialog"; +import { Input } from "@ui/components/input"; +import { CopyableCell } from "@ui/copyable-cell"; +import { Loader2 } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import Image from "next/image"; +import { generateSlug } from "random-word-slugs"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { z } from "zod/v4"; +import { analytics } from "@/lib/analytics"; +import { InstallationDialogContent } from "./installation-dialog-content"; // Validation schemas const mcpMigrationSchema = z.object({ @@ -33,56 +33,56 @@ const mcpMigrationSchema = z.object({ /^https:\/\/mcp\.supermemory\.ai\/[^/]+\/sse$/, "Link must be in format: https://mcp.supermemory.ai/userId/sse", ), -}) +}); export function MCPView() { - const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false) - const projectId = localStorage.getItem("selectedProject") ?? "default" - const { org } = useAuth() - const [apiKey, setApiKey] = useState<string>() - const [isInstallDialogOpen, setIsInstallDialogOpen] = useState(false) + const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false); + const projectId = localStorage.getItem("selectedProject") ?? "default"; + const { org } = useAuth(); + const [apiKey, setApiKey] = useState<string>(); + const [isInstallDialogOpen, setIsInstallDialogOpen] = useState(false); useEffect(() => { - analytics.mcpViewOpened() - }, []) + analytics.mcpViewOpened(); + }, []); const apiKeyMutation = useMutation({ mutationFn: async () => { - if (apiKey) return apiKey + if (apiKey) return apiKey; const res = await authClient.apiKey.create({ metadata: { organizationId: org?.id, }, name: generateSlug(), prefix: `sm_${org?.id}_`, - }) - return res.key + }); + return res.key; }, onSuccess: (data) => { - setApiKey(data) - setIsInstallDialogOpen(true) + setApiKey(data); + setIsInstallDialogOpen(true); }, - }) + }); // Form for MCP migration const mcpMigrationForm = useForm({ defaultValues: { url: "" }, onSubmit: async ({ value, formApi }) => { - const userId = extractUserIdFromMCPUrl(value.url) + const userId = extractUserIdFromMCPUrl(value.url); if (userId) { - migrateMCPMutation.mutate({ userId, projectId }) - formApi.reset() + migrateMCPMutation.mutate({ userId, projectId }); + formApi.reset(); } }, validators: { onChange: mcpMigrationSchema, }, - }) + }); const extractUserIdFromMCPUrl = (url: string): string | null => { - const regex = /^https:\/\/mcp\.supermemory\.ai\/([^/]+)\/sse$/ - const match = url.trim().match(regex) - return match?.[1] || null - } + const regex = /^https:\/\/mcp\.supermemory\.ai\/([^/]+)\/sse$/; + const match = url.trim().match(regex); + return match?.[1] || null; + }; // Migrate MCP mutation const migrateMCPMutation = useMutation({ @@ -90,33 +90,33 @@ export function MCPView() { userId, projectId, }: { - userId: string - projectId: string + userId: string; + projectId: string; }) => { const response = await $fetch("@post/memories/migrate-mcp", { body: { userId, projectId }, - }) + }); if (response.error) { throw new Error( response.error?.message || "Failed to migrate documents", - ) + ); } - return response.data + return response.data; }, onSuccess: (data) => { toast.success("Migration completed!", { description: `Successfully migrated ${data?.migratedCount} documents`, - }) - setIsMigrateDialogOpen(false) + }); + setIsMigrateDialogOpen(false); }, onError: (error) => { toast.error("Migration failed", { description: error instanceof Error ? error.message : "Unknown error", - }) + }); }, - }) + }); return ( <div className="space-y-6"> @@ -155,9 +155,9 @@ export function MCPView() { <Button disabled={apiKeyMutation.isPending} onClick={(e) => { - e.preventDefault() - e.stopPropagation() - apiKeyMutation.mutate() + e.preventDefault(); + e.stopPropagation(); + apiKeyMutation.mutate(); }} > Install Now @@ -213,9 +213,9 @@ export function MCPView() { </DialogHeader> <form onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - mcpMigrationForm.handleSubmit() + e.preventDefault(); + e.stopPropagation(); + mcpMigrationForm.handleSubmit(); }} > <div className="grid gap-4"> @@ -268,8 +268,8 @@ export function MCPView() { <Button className="bg-white/5 hover:bg-white/10 border-white/10 text-white" onClick={() => { - setIsMigrateDialogOpen(false) - mcpMigrationForm.reset() + setIsMigrateDialogOpen(false); + mcpMigrationForm.reset(); }} type="button" variant="outline" @@ -307,5 +307,5 @@ export function MCPView() { )} </AnimatePresence> </div> - ) + ); } diff --git a/apps/web/components/views/mcp/installation-dialog-content.tsx b/apps/web/components/views/mcp/installation-dialog-content.tsx index 3a6b2f3e..d2813f75 100644 --- a/apps/web/components/views/mcp/installation-dialog-content.tsx +++ b/apps/web/components/views/mcp/installation-dialog-content.tsx @@ -1,22 +1,22 @@ -import { Button } from "@ui/components/button" +import { Button } from "@ui/components/button"; import { DialogContent, DialogDescription, DialogHeader, DialogTitle, -} from "@ui/components/dialog" -import { Input } from "@ui/components/input" +} from "@ui/components/dialog"; +import { Input } from "@ui/components/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@ui/components/select" -import { CopyIcon } from "lucide-react" -import { useState } from "react" -import { toast } from "sonner" -import { analytics } from "@/lib/analytics" +} from "@ui/components/select"; +import { CopyIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { analytics } from "@/lib/analytics"; const clients = { cursor: "Cursor", @@ -28,10 +28,10 @@ const clients = { enconvo: "Enconvo", "gemini-cli": "Gemini CLI", "claude-code": "Claude Code", -} as const +} as const; export function InstallationDialogContent() { - const [client, setClient] = useState<keyof typeof clients>("cursor") + const [client, setClient] = useState<keyof typeof clients>("cursor"); return ( <DialogContent> <DialogHeader> @@ -67,13 +67,13 @@ export function InstallationDialogContent() { onClick={() => { navigator.clipboard.writeText( `npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes`, - ) - analytics.mcpInstallCmdCopied() - toast.success("Copied to clipboard!") + ); + analytics.mcpInstallCmdCopied(); + toast.success("Copied to clipboard!"); }} > <CopyIcon className="size-4" /> Copy Installation Command </Button> </DialogContent> - ) + ); } diff --git a/apps/web/components/views/profile.tsx b/apps/web/components/views/profile.tsx index fd7334fe..336b9416 100644 --- a/apps/web/components/views/profile.tsx +++ b/apps/web/components/views/profile.tsx @@ -1,36 +1,43 @@ -"use client" +"use client"; -import { authClient } from "@lib/auth" -import { useAuth } from "@lib/auth-context" +import { authClient } from "@lib/auth"; +import { useAuth } from "@lib/auth-context"; import { fetchConnectionsFeature, fetchMemoriesFeature, fetchSubscriptionStatus, -} from "@lib/queries" -import { Button } from "@repo/ui/components/button" -import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold" -import { useCustomer } from "autumn-js/react" -import { CreditCard, LoaderIcon, LogOut, User, CheckCircle, X } from "lucide-react" -import { motion } from "motion/react" -import Link from "next/link" -import { usePathname, useRouter } from "next/navigation" -import { useState } from "react" -import { analytics } from "@/lib/analytics" +} from "@lib/queries"; +import { Button } from "@repo/ui/components/button"; +import { HeadingH3Bold } from "@repo/ui/text/heading/heading-h3-bold"; +import { useCustomer } from "autumn-js/react"; +import { + CheckCircle, + CreditCard, + LoaderIcon, + LogOut, + User, + X, +} from "lucide-react"; +import { motion } from "motion/react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useState } from "react"; +import { analytics } from "@/lib/analytics"; export function ProfileView() { - const router = useRouter() - const pathname = usePathname() - const { user: session, org } = useAuth() - const organizations = org - const autumn = useCustomer() - const [isLoading, setIsLoading] = useState(false) + const router = useRouter(); + const pathname = usePathname(); + const { user: session, org } = useAuth(); + const organizations = org; + const autumn = useCustomer(); + const [isLoading, setIsLoading] = useState(false); - const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any) - const memoriesUsed = memoriesCheck?.usage ?? 0 - const memoriesLimit = memoriesCheck?.included_usage ?? 0 + const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any); + const memoriesUsed = memoriesCheck?.usage ?? 0; + const memoriesLimit = memoriesCheck?.included_usage ?? 0; - const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any) - const connectionsUsed = connectionsCheck?.usage ?? 0 + const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any); + const connectionsUsed = connectionsCheck?.usage ?? 0; // Fetch subscription status with React Query const { @@ -38,36 +45,36 @@ export function ProfileView() { consumer_pro: null, }, isLoading: isCheckingStatus, - } = fetchSubscriptionStatus(autumn as any) + } = fetchSubscriptionStatus(autumn as any); - const isPro = status.consumer_pro + const isPro = status.consumer_pro; const handleLogout = () => { - analytics.userSignedOut() - authClient.signOut() - router.push("/login") - } + analytics.userSignedOut(); + authClient.signOut(); + router.push("/login"); + }; const handleUpgrade = async () => { - setIsLoading(true) + setIsLoading(true); try { await autumn.attach({ productId: "consumer_pro", successUrl: "https://app.supermemory.ai/", - }) - window.location.reload() + }); + window.location.reload(); } catch (error) { - console.error(error) - setIsLoading(false) + console.error(error); + setIsLoading(false); } - } + }; // Handle manage billing const handleManageBilling = async () => { await autumn.openBillingPortal({ returnUrl: "https://app.supermemory.ai", - }) - } + }); + }; if (session?.isAnonymous) { return ( @@ -78,7 +85,9 @@ export function ProfileView() { initial={{ opacity: 0, scale: 0.9 }} transition={{ type: "spring", damping: 20 }} > - <p className="text-white/70 mb-4">Sign in to access your profile and billing</p> + <p className="text-white/70 mb-4"> + Sign in to access your profile and billing + </p> <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <Button asChild @@ -90,7 +99,7 @@ export function ProfileView() { </motion.div> </motion.div> </div> - ) + ); } return ( @@ -133,14 +142,20 @@ export function ProfileView() { <div className="space-y-2"> <div className="flex justify-between items-center"> <span className="text-sm text-white/70">Memories</span> - <span className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`}> + <span + className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`} + > {memoriesUsed} / {memoriesLimit} </span> </div> <div className="w-full bg-white/10 rounded-full h-2"> <div className={`h-2 rounded-full transition-all ${ - memoriesUsed >= memoriesLimit ? "bg-red-500" : isPro ? "bg-green-500" : "bg-blue-500" + memoriesUsed >= memoriesLimit + ? "bg-red-500" + : isPro + ? "bg-green-500" + : "bg-blue-500" }`} style={{ width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`, @@ -196,12 +211,16 @@ export function ProfileView() { {/* Plan Comparison - Only show for free users */} {!isPro && ( <div className="bg-white/5 rounded-lg p-4 space-y-4"> - <HeadingH3Bold className="text-white text-sm">Upgrade to Pro</HeadingH3Bold> + <HeadingH3Bold className="text-white text-sm"> + Upgrade to Pro + </HeadingH3Bold> <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/* Free Plan */} <div className="p-3 bg-white/5 rounded-lg border border-white/10"> - <h4 className="font-medium text-white/90 mb-3 text-sm">Free Plan</h4> + <h4 className="font-medium text-white/90 mb-3 text-sm"> + Free Plan + </h4> <ul className="space-y-2"> <li className="flex items-center gap-2 text-sm text-white/70"> <CheckCircle className="h-4 w-4 text-green-400" /> @@ -248,7 +267,8 @@ export function ProfileView() { </div> <p className="text-xs text-white/50 text-center"> - $15/month (only for first 100 users) • Cancel anytime. No questions asked. + $15/month (only for first 100 users) • Cancel anytime. No questions + asked. </p> </div> )} @@ -262,5 +282,5 @@ export function ProfileView() { Sign Out </Button> </div> - ) + ); } diff --git a/apps/web/components/views/projects.tsx b/apps/web/components/views/projects.tsx index faa9a317..45e51f6c 100644 --- a/apps/web/components/views/projects.tsx +++ b/apps/web/components/views/projects.tsx @@ -1,7 +1,7 @@ -"use client" +"use client"; -import { $fetch } from "@lib/api" -import { Button } from "@repo/ui/components/button" +import { $fetch } from "@lib/api"; +import { Button } from "@repo/ui/components/button"; import { Dialog, @@ -10,57 +10,57 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@repo/ui/components/dialog" +} from "@repo/ui/components/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "@repo/ui/components/dropdown-menu" -import { Input } from "@repo/ui/components/input" -import { Label } from "@repo/ui/components/label" +} from "@repo/ui/components/dropdown-menu"; +import { Input } from "@repo/ui/components/input"; +import { Label } from "@repo/ui/components/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@repo/ui/components/select" -import { Skeleton } from "@repo/ui/components/skeleton" +} from "@repo/ui/components/select"; +import { Skeleton } from "@repo/ui/components/skeleton"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { FolderIcon, Loader2, MoreVertical, Plus, Trash2 } from "lucide-react" -import { AnimatePresence, motion } from "motion/react" +import { FolderIcon, Loader2, MoreVertical, Plus, Trash2 } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; -import { useState } from "react" -import { toast } from "sonner" -import { useProject } from "@/stores" +import { useState } from "react"; +import { toast } from "sonner"; +import { useProject } from "@/stores"; // Projects View Component export function ProjectsView() { - const queryClient = useQueryClient() - const { selectedProject, setSelectedProject } = useProject() - const [showCreateDialog, setShowCreateDialog] = useState(false) - const [projectName, setProjectName] = useState("") + const queryClient = useQueryClient(); + const { selectedProject, setSelectedProject } = useProject(); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [projectName, setProjectName] = useState(""); const [deleteDialog, setDeleteDialog] = useState<{ - open: boolean - project: null | { id: string; name: string; containerTag: string } - action: "move" | "delete" - targetProjectId: string + open: boolean; + project: null | { id: string; name: string; containerTag: string }; + action: "move" | "delete"; + targetProjectId: string; }>({ open: false, project: null, action: "move", targetProjectId: "", - }) + }); const [expDialog, setExpDialog] = useState<{ - open: boolean - projectId: string + open: boolean; + projectId: string; }>({ open: false, projectId: "", - }) + }); // Fetch projects const { @@ -70,42 +70,42 @@ export function ProjectsView() { } = useQuery({ queryKey: ["projects"], queryFn: async () => { - const response = await $fetch("@get/projects") + const response = await $fetch("@get/projects"); if (response.error) { - throw new Error(response.error?.message || "Failed to load projects") + throw new Error(response.error?.message || "Failed to load projects"); } - return response.data?.projects || [] + return response.data?.projects || []; }, staleTime: 30 * 1000, - }) + }); // Create project mutation const createProjectMutation = useMutation({ mutationFn: async (name: string) => { const response = await $fetch("@post/projects", { body: { name }, - }) + }); if (response.error) { - throw new Error(response.error?.message || "Failed to create project") + throw new Error(response.error?.message || "Failed to create project"); } - return response.data + return response.data; }, onSuccess: () => { - toast.success("Project created successfully!") - setShowCreateDialog(false) - setProjectName("") - queryClient.invalidateQueries({ queryKey: ["projects"] }) + toast.success("Project created successfully!"); + setShowCreateDialog(false); + setProjectName(""); + queryClient.invalidateQueries({ queryKey: ["projects"] }); }, onError: (error) => { toast.error("Failed to create project", { description: error instanceof Error ? error.message : "Unknown error", - }) + }); }, - }) + }); // Delete project mutation const deleteProjectMutation = useMutation({ @@ -114,68 +114,72 @@ export function ProjectsView() { action, targetProjectId, }: { - projectId: string - action: "move" | "delete" - targetProjectId?: string + projectId: string; + action: "move" | "delete"; + targetProjectId?: string; }) => { const response = await $fetch(`@delete/projects/${projectId}`, { body: { action, targetProjectId }, - }) + }); if (response.error) { - throw new Error(response.error?.message || "Failed to delete project") + throw new Error(response.error?.message || "Failed to delete project"); } - return response.data + return response.data; }, onSuccess: () => { - toast.success("Project deleted successfully") + toast.success("Project deleted successfully"); setDeleteDialog({ open: false, project: null, action: "move", targetProjectId: "", - }) - queryClient.invalidateQueries({ queryKey: ["projects"] }) + }); + queryClient.invalidateQueries({ queryKey: ["projects"] }); // If we deleted the selected project, switch to default if (deleteDialog.project?.containerTag === selectedProject) { - setSelectedProject("sm_project_default") + setSelectedProject("sm_project_default"); } }, onError: (error) => { toast.error("Failed to delete project", { description: error instanceof Error ? error.message : "Unknown error", - }) + }); }, - }) + }); // Enable experimental mode mutation const enableExperimentalMutation = useMutation({ mutationFn: async (projectId: string) => { - const response = await $fetch(`@post/projects/${projectId}/enable-experimental`) + const response = await $fetch( + `@post/projects/${projectId}/enable-experimental`, + ); if (response.error) { - throw new Error(response.error?.message || "Failed to enable experimental mode") + throw new Error( + response.error?.message || "Failed to enable experimental mode", + ); } - return response.data + return response.data; }, onSuccess: () => { - toast.success("Experimental mode enabled for project") - queryClient.invalidateQueries({ queryKey: ["projects"] }) - setExpDialog({ open: false, projectId: "" }) + toast.success("Experimental mode enabled for project"); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + setExpDialog({ open: false, projectId: "" }); }, onError: (error) => { toast.error("Failed to enable experimental mode", { description: error instanceof Error ? error.message : "Unknown error", - }) + }); }, - }) + }); // Handle project selection const handleProjectSelect = (containerTag: string) => { - setSelectedProject(containerTag) - toast.success("Project switched successfully") - } + setSelectedProject(containerTag); + toast.success("Project switched successfully"); + }; return ( <div className="space-y-4"> @@ -238,10 +242,11 @@ export function ProjectsView() { {/* Default project */} <motion.div animate={{ opacity: 1, x: 0 }} - className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${selectedProject === "sm_project_default" + className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${ + selectedProject === "sm_project_default" ? "bg-white/20 border border-white/30" : "bg-white/5 hover:bg-white/10" - }`} + }`} exit={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: -20 }} key="default-project" @@ -280,10 +285,11 @@ export function ProjectsView() { .map((project, index) => ( <motion.div animate={{ opacity: 1, x: 0 }} - className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${selectedProject === project.containerTag + className={`flex items-center justify-between p-3 rounded-lg transition-colors cursor-pointer ${ + selectedProject === project.containerTag ? "bg-white/20 border border-white/30" : "bg-white/5 hover:bg-white/10" - }`} + }`} exit={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: -20 }} key={project.id} @@ -338,11 +344,11 @@ export function ProjectsView() { <DropdownMenuItem className="text-blue-400 hover:text-blue-300 cursor-pointer" onClick={(e) => { - e.stopPropagation() + e.stopPropagation(); setExpDialog({ open: true, projectId: project.id, - }) + }); }} > <div className="h-4 w-4 mr-2 rounded border border-blue-400" /> @@ -361,7 +367,7 @@ export function ProjectsView() { <DropdownMenuItem className="text-red-400 hover:text-red-300 cursor-pointer" onClick={(e) => { - e.stopPropagation() + e.stopPropagation(); setDeleteDialog({ open: true, project: { @@ -371,7 +377,7 @@ export function ProjectsView() { }, action: "move", targetProjectId: "", - }) + }); }} > <Trash2 className="h-4 w-4 mr-2" /> @@ -430,8 +436,8 @@ export function ProjectsView() { <Button className="bg-white/5 hover:bg-white/10 border-white/10 text-white" onClick={() => { - setShowCreateDialog(false) - setProjectName("") + setShowCreateDialog(false); + setProjectName(""); }} type="button" variant="outline" @@ -617,10 +623,11 @@ export function ProjectsView() { whileTap={{ scale: 0.95 }} > <Button - className={`${deleteDialog.action === "delete" + className={`${ + deleteDialog.action === "delete" ? "bg-red-600 hover:bg-red-700" : "bg-white/10 hover:bg-white/20" - } text-white border-white/20`} + } text-white border-white/20`} disabled={ deleteProjectMutation.isPending || (deleteDialog.action === "move" && @@ -635,7 +642,7 @@ export function ProjectsView() { deleteDialog.action === "move" ? deleteDialog.targetProjectId : undefined, - }) + }); } }} type="button" @@ -738,5 +745,5 @@ export function ProjectsView() { )} </AnimatePresence> </div> - ) + ); } diff --git a/apps/web/globals.css b/apps/web/globals.css index ab19ef98..f06b87d8 100644 --- a/apps/web/globals.css +++ b/apps/web/globals.css @@ -1,2 +1,2 @@ -@import "tailwindcss"; -@plugin "@tailwindcss/typography";
\ No newline at end of file +@import 'tailwindcss'; +@plugin "@tailwindcss/typography"; diff --git a/apps/web/hooks/use-project-mutations.ts b/apps/web/hooks/use-project-mutations.ts index 21e1bb19..a6766f4e 100644 --- a/apps/web/hooks/use-project-mutations.ts +++ b/apps/web/hooks/use-project-mutations.ts @@ -1,41 +1,41 @@ -"use client" +"use client"; -import { $fetch } from "@lib/api" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { toast } from "sonner" -import { useProject } from "@/stores" +import { $fetch } from "@lib/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useProject } from "@/stores"; export function useProjectMutations() { - const queryClient = useQueryClient() - const { selectedProject, setSelectedProject } = useProject() + const queryClient = useQueryClient(); + const { selectedProject, setSelectedProject } = useProject(); const createProjectMutation = useMutation({ mutationFn: async (name: string) => { const response = await $fetch("@post/projects", { body: { name }, - }) + }); if (response.error) { - throw new Error(response.error?.message || "Failed to create project") + throw new Error(response.error?.message || "Failed to create project"); } - return response.data + return response.data; }, onSuccess: (data) => { - toast.success("Project created successfully!") - queryClient.invalidateQueries({ queryKey: ["projects"] }) + toast.success("Project created successfully!"); + queryClient.invalidateQueries({ queryKey: ["projects"] }); // Automatically switch to the newly created project if (data?.containerTag) { - setSelectedProject(data.containerTag) + setSelectedProject(data.containerTag); } }, onError: (error) => { toast.error("Failed to create project", { description: error instanceof Error ? error.message : "Unknown error", - }) + }); }, - }) + }); const deleteProjectMutation = useMutation({ mutationFn: async ({ @@ -43,47 +43,47 @@ export function useProjectMutations() { action, targetProjectId, }: { - projectId: string - action: "move" | "delete" - targetProjectId?: string + projectId: string; + action: "move" | "delete"; + targetProjectId?: string; }) => { const response = await $fetch(`@delete/projects/${projectId}`, { body: { action, targetProjectId }, - }) + }); if (response.error) { - throw new Error(response.error?.message || "Failed to delete project") + throw new Error(response.error?.message || "Failed to delete project"); } - return response.data + return response.data; }, onSuccess: (_, variables) => { - toast.success("Project deleted successfully") - queryClient.invalidateQueries({ queryKey: ["projects"] }) + toast.success("Project deleted successfully"); + queryClient.invalidateQueries({ queryKey: ["projects"] }); // If we deleted the selected project, switch to default - const deletedProject = queryClient.getQueryData<any[]>(["projects"])?.find( - p => p.id === variables.projectId - ) + const deletedProject = queryClient + .getQueryData<any[]>(["projects"]) + ?.find((p) => p.id === variables.projectId); if (deletedProject?.containerTag === selectedProject) { - setSelectedProject("sm_project_default") + setSelectedProject("sm_project_default"); } }, onError: (error) => { toast.error("Failed to delete project", { description: error instanceof Error ? error.message : "Unknown error", - }) + }); }, - }) + }); const switchProject = (containerTag: string) => { - setSelectedProject(containerTag) - toast.success("Project switched successfully") - } + setSelectedProject(containerTag); + toast.success("Project switched successfully"); + }; return { createProjectMutation, deleteProjectMutation, switchProject, - } + }; } diff --git a/apps/web/hooks/use-project-name.ts b/apps/web/hooks/use-project-name.ts index ef094ff6..2ee1313f 100644 --- a/apps/web/hooks/use-project-name.ts +++ b/apps/web/hooks/use-project-name.ts @@ -1,8 +1,8 @@ -"use client" +"use client"; -import { useQueryClient } from "@tanstack/react-query" -import { useMemo } from "react" -import { useProject } from "@/stores" +import { useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useProject } from "@/stores"; /** * Returns the display name of the currently selected project. @@ -10,17 +10,17 @@ import { useProject } from "@/stores" * hasn’t been fetched yet. */ export function useProjectName() { - const { selectedProject } = useProject() - const queryClient = useQueryClient() + const { selectedProject } = useProject(); + const queryClient = useQueryClient(); // This query is populated by ProjectsView – we just read from the cache. const projects = queryClient.getQueryData(["projects"]) as | Array<{ name: string; containerTag: string }> - | undefined + | undefined; return useMemo(() => { - if (selectedProject === "sm_project_default") return "Default Project" - const found = projects?.find((p) => p.containerTag === selectedProject) - return found?.name ?? selectedProject - }, [projects, selectedProject]) + if (selectedProject === "sm_project_default") return "Default Project"; + const found = projects?.find((p) => p.containerTag === selectedProject); + return found?.name ?? selectedProject; + }, [projects, selectedProject]); } diff --git a/apps/web/hooks/use-resize-observer.ts b/apps/web/hooks/use-resize-observer.ts index c03fb83b..b309347d 100644 --- a/apps/web/hooks/use-resize-observer.ts +++ b/apps/web/hooks/use-resize-observer.ts @@ -1,23 +1,23 @@ import { useEffect, useState } from "react"; export default function useResizeObserver<T extends HTMLElement>( - ref: React.RefObject<T | null> + ref: React.RefObject<T | null>, ) { - const [size, setSize] = useState({ width: 0, height: 0 }); + const [size, setSize] = useState({ width: 0, height: 0 }); - useEffect(() => { - if (!ref.current) return; + useEffect(() => { + if (!ref.current) return; - const observer = new ResizeObserver(([entry]) => { - setSize({ - width: entry?.contentRect.width ?? 0, - height: entry?.contentRect.height ?? 0, - }); - }); + const observer = new ResizeObserver(([entry]) => { + setSize({ + width: entry?.contentRect.width ?? 0, + height: entry?.contentRect.height ?? 0, + }); + }); - observer.observe(ref.current); - return () => observer.disconnect(); - }, [ref]); + observer.observe(ref.current); + return () => observer.disconnect(); + }, [ref]); - return size; -}
\ No newline at end of file + return size; +} diff --git a/apps/web/instrumentation-client.ts b/apps/web/instrumentation-client.ts index 4528e4b7..d348b544 100644 --- a/apps/web/instrumentation-client.ts +++ b/apps/web/instrumentation-client.ts @@ -2,7 +2,7 @@ // The added config here will be used whenever a users loads a page in their browser. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ -import * as Sentry from "@sentry/nextjs" +import * as Sentry from "@sentry/nextjs"; Sentry.init({ _experiments: { @@ -19,6 +19,6 @@ Sentry.init({ // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. tracesSampleRate: 1, -}) +}); -export const onRouterTransitionStart = Sentry.captureRouterTransitionStart +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 21fec3ea..b1fd09d5 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -1,4 +1,4 @@ -import posthog from "posthog-js" +import posthog from "posthog-js"; export const analytics = { userSignedOut: () => posthog.capture("user_signed_out"), @@ -7,13 +7,13 @@ export const analytics = { tourSkipped: () => posthog.capture("tour_skipped"), memoryAdded: (props: { - type: "note" | "link" | "file" - project_id?: string - content_length?: number - file_size?: number - file_type?: string + type: "note" | "link" | "file"; + project_id?: string; + content_length?: number; + file_size?: number; + file_type?: string; }) => posthog.capture("memory_added", props), - + memoryDetailOpened: () => posthog.capture("memory_detail_opened"), projectCreated: () => posthog.capture("project_created"), @@ -22,9 +22,9 @@ export const analytics = { chatHistoryViewed: () => posthog.capture("chat_history_viewed"), chatDeleted: () => posthog.capture("chat_deleted"), - viewModeChanged: (mode: "graph" | "list") => + viewModeChanged: (mode: "graph" | "list") => posthog.capture("view_mode_changed", { mode }), - + documentCardClicked: () => posthog.capture("document_card_clicked"), billingViewed: () => posthog.capture("billing_viewed"), @@ -32,13 +32,13 @@ export const analytics = { upgradeCompleted: () => posthog.capture("upgrade_completed"), billingPortalOpened: () => posthog.capture("billing_portal_opened"), - connectionAdded: (provider: string) => + connectionAdded: (provider: string) => posthog.capture("connection_added", { provider }), connectionDeleted: () => posthog.capture("connection_deleted"), connectionAuthStarted: () => posthog.capture("connection_auth_started"), connectionAuthCompleted: () => posthog.capture("connection_auth_completed"), connectionAuthFailed: () => posthog.capture("connection_auth_failed"), - + mcpViewOpened: () => posthog.capture("mcp_view_opened"), mcpInstallCmdCopied: () => posthog.capture("mcp_install_cmd_copied"), -}
\ No newline at end of file +}; diff --git a/apps/web/lib/mobile-panel-context.tsx b/apps/web/lib/mobile-panel-context.tsx index 3b0b1838..5dc4a01c 100644 --- a/apps/web/lib/mobile-panel-context.tsx +++ b/apps/web/lib/mobile-panel-context.tsx @@ -1,32 +1,32 @@ -"use client" +"use client"; -import { createContext, type ReactNode, useContext, useState } from "react" +import { createContext, type ReactNode, useContext, useState } from "react"; -type ActivePanel = "menu" | "chat" | null +type ActivePanel = "menu" | "chat" | null; interface MobilePanelContextType { - activePanel: ActivePanel - setActivePanel: (panel: ActivePanel) => void + activePanel: ActivePanel; + setActivePanel: (panel: ActivePanel) => void; } const MobilePanelContext = createContext<MobilePanelContextType | undefined>( undefined, -) +); export function MobilePanelProvider({ children }: { children: ReactNode }) { - const [activePanel, setActivePanel] = useState<ActivePanel>(null) + const [activePanel, setActivePanel] = useState<ActivePanel>(null); return ( <MobilePanelContext.Provider value={{ activePanel, setActivePanel }}> {children} </MobilePanelContext.Provider> - ) + ); } export function useMobilePanel() { - const context = useContext(MobilePanelContext) + const context = useContext(MobilePanelContext); if (!context) { - throw new Error("useMobilePanel must be used within a MobilePanelProvider") + throw new Error("useMobilePanel must be used within a MobilePanelProvider"); } - return context + return context; } diff --git a/apps/web/lib/tour-constants.ts b/apps/web/lib/tour-constants.ts index b61fc1e7..9857c878 100644 --- a/apps/web/lib/tour-constants.ts +++ b/apps/web/lib/tour-constants.ts @@ -17,7 +17,7 @@ export const TOUR_STEP_IDS = { MENU_BILLING: "tour-menu-billing", // Legend LEGEND: "tour-legend", -} as const +} as const; // Tour storage key for localStorage -export const TOUR_STORAGE_KEY = "supermemory-tour-completed" +export const TOUR_STORAGE_KEY = "supermemory-tour-completed"; diff --git a/apps/web/lib/view-mode-context.tsx b/apps/web/lib/view-mode-context.tsx index 87c11da1..61627042 100644 --- a/apps/web/lib/view-mode-context.tsx +++ b/apps/web/lib/view-mode-context.tsx @@ -1,4 +1,4 @@ -"use client" +"use client"; import { createContext, @@ -6,73 +6,73 @@ import { useContext, useEffect, useState, -} from "react" -import { analytics } from "@/lib/analytics" +} from "react"; +import { analytics } from "@/lib/analytics"; -type ViewMode = "graph" | "list" +type ViewMode = "graph" | "list"; interface ViewModeContextType { - viewMode: ViewMode - setViewMode: (mode: ViewMode) => void - isInitialized: boolean + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; + isInitialized: boolean; } const ViewModeContext = createContext<ViewModeContextType | undefined>( undefined, -) +); // Cookie utility functions const setCookie = (name: string, value: string, days = 365) => { - if (typeof document === "undefined") return - const expires = new Date() - expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000) - document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/` -} + if (typeof document === "undefined") return; + const expires = new Date(); + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`; +}; const getCookie = (name: string): string | null => { - if (typeof document === "undefined") return null - const nameEQ = `${name}=` - const ca = document.cookie.split(";") + if (typeof document === "undefined") return null; + const nameEQ = `${name}=`; + const ca = document.cookie.split(";"); for (let i = 0; i < ca.length; i++) { - let c = ca[i] - if (!c) continue - while (c.charAt(0) === " ") c = c.substring(1, c.length) - if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length) + let c = ca[i]; + if (!c) continue; + while (c.charAt(0) === " ") c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); } - return null -} + return null; +}; const isMobileDevice = () => { - if (typeof window === "undefined") return false - return window.innerWidth < 768 -} + if (typeof window === "undefined") return false; + return window.innerWidth < 768; +}; export function ViewModeProvider({ children }: { children: ReactNode }) { // Start with a default that works for SSR - const [viewMode, setViewModeState] = useState<ViewMode>("graph") - const [isInitialized, setIsInitialized] = useState(false) + const [viewMode, setViewModeState] = useState<ViewMode>("graph"); + const [isInitialized, setIsInitialized] = useState(false); // Load preferences on the client side useEffect(() => { if (!isInitialized) { // Check for saved preference first - const savedMode = getCookie("memoryViewMode") + const savedMode = getCookie("memoryViewMode"); if (savedMode === "list" || savedMode === "graph") { - setViewModeState(savedMode) + setViewModeState(savedMode); } else { // If no saved preference, default to list on mobile, graph on desktop - setViewModeState(isMobileDevice() ? "list" : "graph") + setViewModeState(isMobileDevice() ? "list" : "graph"); } - setIsInitialized(true) + setIsInitialized(true); } - }, [isInitialized]) + }, [isInitialized]); // Save to cookie whenever view mode changes const handleSetViewMode = (mode: ViewMode) => { - analytics.viewModeChanged(mode) - setViewModeState(mode) - setCookie("memoryViewMode", mode) - } + analytics.viewModeChanged(mode); + setViewModeState(mode); + setCookie("memoryViewMode", mode); + }; return ( <ViewModeContext.Provider @@ -84,13 +84,13 @@ export function ViewModeProvider({ children }: { children: ReactNode }) { > {children} </ViewModeContext.Provider> - ) + ); } export function useViewMode() { - const context = useContext(ViewModeContext) + const context = useContext(ViewModeContext); if (!context) { - throw new Error("useViewMode must be used within a ViewModeProvider") + throw new Error("useViewMode must be used within a ViewModeProvider"); } - return context + return context; } diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 068b8859..8658c0d8 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,29 +1,29 @@ -import { $fetch } from "@lib/api" -import { getSessionCookie } from "better-auth/cookies" -import { NextResponse } from "next/server" +import { $fetch } from "@lib/api"; +import { getSessionCookie } from "better-auth/cookies"; +import { NextResponse } from "next/server"; export default async function middleware(request: Request) { - console.debug("[MIDDLEWARE] === MIDDLEWARE START ===") - const url = new URL(request.url) - console.debug("[MIDDLEWARE] Path:", url.pathname) - console.debug("[MIDDLEWARE] Method:", request.method) + console.debug("[MIDDLEWARE] === MIDDLEWARE START ==="); + const url = new URL(request.url); + console.debug("[MIDDLEWARE] Path:", url.pathname); + console.debug("[MIDDLEWARE] Method:", request.method); - const sessionCookie = getSessionCookie(request) - console.debug("[MIDDLEWARE] Session cookie exists:", !!sessionCookie) + const sessionCookie = getSessionCookie(request); + console.debug("[MIDDLEWARE] Session cookie exists:", !!sessionCookie); // Always allow access to login and waitlist pages - const publicPaths = ["/login"] + const publicPaths = ["/login"]; if (publicPaths.includes(url.pathname)) { - console.debug("[MIDDLEWARE] Public path, allowing access") - return NextResponse.next() + console.debug("[MIDDLEWARE] Public path, allowing access"); + return NextResponse.next(); } // If no session cookie and not on a public path, redirect to login if (!sessionCookie) { console.debug( "[MIDDLEWARE] No session cookie and not on public path, redirecting to /login", - ) - return NextResponse.redirect(new URL("/login", request.url)) + ); + return NextResponse.redirect(new URL("/login", request.url)); } if (url.pathname !== "/waitlist") { @@ -31,20 +31,20 @@ export default async function middleware(request: Request) { headers: { Authorization: `Bearer ${sessionCookie}`, }, - }) - console.debug("[MIDDLEWARE] Waitlist status:", response.data) + }); + console.debug("[MIDDLEWARE] Waitlist status:", response.data); if (response.data && !response.data.accessGranted) { - return NextResponse.redirect(new URL("/waitlist", request.url)) + return NextResponse.redirect(new URL("/waitlist", request.url)); } } - console.debug("[MIDDLEWARE] Passing through to next handler") - console.debug("[MIDDLEWARE] === MIDDLEWARE END ===") - return NextResponse.next() + console.debug("[MIDDLEWARE] Passing through to next handler"); + console.debug("[MIDDLEWARE] === MIDDLEWARE END ==="); + return NextResponse.next(); } export const config = { matcher: [ "/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|ingest|api|login|api/emails).*)", ], -} +}; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index ba7cbc85..6a92e72c 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,5 +1,5 @@ -import { withSentryConfig } from "@sentry/nextjs" -import type { NextConfig } from "next" +import { withSentryConfig } from "@sentry/nextjs"; +import type { NextConfig } from "next"; const nextConfig: NextConfig = { experimental: { @@ -20,10 +20,10 @@ const nextConfig: NextConfig = { source: "/ingest/:path*", destination: "https://us.i.posthog.com/:path*", }, - ] + ]; }, skipTrailingSlashRedirect: true, -} +}; export default withSentryConfig(nextConfig, { // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) @@ -54,7 +54,8 @@ export default withSentryConfig(nextConfig, { // Upload a larger set of source maps for prettier stack traces (increases build time) widenClientFileUpload: true, -}) +}); -import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare" -initOpenNextCloudflareForDev() +import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; + +initOpenNextCloudflareForDev(); diff --git a/apps/web/open-next.config.ts b/apps/web/open-next.config.ts index 544307a9..4f3ea77b 100644 --- a/apps/web/open-next.config.ts +++ b/apps/web/open-next.config.ts @@ -1,6 +1,6 @@ import { defineCloudflareConfig } from "@opennextjs/cloudflare"; import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"; - + export default defineCloudflareConfig({ - incrementalCache: r2IncrementalCache, + incrementalCache: r2IncrementalCache, }); diff --git a/apps/web/package.json b/apps/web/package.json index 6aca8ffb..47f2bff8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,92 +1,96 @@ { - "name": "@repo/web", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", - "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", - "upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload", - "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" - }, - "dependencies": { - "@ai-sdk/google": "^2.0.0-beta.13", - "@ai-sdk/react": "2.0.0-beta.24", - "@better-fetch/fetch": "^1.1.18", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@opennextjs/cloudflare": "^1.6.1", - "@radix-ui/react-accordion": "^1.2.11", - "@radix-ui/react-alert-dialog": "^1.1.14", - "@radix-ui/react-avatar": "^1.1.10", - "@radix-ui/react-checkbox": "^1.3.2", - "@radix-ui/react-dialog": "^1.1.14", - "@radix-ui/react-dropdown-menu": "^2.1.15", - "@radix-ui/react-label": "^2.1.7", - "@radix-ui/react-progress": "^1.1.7", - "@radix-ui/react-select": "^2.2.5", - "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.3", - "@radix-ui/react-tabs": "^1.1.12", - "@radix-ui/react-toggle": "^1.1.9", - "@radix-ui/react-toggle-group": "^1.1.10", - "@radix-ui/react-tooltip": "^1.2.7", - "@react-router/fs-routes": "^7.6.2", - "@react-router/node": "^7.6.2", - "@react-router/serve": "^7.6.2", - "@sentry/nextjs": "^9.33.0", - "@tabler/icons-react": "^3.34.0", - "@tailwindcss/typography": "^0.5.16", - "@tanstack/react-form": "^1.12.4", - "@tanstack/react-query": "^5.81.2", - "@tanstack/react-query-devtools": "^5.84.2", - "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.12", - "ai": "5.0.0-beta.24", - "autumn-js": "0.0.116", - "babel-plugin-react-compiler": "^19.1.0-rc.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "date-fns": "^4.1.0", - "dotenv": "^16.6.0", - "embla-carousel-autoplay": "^8.6.0", - "embla-carousel-react": "^8.6.0", - "isbot": "^5.1.28", - "lucide-react": "^0.525.0", - "motion": "^12.19.2", - "next": "15.3.0", - "next-themes": "^0.4.6", - "nuqs": "^2.4.3", - "posthog-js": "^1.257.0", - "random-word-slugs": "^0.1.7", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-dropzone": "^14.3.8", - "react-error-boundary": "^6.0.0", - "react-markdown": "^10.1.0", - "recharts": "2", - "remark-gfm": "^4.0.1", - "shadcn-dropzone": "^0.2.1", - "sonner": "^2.0.5", - "tailwind-merge": "^3.3.1", - "tw-animate-css": "^1.3.4", - "valibot": "^1.1.0", - "vaul": "^1.1.2", - "zustand": "^5.0.7" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4.1.11", - "@total-typescript/tsconfig": "^1.0.4", - "@types/node": "^24.0.4", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "tailwindcss": "^4.1.11", - "typescript": "^5.8.3", - "wrangler": "^4.26.0" - } + "name": "@repo/web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", + "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", + "upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload", + "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" + }, + "dependencies": { + "@ai-sdk/google": "^2.0.0-beta.13", + "@ai-sdk/react": "2.0.0-beta.24", + "@better-fetch/fetch": "^1.1.18", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@opennextjs/cloudflare": "^1.6.1", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-toggle": "^1.1.9", + "@radix-ui/react-toggle-group": "^1.1.10", + "@radix-ui/react-tooltip": "^1.2.7", + "@react-router/fs-routes": "^7.6.2", + "@react-router/node": "^7.6.2", + "@react-router/serve": "^7.6.2", + "@sentry/nextjs": "^9.33.0", + "@tabler/icons-react": "^3.34.0", + "@tailwindcss/typography": "^0.5.16", + "@tanstack/react-form": "^1.12.4", + "@tanstack/react-query": "^5.81.2", + "@tanstack/react-query-devtools": "^5.84.2", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", + "ai": "5.0.0-beta.24", + "autumn-js": "0.0.116", + "babel-plugin-react-compiler": "^19.1.0-rc.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dotenv": "^16.6.0", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", + "is-hotkey": "^0.2.0", + "isbot": "^5.1.28", + "lucide-react": "^0.525.0", + "motion": "^12.19.2", + "next": "15.3.0", + "next-themes": "^0.4.6", + "nuqs": "^2.4.3", + "posthog-js": "^1.257.0", + "random-word-slugs": "^0.1.7", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-dropzone": "^14.3.8", + "react-error-boundary": "^6.0.0", + "react-markdown": "^10.1.0", + "recharts": "2", + "remark-gfm": "^4.0.1", + "shadcn-dropzone": "^0.2.1", + "slate": "^0.118.0", + "slate-react": "^0.117.4", + "sonner": "^2.0.5", + "tailwind-merge": "^3.3.1", + "tw-animate-css": "^1.3.4", + "valibot": "^1.1.0", + "vaul": "^1.1.2", + "zustand": "^5.0.7" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.11", + "@total-typescript/tsconfig": "^1.0.4", + "@types/is-hotkey": "^0.1.10", + "@types/node": "^24.0.4", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "tailwindcss": "^4.1.11", + "typescript": "^5.8.3", + "wrangler": "^4.26.0" + } } diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs index 78452aad..f50127cd 100644 --- a/apps/web/postcss.config.mjs +++ b/apps/web/postcss.config.mjs @@ -1,5 +1,5 @@ const config = { plugins: ["@tailwindcss/postcss"], -} +}; -export default config +export default config; diff --git a/apps/web/stores/chat.ts b/apps/web/stores/chat.ts index fcb5c9f0..16814bdc 100644 --- a/apps/web/stores/chat.ts +++ b/apps/web/stores/chat.ts @@ -1,182 +1,214 @@ -import { create } from "zustand" -import { persist } from "zustand/middleware" -import type { UIMessage } from "@ai-sdk/react" +import type { UIMessage } from "@ai-sdk/react"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; export interface ConversationSummary { - id: string - title?: string - lastUpdated: string + id: string; + title?: string; + lastUpdated: string; } interface ConversationRecord { - messages: UIMessage[] - title?: string - lastUpdated: string + messages: UIMessage[]; + title?: string; + lastUpdated: string; } interface ProjectConversationsState { - currentChatId: string | null - conversations: Record<string, ConversationRecord> + currentChatId: string | null; + conversations: Record<string, ConversationRecord>; } interface ConversationsStoreState { - byProject: Record<string, ProjectConversationsState> - setCurrentChatId: (projectId: string, chatId: string | null) => void - setConversation: (projectId: string, chatId: string, messages: UIMessage[]) => void - deleteConversation: (projectId: string, chatId: string) => void - setConversationTitle: (projectId: string, chatId: string, title: string | undefined) => void + byProject: Record<string, ProjectConversationsState>; + setCurrentChatId: (projectId: string, chatId: string | null) => void; + setConversation: ( + projectId: string, + chatId: string, + messages: UIMessage[], + ) => void; + deleteConversation: (projectId: string, chatId: string) => void; + setConversationTitle: ( + projectId: string, + chatId: string, + title: string | undefined, + ) => void; } export const usePersistentChatStore = create<ConversationsStoreState>()( - persist( - (set, get) => ({ - byProject: {}, - - setCurrentChatId(projectId, chatId) { - set((state) => { - const project = state.byProject[projectId] ?? { currentChatId: null, conversations: {} } - return { - byProject: { - ...state.byProject, - [projectId]: { ...project, currentChatId: chatId }, - }, - } - }) - }, - - setConversation(projectId, chatId, messages) { - const now = new Date().toISOString() - set((state) => { - const project = state.byProject[projectId] ?? { currentChatId: null, conversations: {} } - const existing = project.conversations[chatId] - const shouldTouchLastUpdated = (() => { - if (!existing) return messages.length > 0 - const previousLength = existing.messages?.length ?? 0 - return messages.length > previousLength - })() - - const record: ConversationRecord = { - messages, - title: existing?.title, - lastUpdated: shouldTouchLastUpdated ? now : existing?.lastUpdated ?? now, - } - return { - byProject: { - ...state.byProject, - [projectId]: { - currentChatId: project.currentChatId, - conversations: { - ...project.conversations, - [chatId]: record, - }, - }, - }, - } - }) - }, - - deleteConversation(projectId, chatId) { - set((state) => { - const project = state.byProject[projectId] ?? { currentChatId: null, conversations: {} } - const { [chatId]: _, ...rest } = project.conversations - const nextCurrent = project.currentChatId === chatId ? null : project.currentChatId - return { - byProject: { - ...state.byProject, - [projectId]: { currentChatId: nextCurrent, conversations: rest }, - }, - } - }) - }, - - setConversationTitle(projectId, chatId, title) { - const now = new Date().toISOString() - set((state) => { - const project = state.byProject[projectId] ?? { currentChatId: null, conversations: {} } - const existing = project.conversations[chatId] - if (!existing) return { byProject: state.byProject } - return { - byProject: { - ...state.byProject, - [projectId]: { - currentChatId: project.currentChatId, - conversations: { - ...project.conversations, - [chatId]: { ...existing, title, lastUpdated: now }, - }, - }, - }, - } - }) - }, - }), - { - name: "supermemory-chats", - }, - ), -) + persist( + (set, get) => ({ + byProject: {}, + + setCurrentChatId(projectId, chatId) { + set((state) => { + const project = state.byProject[projectId] ?? { + currentChatId: null, + conversations: {}, + }; + return { + byProject: { + ...state.byProject, + [projectId]: { ...project, currentChatId: chatId }, + }, + }; + }); + }, + + setConversation(projectId, chatId, messages) { + const now = new Date().toISOString(); + set((state) => { + const project = state.byProject[projectId] ?? { + currentChatId: null, + conversations: {}, + }; + const existing = project.conversations[chatId]; + const shouldTouchLastUpdated = (() => { + if (!existing) return messages.length > 0; + const previousLength = existing.messages?.length ?? 0; + return messages.length > previousLength; + })(); + + const record: ConversationRecord = { + messages, + title: existing?.title, + lastUpdated: shouldTouchLastUpdated + ? now + : (existing?.lastUpdated ?? now), + }; + return { + byProject: { + ...state.byProject, + [projectId]: { + currentChatId: project.currentChatId, + conversations: { + ...project.conversations, + [chatId]: record, + }, + }, + }, + }; + }); + }, + + deleteConversation(projectId, chatId) { + set((state) => { + const project = state.byProject[projectId] ?? { + currentChatId: null, + conversations: {}, + }; + const { [chatId]: _, ...rest } = project.conversations; + const nextCurrent = + project.currentChatId === chatId ? null : project.currentChatId; + return { + byProject: { + ...state.byProject, + [projectId]: { currentChatId: nextCurrent, conversations: rest }, + }, + }; + }); + }, + + setConversationTitle(projectId, chatId, title) { + const now = new Date().toISOString(); + set((state) => { + const project = state.byProject[projectId] ?? { + currentChatId: null, + conversations: {}, + }; + const existing = project.conversations[chatId]; + if (!existing) return { byProject: state.byProject }; + return { + byProject: { + ...state.byProject, + [projectId]: { + currentChatId: project.currentChatId, + conversations: { + ...project.conversations, + [chatId]: { ...existing, title, lastUpdated: now }, + }, + }, + }, + }; + }); + }, + }), + { + name: "supermemory-chats", + }, + ), +); // Always scoped to the current project via useProject import { useProject } from "."; export function usePersistentChat() { - const { selectedProject } = useProject() - const projectId = selectedProject - - const projectState = usePersistentChatStore((s) => s.byProject[projectId]) - const setCurrentChatIdRaw = usePersistentChatStore((s) => s.setCurrentChatId) - const setConversationRaw = usePersistentChatStore((s) => s.setConversation) - const deleteConversationRaw = usePersistentChatStore((s) => s.deleteConversation) - const setConversationTitleRaw = usePersistentChatStore((s) => s.setConversationTitle) - - const conversations: ConversationSummary[] = (() => { - const convs = projectState?.conversations ?? {} - return Object.entries(convs).map(([id, rec]) => ({ id, title: rec.title, lastUpdated: rec.lastUpdated })) - })() - - const currentChatId = projectState?.currentChatId ?? null - - function setCurrentChatId(chatId: string | null): void { - setCurrentChatIdRaw(projectId, chatId) - } - - function setConversation(chatId: string, messages: UIMessage[]): void { - setConversationRaw(projectId, chatId, messages) - } - - function deleteConversation(chatId: string): void { - deleteConversationRaw(projectId, chatId) - } - - function setConversationTitle(chatId: string, title: string | undefined): void { - setConversationTitleRaw(projectId, chatId, title) - } - - function getCurrentConversation(): UIMessage[] | undefined { - const convs = projectState?.conversations ?? {} - const id = currentChatId - if (!id) return undefined - return convs[id]?.messages - } - - function getCurrentChat(): ConversationSummary | undefined { - const id = currentChatId - if (!id) return undefined - const rec = projectState?.conversations?.[id] - if (!rec) return undefined - return { id, title: rec.title, lastUpdated: rec.lastUpdated } - } - - return { - conversations, - currentChatId, - setCurrentChatId, - setConversation, - deleteConversation, - setConversationTitle, - getCurrentConversation, - getCurrentChat, - } + const { selectedProject } = useProject(); + const projectId = selectedProject; + + const projectState = usePersistentChatStore((s) => s.byProject[projectId]); + const setCurrentChatIdRaw = usePersistentChatStore((s) => s.setCurrentChatId); + const setConversationRaw = usePersistentChatStore((s) => s.setConversation); + const deleteConversationRaw = usePersistentChatStore( + (s) => s.deleteConversation, + ); + const setConversationTitleRaw = usePersistentChatStore( + (s) => s.setConversationTitle, + ); + + const conversations: ConversationSummary[] = (() => { + const convs = projectState?.conversations ?? {}; + return Object.entries(convs).map(([id, rec]) => ({ + id, + title: rec.title, + lastUpdated: rec.lastUpdated, + })); + })(); + + const currentChatId = projectState?.currentChatId ?? null; + + function setCurrentChatId(chatId: string | null): void { + setCurrentChatIdRaw(projectId, chatId); + } + + function setConversation(chatId: string, messages: UIMessage[]): void { + setConversationRaw(projectId, chatId, messages); + } + + function deleteConversation(chatId: string): void { + deleteConversationRaw(projectId, chatId); + } + + function setConversationTitle( + chatId: string, + title: string | undefined, + ): void { + setConversationTitleRaw(projectId, chatId, title); + } + + function getCurrentConversation(): UIMessage[] | undefined { + const convs = projectState?.conversations ?? {}; + const id = currentChatId; + if (!id) return undefined; + return convs[id]?.messages; + } + + function getCurrentChat(): ConversationSummary | undefined { + const id = currentChatId; + if (!id) return undefined; + const rec = projectState?.conversations?.[id]; + if (!rec) return undefined; + return { id, title: rec.title, lastUpdated: rec.lastUpdated }; + } + + return { + conversations, + currentChatId, + setCurrentChatId, + setConversation, + deleteConversation, + setConversationTitle, + getCurrentConversation, + getCurrentChat, + }; } - - diff --git a/apps/web/stores/highlights.ts b/apps/web/stores/highlights.ts index 81d75fa0..d7937db1 100644 --- a/apps/web/stores/highlights.ts +++ b/apps/web/stores/highlights.ts @@ -1,32 +1,35 @@ -import { create } from "zustand" +import { create } from "zustand"; interface GraphHighlightsState { - documentIds: string[] - lastUpdated: number - setDocumentIds: (ids: string[]) => void - clear: () => void + documentIds: string[]; + lastUpdated: number; + setDocumentIds: (ids: string[]) => void; + clear: () => void; } -export const useGraphHighlightsStore = create<GraphHighlightsState>()((set, get) => ({ - documentIds: [], - lastUpdated: 0, - setDocumentIds: (ids) => { - const next = Array.from(new Set(ids)) - const prev = get().documentIds - if (prev.length === next.length && prev.every((id) => next.includes(id))) { - return - } - set({ documentIds: next, lastUpdated: Date.now() }) - }, - clear: () => set({ documentIds: [], lastUpdated: Date.now() }), -})) +export const useGraphHighlightsStore = create<GraphHighlightsState>()( + (set, get) => ({ + documentIds: [], + lastUpdated: 0, + setDocumentIds: (ids) => { + const next = Array.from(new Set(ids)); + const prev = get().documentIds; + if ( + prev.length === next.length && + prev.every((id) => next.includes(id)) + ) { + return; + } + set({ documentIds: next, lastUpdated: Date.now() }); + }, + clear: () => set({ documentIds: [], lastUpdated: Date.now() }), + }), +); export function useGraphHighlights() { - const documentIds = useGraphHighlightsStore((s) => s.documentIds) - const lastUpdated = useGraphHighlightsStore((s) => s.lastUpdated) - const setDocumentIds = useGraphHighlightsStore((s) => s.setDocumentIds) - const clear = useGraphHighlightsStore((s) => s.clear) - return { documentIds, lastUpdated, setDocumentIds, clear } + const documentIds = useGraphHighlightsStore((s) => s.documentIds); + const lastUpdated = useGraphHighlightsStore((s) => s.lastUpdated); + const setDocumentIds = useGraphHighlightsStore((s) => s.setDocumentIds); + const clear = useGraphHighlightsStore((s) => s.clear); + return { documentIds, lastUpdated, setDocumentIds, clear }; } - - diff --git a/apps/web/stores/index.ts b/apps/web/stores/index.ts index 0cc8e65f..fb367cf7 100644 --- a/apps/web/stores/index.ts +++ b/apps/web/stores/index.ts @@ -1,78 +1,80 @@ -import { create } from 'zustand' -import { persist } from 'zustand/middleware' +import { create } from "zustand"; +import { persist } from "zustand/middleware"; interface ProjectState { - selectedProject: string - setSelectedProject: (projectId: string) => void + selectedProject: string; + setSelectedProject: (projectId: string) => void; } export const useProjectStore = create<ProjectState>()( - persist( - (set) => ({ - selectedProject: 'sm_project_default', - setSelectedProject: (projectId) => set({ selectedProject: projectId }), - }), - { - name: 'selectedProject', - } - ) -) + persist( + (set) => ({ + selectedProject: "sm_project_default", + setSelectedProject: (projectId) => set({ selectedProject: projectId }), + }), + { + name: "selectedProject", + }, + ), +); interface MemoryGraphState { - positionX: number - positionY: number - setPositionX: (x: number) => void - setPositionY: (y: number) => void - setPosition: (x: number, y: number) => void + positionX: number; + positionY: number; + setPositionX: (x: number) => void; + setPositionY: (y: number) => void; + setPosition: (x: number, y: number) => void; } export const useMemoryGraphStore = create<MemoryGraphState>()((set) => ({ - positionX: 0, - positionY: 0, - setPositionX: (x) => set({ positionX: x }), - setPositionY: (y) => set({ positionY: y }), - setPosition: (x, y) => set({ positionX: x, positionY: y }), -})) + positionX: 0, + positionY: 0, + setPositionX: (x) => set({ positionX: x }), + setPositionY: (y) => set({ positionY: y }), + setPosition: (x, y) => set({ positionX: x, positionY: y }), +})); interface ChatState { - isOpen: boolean - setIsOpen: (isOpen: boolean) => void - toggleChat: () => void + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + toggleChat: () => void; } export const useChatStore = create<ChatState>()((set, get) => ({ - isOpen: false, - setIsOpen: (isOpen) => set({ isOpen }), - toggleChat: () => set({ isOpen: !get().isOpen }), -})) + isOpen: false, + setIsOpen: (isOpen) => set({ isOpen }), + toggleChat: () => set({ isOpen: !get().isOpen }), +})); export function useProject() { - const selectedProject = useProjectStore(state => state.selectedProject) - const setSelectedProject = useProjectStore(state => state.setSelectedProject) - return { selectedProject, setSelectedProject } + const selectedProject = useProjectStore((state) => state.selectedProject); + const setSelectedProject = useProjectStore( + (state) => state.setSelectedProject, + ); + return { selectedProject, setSelectedProject }; } export function useMemoryGraphPosition() { - const positionX = useMemoryGraphStore(state => state.positionX) - const positionY = useMemoryGraphStore(state => state.positionY) - const setPositionX = useMemoryGraphStore(state => state.setPositionX) - const setPositionY = useMemoryGraphStore(state => state.setPositionY) - const setPosition = useMemoryGraphStore(state => state.setPosition) - - return { - x: positionX, - y: positionY, - setX: setPositionX, - setY: setPositionY, - setPosition - } + const positionX = useMemoryGraphStore((state) => state.positionX); + const positionY = useMemoryGraphStore((state) => state.positionY); + const setPositionX = useMemoryGraphStore((state) => state.setPositionX); + const setPositionY = useMemoryGraphStore((state) => state.setPositionY); + const setPosition = useMemoryGraphStore((state) => state.setPosition); + + return { + x: positionX, + y: positionY, + setX: setPositionX, + setY: setPositionY, + setPosition, + }; } export function useChatOpen() { - const isOpen = useChatStore(state => state.isOpen) - const setIsOpen = useChatStore(state => state.setIsOpen) - const toggleChat = useChatStore(state => state.toggleChat) - return { isOpen, setIsOpen, toggleChat } + const isOpen = useChatStore((state) => state.isOpen); + const setIsOpen = useChatStore((state) => state.setIsOpen); + const toggleChat = useChatStore((state) => state.toggleChat); + return { isOpen, setIsOpen, toggleChat }; } -export { usePersistentChatStore, usePersistentChat } from "./chat" +export { usePersistentChat, usePersistentChatStore } from "./chat"; diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..0959d573 --- /dev/null +++ b/biome.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", + "files": { + "includes": [ + "**/*.js", + "**/*.jsx", + "**/*.ts", + "**/*.tsx", + "**/*.json", + "**/*.jsonc", + "!**/node_modules", + "!**/dist", + "!**/build", + "!**/.next", + "!**/.turbo", + "!**/*.d.ts" + ] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "tab", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 80, + "attributePosition": "auto", + "includes": ["**", "!**/*.md"] + }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "recommended": true + }, + "complexity": { + "recommended": true + }, + "correctness": { + "recommended": true + }, + "performance": { + "recommended": true + }, + "security": { + "recommended": true + }, + "style": { + "recommended": true + }, + "suspicious": { + "recommended": true + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "jsxQuoteStyle": "double", + "trailingCommas": "all", + "semicolons": "asNeeded", + "arrowParentheses": "always", + "bracketSpacing": true, + "bracketSameLine": false, + "quoteProperties": "asNeeded", + "attributePosition": "auto" + } + }, + "json": { + "formatter": { + "trailingCommas": "none" + } + } +} Binary files differdiff --git a/package.json b/package.json index e2e82cda..ca4b4df9 100644 --- a/package.json +++ b/package.json @@ -1,65 +1,65 @@ { - "name": "supermemory", - "private": true, - "scripts": { - "build": "turbo run build", - "dev": "turbo run dev", - "format-lint": "bunx biome check --write", - "check-types": "turbo run check-types" - }, - "engines": { - "node": ">=20" - }, - "packageManager": "[email protected]", - "workspaces": [ - "apps/*", - "packages/*" - ], - "dependencies": { - "@ai-sdk/anthropic": "^1.2.12", - "@ai-sdk/cerebras": "^0.2.16", - "@ai-sdk/google": "^1.2.22", - "@ai-sdk/openai": "^1.3.23", - "@anthropic-ai/sdk": "^0.55.1", - "@google/genai": "^1.10.0", - "@google/generative-ai": "^0.24.1", - "@hono/zod-validator": "^0.7.1", - "@scalar/hono-api-reference": "^0.9.11", - "ai": "^4.3.19", - "alchemy": "^0.55.2", - "atmn": "^0.0.16", - "better-auth": "^1.3.3", - "boxen": "^8.0.1", - "cloudflare": "^4.5.0", - "compromise": "^14.14.4", - "dedent": "^1.6.0", - "destr": "^2.0.5", - "drizzle-orm": "^0.44.3", - "drizzle-zod": "~0.7.1", - "file-type": "^21.0.0", - "hono-openapi": "^0.4.8", - "llm-bridge": "1.0.8", - "nanoid": "^5.1.5", - "neverthrow": "^8.2.0", - "pg": "^8.16.3", - "pino": "^9.7.0", - "postgres": "^3.4.7", - "random-word-slugs": "^0.1.7", - "resend": "^4.7.0", - "zod": "^3.25.76", - "zod-openapi": "^4.2.4" - }, - "devDependencies": { - "@biomejs/biome": "^2.1.3", - "@total-typescript/tsconfig": "^1.0.4", - "@types/pg": "^8.15.4", - "drizzle-kit": "^0.31.4", - "turbo": "^2.5.4", - "typescript": "5.8.3", - "wrangler": "4.22.0" - }, - "workerd": { - "import": "./esm/index.mjs", - "require": "./dist/index.js" - } + "name": "supermemory", + "private": true, + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "format-lint": "bunx biome check --write", + "check-types": "turbo run check-types" + }, + "engines": { + "node": ">=20" + }, + "packageManager": "[email protected]", + "workspaces": [ + "apps/*", + "packages/*" + ], + "dependencies": { + "@ai-sdk/anthropic": "^1.2.12", + "@ai-sdk/cerebras": "^0.2.16", + "@ai-sdk/google": "^1.2.22", + "@ai-sdk/openai": "^1.3.23", + "@anthropic-ai/sdk": "^0.55.1", + "@google/genai": "^1.10.0", + "@google/generative-ai": "^0.24.1", + "@hono/zod-validator": "^0.7.1", + "@scalar/hono-api-reference": "^0.9.11", + "ai": "^4.3.19", + "alchemy": "^0.55.2", + "atmn": "^0.0.16", + "better-auth": "^1.3.3", + "boxen": "^8.0.1", + "cloudflare": "^4.5.0", + "compromise": "^14.14.4", + "dedent": "^1.6.0", + "destr": "^2.0.5", + "drizzle-orm": "^0.44.3", + "drizzle-zod": "~0.7.1", + "file-type": "^21.0.0", + "hono-openapi": "^0.4.8", + "llm-bridge": "1.0.8", + "nanoid": "^5.1.5", + "neverthrow": "^8.2.0", + "pg": "^8.16.3", + "pino": "^9.7.0", + "postgres": "^3.4.7", + "random-word-slugs": "^0.1.7", + "resend": "^4.7.0", + "zod": "^3.25.76", + "zod-openapi": "^4.2.4" + }, + "devDependencies": { + "@biomejs/biome": "^2.1.3", + "@total-typescript/tsconfig": "^1.0.4", + "@types/pg": "^8.15.4", + "drizzle-kit": "^0.31.4", + "turbo": "^2.5.4", + "typescript": "5.8.3", + "wrangler": "4.22.0" + }, + "workerd": { + "import": "./esm/index.mjs", + "require": "./dist/index.js" + } } diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index 09d316ef..ca42eee9 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -1,8 +1,8 @@ -import js from "@eslint/js"; -import eslintConfigPrettier from "eslint-config-prettier"; -import turboPlugin from "eslint-plugin-turbo"; -import tseslint from "typescript-eslint"; -import onlyWarn from "eslint-plugin-only-warn"; +import js from "@eslint/js" +import eslintConfigPrettier from "eslint-config-prettier" +import onlyWarn from "eslint-plugin-only-warn" +import turboPlugin from "eslint-plugin-turbo" +import tseslint from "typescript-eslint" /** * A shared ESLint configuration for the repository. @@ -10,23 +10,23 @@ import onlyWarn from "eslint-plugin-only-warn"; * @type {import("eslint").Linter.Config[]} * */ export const config = [ - js.configs.recommended, - eslintConfigPrettier, - ...tseslint.configs.recommended, - { - plugins: { - turbo: turboPlugin, - }, - rules: { - "turbo/no-undeclared-env-vars": "warn", - }, - }, - { - plugins: { - onlyWarn, - }, - }, - { - ignores: ["dist/**"], - }, -]; + js.configs.recommended, + eslintConfigPrettier, + ...tseslint.configs.recommended, + { + plugins: { + turbo: turboPlugin, + }, + rules: { + "turbo/no-undeclared-env-vars": "warn", + }, + }, + { + plugins: { + onlyWarn, + }, + }, + { + ignores: ["dist/**"], + }, +] diff --git a/packages/eslint-config/next.js b/packages/eslint-config/next.js index 6bf01a74..74ef8ef3 100644 --- a/packages/eslint-config/next.js +++ b/packages/eslint-config/next.js @@ -1,11 +1,11 @@ -import js from "@eslint/js"; -import eslintConfigPrettier from "eslint-config-prettier"; -import tseslint from "typescript-eslint"; -import pluginReactHooks from "eslint-plugin-react-hooks"; -import pluginReact from "eslint-plugin-react"; -import globals from "globals"; -import pluginNext from "@next/eslint-plugin-next"; -import { config as baseConfig } from "./base.js"; +import js from "@eslint/js" +import pluginNext from "@next/eslint-plugin-next" +import eslintConfigPrettier from "eslint-config-prettier" +import pluginReact from "eslint-plugin-react" +import pluginReactHooks from "eslint-plugin-react-hooks" +import globals from "globals" +import tseslint from "typescript-eslint" +import { config as baseConfig } from "./base.js" /** * A custom ESLint configuration for libraries that use Next.js. @@ -13,37 +13,37 @@ import { config as baseConfig } from "./base.js"; * @type {import("eslint").Linter.Config[]} * */ export const nextJsConfig = [ - ...baseConfig, - js.configs.recommended, - eslintConfigPrettier, - ...tseslint.configs.recommended, - { - ...pluginReact.configs.flat.recommended, - languageOptions: { - ...pluginReact.configs.flat.recommended.languageOptions, - globals: { - ...globals.serviceworker, - }, - }, - }, - { - plugins: { - "@next/next": pluginNext, - }, - rules: { - ...pluginNext.configs.recommended.rules, - ...pluginNext.configs["core-web-vitals"].rules, - }, - }, - { - plugins: { - "react-hooks": pluginReactHooks, - }, - settings: { react: { version: "detect" } }, - rules: { - ...pluginReactHooks.configs.recommended.rules, - // React scope no longer necessary with new JSX transform. - "react/react-in-jsx-scope": "off", - }, - }, -]; + ...baseConfig, + js.configs.recommended, + eslintConfigPrettier, + ...tseslint.configs.recommended, + { + ...pluginReact.configs.flat.recommended, + languageOptions: { + ...pluginReact.configs.flat.recommended.languageOptions, + globals: { + ...globals.serviceworker, + }, + }, + }, + { + plugins: { + "@next/next": pluginNext, + }, + rules: { + ...pluginNext.configs.recommended.rules, + ...pluginNext.configs["core-web-vitals"].rules, + }, + }, + { + plugins: { + "react-hooks": pluginReactHooks, + }, + settings: { react: { version: "detect" } }, + rules: { + ...pluginReactHooks.configs.recommended.rules, + // React scope no longer necessary with new JSX transform. + "react/react-in-jsx-scope": "off", + }, + }, +] diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index b085db51..9f420e9c 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,24 +1,24 @@ { - "name": "@repo/eslint-config", - "version": "0.0.0", - "type": "module", - "private": true, - "exports": { - "./base": "./base.js", - "./next-js": "./next.js", - "./react-internal": "./react-internal.js" - }, - "devDependencies": { - "@eslint/js": "^9.33.0", - "@next/eslint-plugin-next": "^15.4.2", - "eslint": "^9.33.0", - "eslint-config-prettier": "^10.1.1", - "eslint-plugin-only-warn": "^1.1.0", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-turbo": "^2.5.0", - "globals": "^16.3.0", - "typescript": "^5.9.2", - "typescript-eslint": "^8.39.0" - } + "name": "@repo/eslint-config", + "version": "0.0.0", + "type": "module", + "private": true, + "exports": { + "./base": "./base.js", + "./next-js": "./next.js", + "./react-internal": "./react-internal.js" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@next/eslint-plugin-next": "^15.4.2", + "eslint": "^9.33.0", + "eslint-config-prettier": "^10.1.1", + "eslint-plugin-only-warn": "^1.1.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-turbo": "^2.5.0", + "globals": "^16.3.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.39.0" + } } diff --git a/packages/eslint-config/react-internal.js b/packages/eslint-config/react-internal.js index daeccba2..b179949e 100644 --- a/packages/eslint-config/react-internal.js +++ b/packages/eslint-config/react-internal.js @@ -1,39 +1,39 @@ -import js from "@eslint/js"; -import eslintConfigPrettier from "eslint-config-prettier"; -import tseslint from "typescript-eslint"; -import pluginReactHooks from "eslint-plugin-react-hooks"; -import pluginReact from "eslint-plugin-react"; -import globals from "globals"; -import { config as baseConfig } from "./base.js"; +import js from "@eslint/js" +import eslintConfigPrettier from "eslint-config-prettier" +import pluginReact from "eslint-plugin-react" +import pluginReactHooks from "eslint-plugin-react-hooks" +import globals from "globals" +import tseslint from "typescript-eslint" +import { config as baseConfig } from "./base.js" /** * A custom ESLint configuration for libraries that use React. * * @type {import("eslint").Linter.Config[]} */ export const config = [ - ...baseConfig, - js.configs.recommended, - eslintConfigPrettier, - ...tseslint.configs.recommended, - pluginReact.configs.flat.recommended, - { - languageOptions: { - ...pluginReact.configs.flat.recommended.languageOptions, - globals: { - ...globals.serviceworker, - ...globals.browser, - }, - }, - }, - { - plugins: { - "react-hooks": pluginReactHooks, - }, - settings: { react: { version: "detect" } }, - rules: { - ...pluginReactHooks.configs.recommended.rules, - // React scope no longer necessary with new JSX transform. - "react/react-in-jsx-scope": "off", - }, - }, -]; + ...baseConfig, + js.configs.recommended, + eslintConfigPrettier, + ...tseslint.configs.recommended, + pluginReact.configs.flat.recommended, + { + languageOptions: { + ...pluginReact.configs.flat.recommended.languageOptions, + globals: { + ...globals.serviceworker, + ...globals.browser, + }, + }, + }, + { + plugins: { + "react-hooks": pluginReactHooks, + }, + settings: { react: { version: "detect" } }, + rules: { + ...pluginReactHooks.configs.recommended.rules, + // React scope no longer necessary with new JSX transform. + "react/react-in-jsx-scope": "off", + }, + }, +] diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 918d877f..63a7d0e5 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,5 +1,5 @@ { - "name": "@repo/hooks", - "version": "0.0.0", - "private": true -}
\ No newline at end of file + "name": "@repo/hooks", + "version": "0.0.0", + "private": true +} diff --git a/packages/lib/api.ts b/packages/lib/api.ts index 3aa00b34..ba609b16 100644 --- a/packages/lib/api.ts +++ b/packages/lib/api.ts @@ -146,14 +146,14 @@ export const apiSchema = createSchema({ input: DocumentsWithMemoriesQuerySchema, output: DocumentsWithMemoriesResponseSchema, }, - "@post/memories/documents/by-ids": { - input: z.object({ - ids: z.array(z.string()), - by: z.enum(["id", "customId"]).optional(), - containerTags: z.array(z.string()).optional(), - }), - output: DocumentsWithMemoriesResponseSchema, - }, + "@post/memories/documents/by-ids": { + input: z.object({ + ids: z.array(z.string()), + by: z.enum(["id", "customId"]).optional(), + containerTags: z.array(z.string()).optional(), + }), + output: DocumentsWithMemoriesResponseSchema, + }, "@post/memories/migrate-mcp": { input: MigrateMCPRequestSchema, output: MigrateMCPResponseSchema, diff --git a/packages/lib/package.json b/packages/lib/package.json index dcd84902..7b548984 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -1,12 +1,12 @@ { - "name": "@repo/lib", - "version": "0.0.0", - "private": true, - "type": "module", - "dependencies": { - "@ai-sdk/anthropic": "^1.2.12", - "@ai-sdk/google": "^1.2.22", - "@ai-sdk/groq": "^1.2.9", - "ai-gateway-provider": "^0.0.11" - } + "name": "@repo/lib", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@ai-sdk/anthropic": "^1.2.12", + "@ai-sdk/google": "^1.2.22", + "@ai-sdk/groq": "^1.2.9", + "ai-gateway-provider": "^0.0.11" + } } diff --git a/packages/typescript-config/base.json b/packages/typescript-config/base.json index 5117f2a3..0756a8cd 100644 --- a/packages/typescript-config/base.json +++ b/packages/typescript-config/base.json @@ -1,19 +1,19 @@ { - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "declaration": true, - "declarationMap": true, - "esModuleInterop": true, - "incremental": false, - "isolatedModules": true, - "lib": ["es2022", "DOM", "DOM.Iterable"], - "module": "NodeNext", - "moduleDetection": "force", - "moduleResolution": "NodeNext", - "noUncheckedIndexedAccess": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "strict": true, - "target": "ES2022" - } + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "incremental": false, + "isolatedModules": true, + "lib": ["es2022", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleDetection": "force", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + } } diff --git a/packages/typescript-config/nextjs.json b/packages/typescript-config/nextjs.json index e6defa48..20317a2a 100644 --- a/packages/typescript-config/nextjs.json +++ b/packages/typescript-config/nextjs.json @@ -1,12 +1,12 @@ { - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./base.json", - "compilerOptions": { - "plugins": [{ "name": "next" }], - "module": "ESNext", - "moduleResolution": "Bundler", - "allowJs": true, - "jsx": "preserve", - "noEmit": true - } + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "plugins": [{ "name": "next" }], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowJs": true, + "jsx": "preserve", + "noEmit": true + } } diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 27c0e604..a9d4b226 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,9 +1,9 @@ { - "name": "@repo/typescript-config", - "version": "0.0.0", - "private": true, - "license": "MIT", - "publishConfig": { - "access": "public" - } + "name": "@repo/typescript-config", + "version": "0.0.0", + "private": true, + "license": "MIT", + "publishConfig": { + "access": "public" + } } diff --git a/packages/typescript-config/react-library.json b/packages/typescript-config/react-library.json index c3a1b26f..44957d69 100644 --- a/packages/typescript-config/react-library.json +++ b/packages/typescript-config/react-library.json @@ -1,7 +1,7 @@ { - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./base.json", - "compilerOptions": { - "jsx": "react-jsx" - } + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "jsx": "react-jsx" + } } diff --git a/packages/ui/assets/Logo.tsx b/packages/ui/assets/Logo.tsx index 8e6b209e..3b9e98a9 100644 --- a/packages/ui/assets/Logo.tsx +++ b/packages/ui/assets/Logo.tsx @@ -2,8 +2,8 @@ export const Logo = ({ className, id, }: { - className?: string - id?: string + className?: string; + id?: string; }) => { return ( <svg @@ -19,15 +19,15 @@ export const Logo = ({ fill="#EFEFEF" /> </svg> - ) -} + ); +}; export const LogoFull = ({ className, id, }: { - className?: string - id?: string + className?: string; + id?: string; }) => { return ( <svg @@ -47,5 +47,5 @@ export const LogoFull = ({ </clipPath> </defs> </svg> - ) -} + ); +}; diff --git a/packages/ui/assets/icons.tsx b/packages/ui/assets/icons.tsx index 5eb38b42..5383f690 100644 --- a/packages/ui/assets/icons.tsx +++ b/packages/ui/assets/icons.tsx @@ -22,7 +22,7 @@ export const OneDrive = ({ className }: { className?: string }) => ( fill="#28A8EA" /> </svg> -) +); export const GoogleDrive = ({ className }: { className?: string }) => ( <svg @@ -56,7 +56,7 @@ export const GoogleDrive = ({ className }: { className?: string }) => ( fill="#FFBA00" /> </svg> -) +); export const Notion = ({ className }: { className?: string }) => ( <svg @@ -71,7 +71,7 @@ export const Notion = ({ className }: { className?: string }) => ( /> <path d="M164.09.608L16.092 11.538C4.155 12.573 0 20.374 0 29.726v162.245c0 7.284 2.585 13.516 8.826 21.843l34.789 45.237c5.715 7.284 10.912 8.844 21.825 8.327l171.864-10.404c14.532-1.035 18.696-7.801 18.696-19.24V55.207c0-5.911-2.336-7.614-9.21-12.66l-1.185-.856L198.37 8.409C186.94.1 182.27-.952 164.09.608M69.327 52.22c-14.033.945-17.216 1.159-25.186-5.323L23.876 30.778c-2.06-2.086-1.026-4.69 4.163-5.207l142.274-10.395c11.947-1.043 18.17 3.12 22.842 6.758l24.401 17.68c1.043.525 3.638 3.637.517 3.637L71.146 52.095zm-16.36 183.954V81.222c0-6.767 2.077-9.887 8.3-10.413L230.02 60.93c5.724-.517 8.31 3.12 8.31 9.879v153.917c0 6.767-1.044 12.49-10.387 13.008l-161.487 9.361c-9.343.517-13.489-2.594-13.489-10.921M212.377 89.53c1.034 4.681 0 9.362-4.681 9.897l-7.783 1.542v114.404c-6.758 3.637-12.981 5.715-18.18 5.715c-8.308 0-10.386-2.604-16.609-10.396l-50.898-80.079v77.476l16.1 3.646s0 9.362-12.989 9.362l-35.814 2.077c-1.043-2.086 0-7.284 3.63-8.318l9.351-2.595V109.823l-12.98-1.052c-1.044-4.68 1.55-11.439 8.826-11.965l38.426-2.585l52.958 81.113v-71.76l-13.498-1.552c-1.043-5.733 3.111-9.896 8.3-10.404z" /> </svg> -) +); export const GoogleDocs = ({ className }: { className?: string }) => ( <svg @@ -85,7 +85,7 @@ export const GoogleDocs = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -) +); export const GoogleSheets = ({ className }: { className?: string }) => ( <svg @@ -99,7 +99,7 @@ export const GoogleSheets = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -) +); export const GoogleSlides = ({ className }: { className?: string }) => ( <svg @@ -113,7 +113,7 @@ export const GoogleSlides = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -) +); export const NotionDoc = ({ className }: { className?: string }) => ( <svg @@ -127,7 +127,7 @@ export const NotionDoc = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -) +); export const MicrosoftWord = ({ className }: { className?: string }) => ( <svg @@ -141,7 +141,7 @@ export const MicrosoftWord = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -) +); export const MicrosoftExcel = ({ className }: { className?: string }) => ( <svg @@ -155,7 +155,7 @@ export const MicrosoftExcel = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -) +); export const MicrosoftPowerpoint = ({ className }: { className?: string }) => ( <svg @@ -169,7 +169,7 @@ export const MicrosoftPowerpoint = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -) +); export const MicrosoftOneNote = ({ className }: { className?: string }) => ( <svg @@ -183,7 +183,7 @@ export const MicrosoftOneNote = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -) +); export const PDF = ({ className }: { className?: string }) => ( <svg @@ -205,4 +205,4 @@ export const PDF = ({ className }: { className?: string }) => ( fill="currentColor" /> </svg> -) +); diff --git a/packages/ui/biome.json b/packages/ui/biome.json index 79f38fb5..3fdb0558 100644 --- a/packages/ui/biome.json +++ b/packages/ui/biome.json @@ -1,10 +1,4 @@ { - "$schema": "https://biomejs.dev/schemas/2.1.2/schema.json", - "extends": "//", - "linter": { - "domains": { - "next": "recommended", - "react": "recommended" - } - } + "root": false, + "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json" } diff --git a/packages/ui/button/external-auth.tsx b/packages/ui/button/external-auth.tsx index d61c11e3..e25219a6 100644 --- a/packages/ui/button/external-auth.tsx +++ b/packages/ui/button/external-auth.tsx @@ -1,9 +1,9 @@ -import { cn } from "@lib/utils" -import { Button } from "@ui/components/button" +import { cn } from "@lib/utils"; +import { Button } from "@ui/components/button"; interface ExternalAuthButtonProps extends React.ComponentProps<typeof Button> { - authProvider: string - authIcon: React.ReactNode + authProvider: string; + authIcon: React.ReactNode; } export function ExternalAuthButton({ @@ -25,5 +25,5 @@ export function ExternalAuthButton({ Continue with {authProvider} </span> </Button> - ) + ); } diff --git a/packages/ui/components/accordion.tsx b/packages/ui/components/accordion.tsx index 1a33121e..0a5ef2b8 100644 --- a/packages/ui/components/accordion.tsx +++ b/packages/ui/components/accordion.tsx @@ -1,14 +1,14 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDownIcon } from "lucide-react" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "lucide-react"; +import type * as React from "react"; function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) { - return <AccordionPrimitive.Root data-slot="accordion" {...props} /> + return <AccordionPrimitive.Root data-slot="accordion" {...props} />; } function AccordionItem({ @@ -21,7 +21,7 @@ function AccordionItem({ data-slot="accordion-item" {...props} /> - ) + ); } function AccordionTrigger({ @@ -43,7 +43,7 @@ function AccordionTrigger({ <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" /> </AccordionPrimitive.Trigger> </AccordionPrimitive.Header> - ) + ); } function AccordionContent({ @@ -59,7 +59,7 @@ function AccordionContent({ > <div className={cn("pt-0 pb-4", className)}>{children}</div> </AccordionPrimitive.Content> - ) + ); } -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/packages/ui/components/alert-dialog.tsx b/packages/ui/components/alert-dialog.tsx index 619f8516..b4765eec 100644 --- a/packages/ui/components/alert-dialog.tsx +++ b/packages/ui/components/alert-dialog.tsx @@ -1,14 +1,14 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" -import { buttonVariants } from "@ui/components/button" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import { buttonVariants } from "@ui/components/button"; +import type * as React from "react"; function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { - return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> + return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />; } function AlertDialogTrigger({ @@ -16,7 +16,7 @@ function AlertDialogTrigger({ }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { return ( <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} /> - ) + ); } function AlertDialogPortal({ @@ -24,7 +24,7 @@ function AlertDialogPortal({ }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { return ( <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> - ) + ); } function AlertDialogOverlay({ @@ -40,7 +40,7 @@ function AlertDialogOverlay({ data-slot="alert-dialog-overlay" {...props} /> - ) + ); } function AlertDialogContent({ @@ -59,7 +59,7 @@ function AlertDialogContent({ {...props} /> </AlertDialogPortal> - ) + ); } function AlertDialogHeader({ @@ -72,7 +72,7 @@ function AlertDialogHeader({ data-slot="alert-dialog-header" {...props} /> - ) + ); } function AlertDialogFooter({ @@ -88,7 +88,7 @@ function AlertDialogFooter({ data-slot="alert-dialog-footer" {...props} /> - ) + ); } function AlertDialogTitle({ @@ -101,7 +101,7 @@ function AlertDialogTitle({ data-slot="alert-dialog-title" {...props} /> - ) + ); } function AlertDialogDescription({ @@ -114,7 +114,7 @@ function AlertDialogDescription({ data-slot="alert-dialog-description" {...props} /> - ) + ); } function AlertDialogAction({ @@ -126,7 +126,7 @@ function AlertDialogAction({ className={cn(buttonVariants(), className)} {...props} /> - ) + ); } function AlertDialogCancel({ @@ -138,7 +138,7 @@ function AlertDialogCancel({ className={cn(buttonVariants({ variant: "outline" }), className)} {...props} /> - ) + ); } export { @@ -153,4 +153,4 @@ export { AlertDialogDescription, AlertDialogAction, AlertDialogCancel, -} +}; diff --git a/packages/ui/components/avatar.tsx b/packages/ui/components/avatar.tsx index d73d2ea6..db668678 100644 --- a/packages/ui/components/avatar.tsx +++ b/packages/ui/components/avatar.tsx @@ -1,8 +1,8 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as AvatarPrimitive from "@radix-ui/react-avatar" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import type * as React from "react"; function Avatar({ className, @@ -17,7 +17,7 @@ function Avatar({ data-slot="avatar" {...props} /> - ) + ); } function AvatarImage({ @@ -30,7 +30,7 @@ function AvatarImage({ data-slot="avatar-image" {...props} /> - ) + ); } function AvatarFallback({ @@ -46,7 +46,7 @@ function AvatarFallback({ data-slot="avatar-fallback" {...props} /> - ) + ); } -export { Avatar, AvatarImage, AvatarFallback } +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/packages/ui/components/badge.tsx b/packages/ui/components/badge.tsx index 977109e6..c5a03c29 100644 --- a/packages/ui/components/badge.tsx +++ b/packages/ui/components/badge.tsx @@ -1,7 +1,7 @@ -import { cn } from "@lib/utils" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" -import type * as React from "react" +import { cn } from "@lib/utils"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; const badgeVariants = cva( "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", @@ -22,7 +22,7 @@ const badgeVariants = cva( variant: "default", }, }, -) +); function Badge({ className, @@ -31,7 +31,7 @@ function Badge({ ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span" + const Comp = asChild ? Slot : "span"; return ( <Comp @@ -39,7 +39,7 @@ function Badge({ data-slot="badge" {...props} /> - ) + ); } -export { Badge, badgeVariants } +export { Badge, badgeVariants }; diff --git a/packages/ui/components/breadcrumb.tsx b/packages/ui/components/breadcrumb.tsx index 7a8104d4..1582ccdb 100644 --- a/packages/ui/components/breadcrumb.tsx +++ b/packages/ui/components/breadcrumb.tsx @@ -1,10 +1,10 @@ -import { cn } from "@lib/utils" -import { Slot } from "@radix-ui/react-slot" -import { ChevronRight, MoreHorizontal } from "lucide-react" -import type * as React from "react" +import { cn } from "@lib/utils"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import type * as React from "react"; function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { - return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} /> + return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />; } function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { @@ -17,7 +17,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { data-slot="breadcrumb-list" {...props} /> - ) + ); } function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { @@ -27,7 +27,7 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { data-slot="breadcrumb-item" {...props} /> - ) + ); } function BreadcrumbLink({ @@ -35,9 +35,9 @@ function BreadcrumbLink({ className, ...props }: React.ComponentProps<"a"> & { - asChild?: boolean + asChild?: boolean; }) { - const Comp = asChild ? Slot : "a" + const Comp = asChild ? Slot : "a"; return ( <Comp @@ -45,7 +45,7 @@ function BreadcrumbLink({ data-slot="breadcrumb-link" {...props} /> - ) + ); } function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { @@ -59,7 +59,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { tabIndex={0} {...props} /> - ) + ); } function BreadcrumbSeparator({ @@ -77,7 +77,7 @@ function BreadcrumbSeparator({ > {children ?? <ChevronRight />} </li> - ) + ); } function BreadcrumbEllipsis({ @@ -95,7 +95,7 @@ function BreadcrumbEllipsis({ <MoreHorizontal className="size-4" /> <span className="sr-only">More</span> </span> - ) + ); } export { @@ -106,4 +106,4 @@ export { BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis, -} +}; diff --git a/packages/ui/components/button.tsx b/packages/ui/components/button.tsx index 99d10e10..3a671e03 100644 --- a/packages/ui/components/button.tsx +++ b/packages/ui/components/button.tsx @@ -1,7 +1,7 @@ -import { cn } from "@lib/utils" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" -import type * as React from "react" +import { cn } from "@lib/utils"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -32,7 +32,7 @@ const buttonVariants = cva( size: "default", }, }, -) +); function Button({ className, @@ -42,9 +42,9 @@ function Button({ ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { - asChild?: boolean + asChild?: boolean; }) { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : "button"; return ( <Comp @@ -52,7 +52,7 @@ function Button({ data-slot="button" {...props} /> - ) + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/packages/ui/components/card.tsx b/packages/ui/components/card.tsx index cc4b5fd4..2086d12e 100644 --- a/packages/ui/components/card.tsx +++ b/packages/ui/components/card.tsx @@ -1,5 +1,5 @@ -import { cn } from "@lib/utils" -import type * as React from "react" +import { cn } from "@lib/utils"; +import type * as React from "react"; function Card({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -11,7 +11,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) { data-slot="card" {...props} /> - ) + ); } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -24,7 +24,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-header" {...props} /> - ) + ); } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { @@ -34,7 +34,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-title" {...props} /> - ) + ); } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { @@ -44,7 +44,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-description" {...props} /> - ) + ); } function CardAction({ className, ...props }: React.ComponentProps<"div">) { @@ -57,7 +57,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-action" {...props} /> - ) + ); } function CardContent({ className, ...props }: React.ComponentProps<"div">) { @@ -67,7 +67,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-content" {...props} /> - ) + ); } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { @@ -77,7 +77,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-footer" {...props} /> - ) + ); } export { @@ -88,4 +88,4 @@ export { CardAction, CardDescription, CardContent, -} +}; diff --git a/packages/ui/components/carousel.tsx b/packages/ui/components/carousel.tsx index ecc89fe1..2462c404 100644 --- a/packages/ui/components/carousel.tsx +++ b/packages/ui/components/carousel.tsx @@ -1,44 +1,44 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import { Button } from "@ui/components/button" +import { cn } from "@lib/utils"; +import { Button } from "@ui/components/button"; import useEmblaCarousel, { type UseEmblaCarouselType, -} from "embla-carousel-react" -import { ArrowLeft, ArrowRight } from "lucide-react" -import * as React from "react" +} from "embla-carousel-react"; +import { ArrowLeft, ArrowRight } from "lucide-react"; +import * as React from "react"; -type CarouselApi = UseEmblaCarouselType[1] -type UseCarouselParameters = Parameters<typeof useEmblaCarousel> -type CarouselOptions = UseCarouselParameters[0] -type CarouselPlugin = UseCarouselParameters[1] +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; type CarouselProps = { - opts?: CarouselOptions - plugins?: CarouselPlugin - orientation?: "horizontal" | "vertical" - setApi?: (api: CarouselApi) => void -} + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; +}; type CarouselContextProps = { - carouselRef: ReturnType<typeof useEmblaCarousel>[0] - api: ReturnType<typeof useEmblaCarousel>[1] - scrollPrev: () => void - scrollNext: () => void - canScrollPrev: boolean - canScrollNext: boolean -} & CarouselProps + carouselRef: ReturnType<typeof useEmblaCarousel>[0]; + api: ReturnType<typeof useEmblaCarousel>[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; -const CarouselContext = React.createContext<CarouselContextProps | null>(null) +const CarouselContext = React.createContext<CarouselContextProps | null>(null); function useCarousel() { - const context = React.useContext(CarouselContext) + const context = React.useContext(CarouselContext); if (!context) { - throw new Error("useCarousel must be used within a <Carousel />") + throw new Error("useCarousel must be used within a <Carousel />"); } - return context + return context; } function Carousel({ @@ -56,52 +56,52 @@ function Carousel({ axis: orientation === "horizontal" ? "x" : "y", }, plugins, - ) - const [canScrollPrev, setCanScrollPrev] = React.useState(false) - const [canScrollNext, setCanScrollNext] = React.useState(false) + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); const onSelect = React.useCallback((api: CarouselApi) => { - if (!api) return - setCanScrollPrev(api.canScrollPrev()) - setCanScrollNext(api.canScrollNext()) - }, []) + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); const scrollPrev = React.useCallback(() => { - api?.scrollPrev() - }, [api]) + api?.scrollPrev(); + }, [api]); const scrollNext = React.useCallback(() => { - api?.scrollNext() - }, [api]) + api?.scrollNext(); + }, [api]); const handleKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLDivElement>) => { if (event.key === "ArrowLeft") { - event.preventDefault() - scrollPrev() + event.preventDefault(); + scrollPrev(); } else if (event.key === "ArrowRight") { - event.preventDefault() - scrollNext() + event.preventDefault(); + scrollNext(); } }, [scrollPrev, scrollNext], - ) + ); React.useEffect(() => { - if (!api || !setApi) return - setApi(api) - }, [api, setApi]) + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); React.useEffect(() => { - if (!api) return - onSelect(api) - api.on("reInit", onSelect) - api.on("select", onSelect) + if (!api) return; + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); return () => { - api?.off("select", onSelect) - } - }, [api, onSelect]) + api?.off("select", onSelect); + }; + }, [api, onSelect]); return ( <CarouselContext.Provider @@ -126,11 +126,11 @@ function Carousel({ {children} </section> </CarouselContext.Provider> - ) + ); } function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { - const { carouselRef, orientation } = useCarousel() + const { carouselRef, orientation } = useCarousel(); return ( <div @@ -147,11 +147,11 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { {...props} /> </div> - ) + ); } function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { - const { orientation } = useCarousel() + const { orientation } = useCarousel(); return ( <div @@ -165,7 +165,7 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { role="group" {...props} /> - ) + ); } function CarouselPrevious({ @@ -174,7 +174,7 @@ function CarouselPrevious({ size = "icon", ...props }: React.ComponentProps<typeof Button>) { - const { orientation, scrollPrev, canScrollPrev } = useCarousel() + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); return ( <Button @@ -195,7 +195,7 @@ function CarouselPrevious({ <ArrowLeft /> <span className="sr-only">Previous slide</span> </Button> - ) + ); } function CarouselNext({ @@ -204,7 +204,7 @@ function CarouselNext({ size = "icon", ...props }: React.ComponentProps<typeof Button>) { - const { orientation, scrollNext, canScrollNext } = useCarousel() + const { orientation, scrollNext, canScrollNext } = useCarousel(); return ( <Button @@ -225,7 +225,7 @@ function CarouselNext({ <ArrowRight /> <span className="sr-only">Next slide</span> </Button> - ) + ); } export { @@ -235,4 +235,4 @@ export { CarouselItem, CarouselPrevious, CarouselNext, -} +}; diff --git a/packages/ui/components/chart.tsx b/packages/ui/components/chart.tsx index 6f8f6642..3b94bda0 100644 --- a/packages/ui/components/chart.tsx +++ b/packages/ui/components/chart.tsx @@ -1,36 +1,36 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as React from "react" -import * as RechartsPrimitive from "recharts" +import { cn } from "@lib/utils"; +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; // Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: "", dark: ".dark" } as const +const THEMES = { light: "", dark: ".dark" } as const; export type ChartConfig = { [k in string]: { - label?: React.ReactNode - icon?: React.ComponentType + label?: React.ReactNode; + icon?: React.ComponentType; } & ( | { color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> } - ) -} + ); +}; type ChartContextProps = { - config: ChartConfig -} + config: ChartConfig; +}; -const ChartContext = React.createContext<ChartContextProps | null>(null) +const ChartContext = React.createContext<ChartContextProps | null>(null); function useChart() { - const context = React.useContext(ChartContext) + const context = React.useContext(ChartContext); if (!context) { - throw new Error("useChart must be used within a <ChartContainer />") + throw new Error("useChart must be used within a <ChartContainer />"); } - return context + return context; } function ChartContainer({ @@ -40,13 +40,13 @@ function ChartContainer({ config, ...props }: React.ComponentProps<"div"> & { - config: ChartConfig + config: ChartConfig; children: React.ComponentProps< typeof RechartsPrimitive.ResponsiveContainer - >["children"] + >["children"]; }) { - const uniqueId = React.useId() - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; return ( <ChartContext.Provider value={{ config }}> @@ -65,16 +65,16 @@ function ChartContainer({ </RechartsPrimitive.ResponsiveContainer> </div> </ChartContext.Provider> - ) + ); } const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const colorConfig = Object.entries(config).filter( ([, config]) => config.theme || config.color, - ) + ); if (!colorConfig.length) { - return null + return null; } return ( @@ -89,8 +89,8 @@ ${colorConfig .map(([key, itemConfig]) => { const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || - itemConfig.color - return color ? ` --color-${key}: ${color};` : null + itemConfig.color; + return color ? ` --color-${key}: ${color};` : null; }) .join("\n")} } @@ -99,10 +99,10 @@ ${colorConfig .join("\n"), }} /> - ) -} + ); +}; -const ChartTooltip = RechartsPrimitive.Tooltip +const ChartTooltip = RechartsPrimitive.Tooltip; function ChartTooltipContent({ active, @@ -120,40 +120,40 @@ function ChartTooltipContent({ labelKey, }: React.ComponentProps<typeof RechartsPrimitive.Tooltip> & React.ComponentProps<"div"> & { - hideLabel?: boolean - hideIndicator?: boolean - indicator?: "line" | "dot" | "dashed" - nameKey?: string - labelKey?: string + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: "line" | "dot" | "dashed"; + nameKey?: string; + labelKey?: string; }) { - const { config } = useChart() + const { config } = useChart(); const tooltipLabel = React.useMemo(() => { if (hideLabel || !payload?.length) { - return null + return null; } - const [item] = payload - const key = `${labelKey || item?.dataKey || item?.name || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) + const [item] = payload; + const key = `${labelKey || item?.dataKey || item?.name || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); const value = !labelKey && typeof label === "string" ? config[label as keyof typeof config]?.label || label - : itemConfig?.label + : itemConfig?.label; if (labelFormatter) { return ( <div className={cn("font-medium", labelClassName)}> {labelFormatter(value, payload)} </div> - ) + ); } if (!value) { - return null + return null; } - return <div className={cn("font-medium", labelClassName)}>{value}</div> + return <div className={cn("font-medium", labelClassName)}>{value}</div>; }, [ label, labelFormatter, @@ -162,13 +162,13 @@ function ChartTooltipContent({ labelClassName, config, labelKey, - ]) + ]); if (!active || !payload?.length) { - return null + return null; } - const nestLabel = payload.length === 1 && indicator !== "dot" + const nestLabel = payload.length === 1 && indicator !== "dot"; return ( <div @@ -180,9 +180,9 @@ function ChartTooltipContent({ {!nestLabel ? tooltipLabel : null} <div className="grid gap-1.5"> {payload.map((item, index) => { - const key = `${nameKey || item.name || item.dataKey || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) - const indicatorColor = color || item.payload.fill || item.color + const key = `${nameKey || item.name || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const indicatorColor = color || item.payload.fill || item.color; return ( <div @@ -241,14 +241,14 @@ function ChartTooltipContent({ </> )} </div> - ) + ); })} </div> </div> - ) + ); } -const ChartLegend = RechartsPrimitive.Legend +const ChartLegend = RechartsPrimitive.Legend; function ChartLegendContent({ className, @@ -258,13 +258,13 @@ function ChartLegendContent({ nameKey, }: React.ComponentProps<"div"> & Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { - hideIcon?: boolean - nameKey?: string + hideIcon?: boolean; + nameKey?: string; }) { - const { config } = useChart() + const { config } = useChart(); if (!payload?.length) { - return null + return null; } return ( @@ -276,8 +276,8 @@ function ChartLegendContent({ )} > {payload.map((item) => { - const key = `${nameKey || item.dataKey || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) + const key = `${nameKey || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); return ( <div @@ -298,10 +298,10 @@ function ChartLegendContent({ )} {itemConfig?.label} </div> - ) + ); })} </div> - ) + ); } // Helper to extract item config from a payload. @@ -311,7 +311,7 @@ function getPayloadConfigFromPayload( key: string, ) { if (typeof payload !== "object" || payload === null) { - return undefined + return undefined; } const payloadPayload = @@ -319,15 +319,15 @@ function getPayloadConfigFromPayload( typeof payload.payload === "object" && payload.payload !== null ? payload.payload - : undefined + : undefined; - let configLabelKey: string = key + let configLabelKey: string = key; if ( key in payload && typeof payload[key as keyof typeof payload] === "string" ) { - configLabelKey = payload[key as keyof typeof payload] as string + configLabelKey = payload[key as keyof typeof payload] as string; } else if ( payloadPayload && key in payloadPayload && @@ -335,12 +335,12 @@ function getPayloadConfigFromPayload( ) { configLabelKey = payloadPayload[ key as keyof typeof payloadPayload - ] as string + ] as string; } return configLabelKey in config ? config[configLabelKey] - : config[key as keyof typeof config] + : config[key as keyof typeof config]; } export { @@ -350,4 +350,4 @@ export { ChartLegend, ChartLegendContent, ChartStyle, -} +}; diff --git a/packages/ui/components/checkbox.tsx b/packages/ui/components/checkbox.tsx index 45a968f9..064a7627 100644 --- a/packages/ui/components/checkbox.tsx +++ b/packages/ui/components/checkbox.tsx @@ -1,9 +1,9 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { CheckIcon } from "lucide-react" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "lucide-react"; +import type * as React from "react"; function Checkbox({ className, @@ -25,7 +25,7 @@ function Checkbox({ <CheckIcon className="size-3.5" /> </CheckboxPrimitive.Indicator> </CheckboxPrimitive.Root> - ) + ); } -export { Checkbox } +export { Checkbox }; diff --git a/packages/ui/components/collapsible.tsx b/packages/ui/components/collapsible.tsx index f8de4e4c..0551ffdd 100644 --- a/packages/ui/components/collapsible.tsx +++ b/packages/ui/components/collapsible.tsx @@ -1,11 +1,11 @@ -"use client" +"use client"; -import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { - return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> + return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />; } function CollapsibleTrigger({ @@ -16,7 +16,7 @@ function CollapsibleTrigger({ data-slot="collapsible-trigger" {...props} /> - ) + ); } function CollapsibleContent({ @@ -27,7 +27,7 @@ function CollapsibleContent({ data-slot="collapsible-content" {...props} /> - ) + ); } -export { Collapsible, CollapsibleTrigger, CollapsibleContent } +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/packages/ui/components/combobox.tsx b/packages/ui/components/combobox.tsx index 8c9f9c97..657e38e2 100644 --- a/packages/ui/components/combobox.tsx +++ b/packages/ui/components/combobox.tsx @@ -1,7 +1,7 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import { Button } from "@ui/components/button" +import { cn } from "@lib/utils"; +import { Button } from "@ui/components/button"; import { Command, CommandEmpty, @@ -9,25 +9,29 @@ import { CommandInput, CommandItem, CommandList, -} from "@ui/components/command" -import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" -import { Check, ChevronsUpDown, X } from "lucide-react" -import * as React from "react" +} from "@ui/components/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@ui/components/popover"; +import { Check, ChevronsUpDown, X } from "lucide-react"; +import * as React from "react"; interface Option { - value: string - label: string + value: string; + label: string; } interface ComboboxProps { - options: Option[] - onSelect: (value: string) => void - onSubmit: (newName: string) => void - selectedValues: string[] - setSelectedValues: React.Dispatch<React.SetStateAction<string[]>> - className?: string - placeholder?: string - triggerClassName?: string + options: Option[]; + onSelect: (value: string) => void; + onSubmit: (newName: string) => void; + selectedValues: string[]; + setSelectedValues: React.Dispatch<React.SetStateAction<string[]>>; + className?: string; + placeholder?: string; + triggerClassName?: string; } export function Combobox({ @@ -40,36 +44,38 @@ export function Combobox({ placeholder = "Select...", triggerClassName, }: ComboboxProps) { - const [open, setOpen] = React.useState(false) - const [inputValue, setInputValue] = React.useState("") + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); const handleSelect = (value: string) => { - onSelect(value) - setOpen(false) - setInputValue("") - } + onSelect(value); + setOpen(false); + setInputValue(""); + }; const handleCreate = () => { if (inputValue.trim()) { - onSubmit(inputValue) - setOpen(false) - setInputValue("") + onSubmit(inputValue); + setOpen(false); + setInputValue(""); } - } + }; const handleRemove = (valueToRemove: string) => { - setSelectedValues((prev) => prev.filter((value) => value !== valueToRemove)) - } + setSelectedValues((prev) => + prev.filter((value) => value !== valueToRemove), + ); + }; const filteredOptions = options.filter( (option) => !selectedValues.includes(option.value), - ) + ); const isNewValue = inputValue.trim() && !options.some( (option) => option.label.toLowerCase() === inputValue.toLowerCase(), - ) + ); return ( <Popover onOpenChange={setOpen} open={open}> @@ -87,7 +93,7 @@ export function Combobox({ <div className="flex flex-wrap gap-1 items-center w-full"> {selectedValues.length > 0 ? ( selectedValues.map((value) => { - const option = options.find((opt) => opt.value === value) + const option = options.find((opt) => opt.value === value); return ( <span className="inline-flex items-center gap-1 px-2 py-0.5 bg-secondary text-sm rounded-md" @@ -97,15 +103,15 @@ export function Combobox({ <button className="hover:text-destructive" onClick={(e) => { - e.stopPropagation() - handleRemove(value) + e.stopPropagation(); + handleRemove(value); }} type="button" > <X className="h-3 w-3" /> </button> </span> - ) + ); }) ) : ( <span className="text-muted-foreground">{placeholder}</span> @@ -157,5 +163,5 @@ export function Combobox({ </Command> </PopoverContent> </Popover> - ) + ); } diff --git a/packages/ui/components/command.tsx b/packages/ui/components/command.tsx index 4c04c8c3..7fa7aed6 100644 --- a/packages/ui/components/command.tsx +++ b/packages/ui/components/command.tsx @@ -1,16 +1,16 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" +import { cn } from "@lib/utils"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, -} from "@ui/components/dialog" -import { Command as CommandPrimitive } from "cmdk" -import { SearchIcon } from "lucide-react" -import type * as React from "react" +} from "@ui/components/dialog"; +import { Command as CommandPrimitive } from "cmdk"; +import { SearchIcon } from "lucide-react"; +import type * as React from "react"; function Command({ className, @@ -25,7 +25,7 @@ function Command({ data-slot="command" {...props} /> - ) + ); } function CommandDialog({ @@ -36,10 +36,10 @@ function CommandDialog({ showCloseButton = true, ...props }: React.ComponentProps<typeof Dialog> & { - title?: string - description?: string - className?: string - showCloseButton?: boolean + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; }) { return ( <Dialog {...props}> @@ -56,7 +56,7 @@ function CommandDialog({ </Command> </DialogContent> </Dialog> - ) + ); } function CommandInput({ @@ -78,7 +78,7 @@ function CommandInput({ {...props} /> </div> - ) + ); } function CommandList({ @@ -94,7 +94,7 @@ function CommandList({ data-slot="command-list" {...props} /> - ) + ); } function CommandEmpty({ @@ -106,7 +106,7 @@ function CommandEmpty({ data-slot="command-empty" {...props} /> - ) + ); } function CommandGroup({ @@ -122,7 +122,7 @@ function CommandGroup({ data-slot="command-group" {...props} /> - ) + ); } function CommandSeparator({ @@ -135,7 +135,7 @@ function CommandSeparator({ data-slot="command-separator" {...props} /> - ) + ); } function CommandItem({ @@ -151,7 +151,7 @@ function CommandItem({ data-slot="command-item" {...props} /> - ) + ); } function CommandShortcut({ @@ -167,7 +167,7 @@ function CommandShortcut({ data-slot="command-shortcut" {...props} /> - ) + ); } export { @@ -180,4 +180,4 @@ export { CommandItem, CommandShortcut, CommandSeparator, -} +}; diff --git a/packages/ui/components/dialog.tsx b/packages/ui/components/dialog.tsx index b0a45b55..80530816 100644 --- a/packages/ui/components/dialog.tsx +++ b/packages/ui/components/dialog.tsx @@ -1,32 +1,32 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import type * as React from "react"; function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) { - return <DialogPrimitive.Root data-slot="dialog" {...props} /> + return <DialogPrimitive.Root data-slot="dialog" {...props} />; } function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { - return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; } function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) { - return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; } function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) { - return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> + return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; } function DialogOverlay({ @@ -42,7 +42,7 @@ function DialogOverlay({ data-slot="dialog-overlay" {...props} /> - ) + ); } function DialogContent({ @@ -51,7 +51,7 @@ function DialogContent({ showCloseButton = true, ...props }: React.ComponentProps<typeof DialogPrimitive.Content> & { - showCloseButton?: boolean + showCloseButton?: boolean; }) { return ( <DialogPortal data-slot="dialog-portal"> @@ -76,7 +76,7 @@ function DialogContent({ )} </DialogPrimitive.Content> </DialogPortal> - ) + ); } function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -86,7 +86,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { data-slot="dialog-header" {...props} /> - ) + ); } function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { @@ -99,7 +99,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { data-slot="dialog-footer" {...props} /> - ) + ); } function DialogTitle({ @@ -112,7 +112,7 @@ function DialogTitle({ data-slot="dialog-title" {...props} /> - ) + ); } function DialogDescription({ @@ -125,7 +125,7 @@ function DialogDescription({ data-slot="dialog-description" {...props} /> - ) + ); } export { @@ -139,4 +139,4 @@ export { DialogPortal, DialogTitle, DialogTrigger, -} +}; diff --git a/packages/ui/components/drawer.tsx b/packages/ui/components/drawer.tsx index 6431e856..3e1b1c5e 100644 --- a/packages/ui/components/drawer.tsx +++ b/packages/ui/components/drawer.tsx @@ -1,31 +1,31 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import type * as React from "react" -import { Drawer as DrawerPrimitive } from "vaul" +import { cn } from "@lib/utils"; +import type * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; function Drawer({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) { - return <DrawerPrimitive.Root data-slot="drawer" {...props} /> + return <DrawerPrimitive.Root data-slot="drawer" {...props} />; } function DrawerTrigger({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) { - return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} /> + return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />; } function DrawerPortal({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Portal>) { - return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} /> + return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />; } function DrawerClose({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Close>) { - return <DrawerPrimitive.Close data-slot="drawer-close" {...props} /> + return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />; } function DrawerOverlay({ @@ -41,7 +41,7 @@ function DrawerOverlay({ data-slot="drawer-overlay" {...props} /> - ) + ); } function DrawerContent({ @@ -68,7 +68,7 @@ function DrawerContent({ {children} </DrawerPrimitive.Content> </DrawerPortal> - ) + ); } function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -81,7 +81,7 @@ function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { data-slot="drawer-header" {...props} /> - ) + ); } function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { @@ -91,7 +91,7 @@ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { data-slot="drawer-footer" {...props} /> - ) + ); } function DrawerTitle({ @@ -104,7 +104,7 @@ function DrawerTitle({ data-slot="drawer-title" {...props} /> - ) + ); } function DrawerDescription({ @@ -117,7 +117,7 @@ function DrawerDescription({ data-slot="drawer-description" {...props} /> - ) + ); } export { @@ -131,4 +131,4 @@ export { DrawerFooter, DrawerTitle, DrawerDescription, -} +}; diff --git a/packages/ui/components/dropdown-menu.tsx b/packages/ui/components/dropdown-menu.tsx index fbd09e9c..5af01088 100644 --- a/packages/ui/components/dropdown-menu.tsx +++ b/packages/ui/components/dropdown-menu.tsx @@ -1,14 +1,14 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import type * as React from "react"; function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { - return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> + return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />; } function DropdownMenuPortal({ @@ -16,7 +16,7 @@ function DropdownMenuPortal({ }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { return ( <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> - ) + ); } function DropdownMenuTrigger({ @@ -27,7 +27,7 @@ function DropdownMenuTrigger({ data-slot="dropdown-menu-trigger" {...props} /> - ) + ); } function DropdownMenuContent({ @@ -47,7 +47,7 @@ function DropdownMenuContent({ {...props} /> </DropdownMenuPrimitive.Portal> - ) + ); } function DropdownMenuGroup({ @@ -55,7 +55,7 @@ function DropdownMenuGroup({ }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { return ( <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> - ) + ); } function DropdownMenuItem({ @@ -64,8 +64,8 @@ function DropdownMenuItem({ variant = "default", ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { - inset?: boolean - variant?: "default" | "destructive" + inset?: boolean; + variant?: "default" | "destructive"; }) { return ( <DropdownMenuPrimitive.Item @@ -78,7 +78,7 @@ function DropdownMenuItem({ data-variant={variant} {...props} /> - ) + ); } function DropdownMenuCheckboxItem({ @@ -104,7 +104,7 @@ function DropdownMenuCheckboxItem({ </span> {children} </DropdownMenuPrimitive.CheckboxItem> - ) + ); } function DropdownMenuRadioGroup({ @@ -115,7 +115,7 @@ function DropdownMenuRadioGroup({ data-slot="dropdown-menu-radio-group" {...props} /> - ) + ); } function DropdownMenuRadioItem({ @@ -139,7 +139,7 @@ function DropdownMenuRadioItem({ </span> {children} </DropdownMenuPrimitive.RadioItem> - ) + ); } function DropdownMenuLabel({ @@ -147,7 +147,7 @@ function DropdownMenuLabel({ inset, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { - inset?: boolean + inset?: boolean; }) { return ( <DropdownMenuPrimitive.Label @@ -159,7 +159,7 @@ function DropdownMenuLabel({ data-slot="dropdown-menu-label" {...props} /> - ) + ); } function DropdownMenuSeparator({ @@ -172,7 +172,7 @@ function DropdownMenuSeparator({ data-slot="dropdown-menu-separator" {...props} /> - ) + ); } function DropdownMenuShortcut({ @@ -188,13 +188,13 @@ function DropdownMenuShortcut({ data-slot="dropdown-menu-shortcut" {...props} /> - ) + ); } function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { - return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> + return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />; } function DropdownMenuSubTrigger({ @@ -203,7 +203,7 @@ function DropdownMenuSubTrigger({ children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { - inset?: boolean + inset?: boolean; }) { return ( <DropdownMenuPrimitive.SubTrigger @@ -218,7 +218,7 @@ function DropdownMenuSubTrigger({ {children} <ChevronRightIcon className="ml-auto size-4" /> </DropdownMenuPrimitive.SubTrigger> - ) + ); } function DropdownMenuSubContent({ @@ -234,7 +234,7 @@ function DropdownMenuSubContent({ data-slot="dropdown-menu-sub-content" {...props} /> - ) + ); } export { @@ -253,4 +253,4 @@ export { DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, -} +}; diff --git a/packages/ui/components/input.tsx b/packages/ui/components/input.tsx index cb670693..4fb1bd7e 100644 --- a/packages/ui/components/input.tsx +++ b/packages/ui/components/input.tsx @@ -1,5 +1,5 @@ -import { cn } from "@lib/utils" -import type * as React from "react" +import { cn } from "@lib/utils"; +import type * as React from "react"; function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( @@ -14,7 +14,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} {...props} /> - ) + ); } -export { Input } +export { Input }; diff --git a/packages/ui/components/label.tsx b/packages/ui/components/label.tsx index f1c2a2f4..97961968 100644 --- a/packages/ui/components/label.tsx +++ b/packages/ui/components/label.tsx @@ -1,8 +1,8 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as LabelPrimitive from "@radix-ui/react-label" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import type * as React from "react"; function Label({ className, @@ -17,7 +17,7 @@ function Label({ data-slot="label" {...props} /> - ) + ); } -export { Label } +export { Label }; diff --git a/packages/ui/components/popover.tsx b/packages/ui/components/popover.tsx index bbb8885a..b3dbe9bc 100644 --- a/packages/ui/components/popover.tsx +++ b/packages/ui/components/popover.tsx @@ -1,19 +1,19 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as PopoverPrimitive from "@radix-ui/react-popover" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import type * as React from "react"; function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) { - return <PopoverPrimitive.Root data-slot="popover" {...props} /> + return <PopoverPrimitive.Root data-slot="popover" {...props} />; } function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { - return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> + return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />; } function PopoverContent({ @@ -35,13 +35,13 @@ function PopoverContent({ {...props} /> </PopoverPrimitive.Portal> - ) + ); } function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { - return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> + return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />; } -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/packages/ui/components/progress.tsx b/packages/ui/components/progress.tsx index c42d1c0c..d3300cef 100644 --- a/packages/ui/components/progress.tsx +++ b/packages/ui/components/progress.tsx @@ -1,8 +1,8 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as ProgressPrimitive from "@radix-ui/react-progress" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import type * as React from "react"; function Progress({ className, @@ -24,7 +24,7 @@ function Progress({ style={{ transform: `translateX(-${100 - (value || 0)}%)` }} /> </ProgressPrimitive.Root> - ) + ); } -export { Progress } +export { Progress }; diff --git a/packages/ui/components/scroll-area.tsx b/packages/ui/components/scroll-area.tsx index 26ce58ca..3d4ccf97 100644 --- a/packages/ui/components/scroll-area.tsx +++ b/packages/ui/components/scroll-area.tsx @@ -1,8 +1,8 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; +import type * as React from "react"; function ScrollArea({ className, @@ -24,7 +24,7 @@ function ScrollArea({ <ScrollBar /> <ScrollAreaPrimitive.Corner /> </ScrollAreaPrimitive.Root> - ) + ); } function ScrollBar({ @@ -51,7 +51,7 @@ function ScrollBar({ data-slot="scroll-area-thumb" /> </ScrollAreaPrimitive.ScrollAreaScrollbar> - ) + ); } -export { ScrollArea, ScrollBar } +export { ScrollArea, ScrollBar }; diff --git a/packages/ui/components/select.tsx b/packages/ui/components/select.tsx index ff905ed0..7a20f4c4 100644 --- a/packages/ui/components/select.tsx +++ b/packages/ui/components/select.tsx @@ -1,26 +1,26 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as SelectPrimitive from "@radix-ui/react-select" -import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import type * as React from "react"; function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { - return <SelectPrimitive.Root data-slot="select" {...props} /> + return <SelectPrimitive.Root data-slot="select" {...props} />; } function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) { - return <SelectPrimitive.Group data-slot="select-group" {...props} /> + return <SelectPrimitive.Group data-slot="select-group" {...props} />; } function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) { - return <SelectPrimitive.Value data-slot="select-value" {...props} /> + return <SelectPrimitive.Value data-slot="select-value" {...props} />; } function SelectTrigger({ @@ -29,7 +29,7 @@ function SelectTrigger({ children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger> & { - size?: "sm" | "default" + size?: "sm" | "default"; }) { return ( <SelectPrimitive.Trigger @@ -46,7 +46,7 @@ function SelectTrigger({ <ChevronDownIcon className="size-4 opacity-50" /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> - ) + ); } function SelectContent({ @@ -81,7 +81,7 @@ function SelectContent({ <SelectScrollDownButton /> </SelectPrimitive.Content> </SelectPrimitive.Portal> - ) + ); } function SelectLabel({ @@ -94,7 +94,7 @@ function SelectLabel({ data-slot="select-label" {...props} /> - ) + ); } function SelectItem({ @@ -118,7 +118,7 @@ function SelectItem({ </span> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> </SelectPrimitive.Item> - ) + ); } function SelectSeparator({ @@ -131,7 +131,7 @@ function SelectSeparator({ data-slot="select-separator" {...props} /> - ) + ); } function SelectScrollUpButton({ @@ -149,7 +149,7 @@ function SelectScrollUpButton({ > <ChevronUpIcon className="size-4" /> </SelectPrimitive.ScrollUpButton> - ) + ); } function SelectScrollDownButton({ @@ -167,7 +167,7 @@ function SelectScrollDownButton({ > <ChevronDownIcon className="size-4" /> </SelectPrimitive.ScrollDownButton> - ) + ); } export { @@ -181,4 +181,4 @@ export { SelectSeparator, SelectTrigger, SelectValue, -} +}; diff --git a/packages/ui/components/separator.tsx b/packages/ui/components/separator.tsx index 670b6944..629bf4d5 100644 --- a/packages/ui/components/separator.tsx +++ b/packages/ui/components/separator.tsx @@ -1,8 +1,8 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as SeparatorPrimitive from "@radix-ui/react-separator" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import type * as React from "react"; function Separator({ className, @@ -21,7 +21,7 @@ function Separator({ orientation={orientation} {...props} /> - ) + ); } -export { Separator } +export { Separator }; diff --git a/packages/ui/components/shadcn-io/dropzone.tsx b/packages/ui/components/shadcn-io/dropzone.tsx index dc54c3d1..a93bd612 100644 --- a/packages/ui/components/shadcn-io/dropzone.tsx +++ b/packages/ui/components/shadcn-io/dropzone.tsx @@ -1,48 +1,48 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import { Button } from "@ui/components/button" -import { UploadIcon } from "lucide-react" -import type { ReactNode } from "react" -import { createContext, useContext } from "react" -import type { DropEvent, DropzoneOptions, FileRejection } from "react-dropzone" -import { useDropzone } from "react-dropzone" +import { cn } from "@lib/utils"; +import { Button } from "@ui/components/button"; +import { UploadIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { createContext, useContext } from "react"; +import type { DropEvent, DropzoneOptions, FileRejection } from "react-dropzone"; +import { useDropzone } from "react-dropzone"; type DropzoneContextType = { - src?: File[] - accept?: DropzoneOptions["accept"] - maxSize?: DropzoneOptions["maxSize"] - minSize?: DropzoneOptions["minSize"] - maxFiles?: DropzoneOptions["maxFiles"] -} + src?: File[]; + accept?: DropzoneOptions["accept"]; + maxSize?: DropzoneOptions["maxSize"]; + minSize?: DropzoneOptions["minSize"]; + maxFiles?: DropzoneOptions["maxFiles"]; +}; const renderBytes = (bytes: number) => { - const units = ["B", "KB", "MB", "GB", "TB", "PB"] - let size = bytes - let unitIndex = 0 + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + let size = bytes; + let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024 - unitIndex++ + size /= 1024; + unitIndex++; } - return `${size.toFixed(2)}${units[unitIndex]}` -} + return `${size.toFixed(2)}${units[unitIndex]}`; +}; const DropzoneContext = createContext<DropzoneContextType | undefined>( undefined, -) +); export type DropzoneProps = Omit<DropzoneOptions, "onDrop"> & { - src?: File[] - className?: string + src?: File[]; + className?: string; onDrop?: ( acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent, - ) => void - children?: ReactNode -} + ) => void; + children?: ReactNode; +}; export const Dropzone = ({ accept, @@ -66,15 +66,15 @@ export const Dropzone = ({ disabled, onDrop: (acceptedFiles, fileRejections, event) => { if (fileRejections.length > 0) { - const message = fileRejections.at(0)?.errors.at(0)?.message - onError?.(new Error(message)) - return + const message = fileRejections.at(0)?.errors.at(0)?.message; + onError?.(new Error(message)); + return; } - onDrop?.(acceptedFiles, fileRejections, event) + onDrop?.(acceptedFiles, fileRejections, event); }, ...props, - }) + }); return ( <DropzoneContext.Provider @@ -96,38 +96,38 @@ export const Dropzone = ({ {children} </Button> </DropzoneContext.Provider> - ) -} + ); +}; const useDropzoneContext = () => { - const context = useContext(DropzoneContext) + const context = useContext(DropzoneContext); if (!context) { - throw new Error("useDropzoneContext must be used within a Dropzone") + throw new Error("useDropzoneContext must be used within a Dropzone"); } - return context -} + return context; +}; export type DropzoneContentProps = { - children?: ReactNode - className?: string -} + children?: ReactNode; + className?: string; +}; -const maxLabelItems = 1 +const maxLabelItems = 1; export const DropzoneContent = ({ children, className, }: DropzoneContentProps) => { - const { src } = useDropzoneContext() + const { src } = useDropzoneContext(); if (!src) { - return null + return null; } if (children) { - return children + return children; } return ( @@ -146,41 +146,41 @@ export const DropzoneContent = ({ Drag and drop or click to replace </p> </div> - ) -} + ); +}; export type DropzoneEmptyStateProps = { - children?: ReactNode - className?: string -} + children?: ReactNode; + className?: string; +}; export const DropzoneEmptyState = ({ children, className, }: DropzoneEmptyStateProps) => { - const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext() + const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext(); if (src) { - return null + return null; } if (children) { - return children + return children; } - let caption = "" + let caption = ""; if (accept) { - caption += "Accepts " - caption += new Intl.ListFormat("en").format(Object.keys(accept)) + caption += "Accepts "; + caption += new Intl.ListFormat("en").format(Object.keys(accept)); } if (minSize && maxSize) { - caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}` + caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`; } else if (minSize) { - caption += ` at least ${renderBytes(minSize)}` + caption += ` at least ${renderBytes(minSize)}`; } else if (maxSize) { - caption += ` less than ${renderBytes(maxSize)}` + caption += ` less than ${renderBytes(maxSize)}`; } return ( @@ -198,5 +198,5 @@ export const DropzoneEmptyState = ({ <p className="text-wrap text-muted-foreground text-xs">{caption}.</p> )} </div> - ) -} + ); +}; diff --git a/packages/ui/components/sheet.tsx b/packages/ui/components/sheet.tsx index e5f49634..242a4688 100644 --- a/packages/ui/components/sheet.tsx +++ b/packages/ui/components/sheet.tsx @@ -1,30 +1,30 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as SheetPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import type * as React from "react"; function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { - return <SheetPrimitive.Root data-slot="sheet" {...props} /> + return <SheetPrimitive.Root data-slot="sheet" {...props} />; } function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) { - return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> + return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />; } function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) { - return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> + return <SheetPrimitive.Close data-slot="sheet-close" {...props} />; } function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) { - return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> + return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />; } function SheetOverlay({ @@ -40,7 +40,7 @@ function SheetOverlay({ data-slot="sheet-overlay" {...props} /> - ) + ); } function SheetContent({ @@ -49,7 +49,7 @@ function SheetContent({ side = "right", ...props }: React.ComponentProps<typeof SheetPrimitive.Content> & { - side?: "top" | "right" | "bottom" | "left" + side?: "top" | "right" | "bottom" | "left"; }) { return ( <SheetPortal> @@ -77,7 +77,7 @@ function SheetContent({ </SheetPrimitive.Close> </SheetPrimitive.Content> </SheetPortal> - ) + ); } function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -87,7 +87,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { data-slot="sheet-header" {...props} /> - ) + ); } function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { @@ -97,7 +97,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { data-slot="sheet-footer" {...props} /> - ) + ); } function SheetTitle({ @@ -110,7 +110,7 @@ function SheetTitle({ data-slot="sheet-title" {...props} /> - ) + ); } function SheetDescription({ @@ -123,7 +123,7 @@ function SheetDescription({ data-slot="sheet-description" {...props} /> - ) + ); } export { @@ -135,4 +135,4 @@ export { SheetFooter, SheetTitle, SheetDescription, -} +}; diff --git a/packages/ui/components/sidebar.tsx b/packages/ui/components/sidebar.tsx index 626e23d0..a2406d18 100644 --- a/packages/ui/components/sidebar.tsx +++ b/packages/ui/components/sidebar.tsx @@ -1,55 +1,55 @@ -"use client" - -import { useIsMobile } from "@hooks/use-mobile" -import { cn } from "@lib/utils" -import { Slot } from "@radix-ui/react-slot" -import { Button } from "@ui/components/button" -import { Input } from "@ui/components/input" -import { Separator } from "@ui/components/separator" +"use client"; + +import { useIsMobile } from "@hooks/use-mobile"; +import { cn } from "@lib/utils"; +import { Slot } from "@radix-ui/react-slot"; +import { Button } from "@ui/components/button"; +import { Input } from "@ui/components/input"; +import { Separator } from "@ui/components/separator"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, -} from "@ui/components/sheet" -import { Skeleton } from "@ui/components/skeleton" +} from "@ui/components/sheet"; +import { Skeleton } from "@ui/components/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, -} from "@ui/components/tooltip" -import { cva, type VariantProps } from "class-variance-authority" -import { PanelLeftIcon } from "lucide-react" -import * as React from "react" - -const SIDEBAR_COOKIE_NAME = "sidebar_state" -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 -const SIDEBAR_WIDTH = "16rem" -const SIDEBAR_WIDTH_MOBILE = "18rem" -const SIDEBAR_WIDTH_ICON = "3rem" -const SIDEBAR_KEYBOARD_SHORTCUT = "b" +} from "@ui/components/tooltip"; +import { cva, type VariantProps } from "class-variance-authority"; +import { PanelLeftIcon } from "lucide-react"; +import * as React from "react"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; type SidebarContextProps = { - state: "expanded" | "collapsed" - open: boolean - setOpen: (open: boolean) => void - openMobile: boolean - setOpenMobile: (open: boolean) => void - isMobile: boolean - toggleSidebar: () => void -} + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; -const SidebarContext = React.createContext<SidebarContextProps | null>(null) +const SidebarContext = React.createContext<SidebarContextProps | null>(null); function useSidebar() { - const context = React.useContext(SidebarContext) + const context = React.useContext(SidebarContext); if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider.") + throw new Error("useSidebar must be used within a SidebarProvider."); } - return context + return context; } function SidebarProvider({ @@ -61,36 +61,36 @@ function SidebarProvider({ children, ...props }: React.ComponentProps<"div"> & { - defaultOpen?: boolean - open?: boolean - onOpenChange?: (open: boolean) => void + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const isMobile = useIsMobile() - const [openMobile, setOpenMobile] = React.useState(false) + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen) - const open = openProp ?? _open + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value + const openState = typeof value === "function" ? value(open) : value; if (setOpenProp) { - setOpenProp(openState) + setOpenProp(openState); } else { - _setOpen(openState) + _setOpen(openState); } // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; }, [setOpenProp, open], - ) + ); // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { - return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) - }, [isMobile, setOpen]) + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen]); // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { @@ -99,18 +99,18 @@ function SidebarProvider({ event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) ) { - event.preventDefault() - toggleSidebar() + event.preventDefault(); + toggleSidebar(); } - } + }; - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [toggleSidebar]) + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed" + const state = open ? "expanded" : "collapsed"; const contextValue = React.useMemo<SidebarContextProps>( () => ({ @@ -123,7 +123,7 @@ function SidebarProvider({ toggleSidebar, }), [state, open, setOpen, isMobile, openMobile, toggleSidebar], - ) + ); return ( <SidebarContext.Provider value={contextValue}> @@ -147,7 +147,7 @@ function SidebarProvider({ </div> </TooltipProvider> </SidebarContext.Provider> - ) + ); } function Sidebar({ @@ -158,11 +158,11 @@ function Sidebar({ children, ...props }: React.ComponentProps<"div"> & { - side?: "left" | "right" - variant?: "sidebar" | "floating" | "inset" - collapsible?: "offcanvas" | "icon" | "none" + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; }) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); if (collapsible === "none") { return ( @@ -176,7 +176,7 @@ function Sidebar({ > {children} </div> - ) + ); } if (isMobile) { @@ -201,7 +201,7 @@ function Sidebar({ <div className="flex h-full w-full flex-col">{children}</div> </SheetContent> </Sheet> - ) + ); } return ( @@ -249,7 +249,7 @@ function Sidebar({ </div> </div> </div> - ) + ); } function SidebarTrigger({ @@ -257,7 +257,7 @@ function SidebarTrigger({ onClick, ...props }: React.ComponentProps<typeof Button>) { - const { toggleSidebar } = useSidebar() + const { toggleSidebar } = useSidebar(); return ( <Button @@ -265,8 +265,8 @@ function SidebarTrigger({ data-sidebar="trigger" data-slot="sidebar-trigger" onClick={(event) => { - onClick?.(event) - toggleSidebar() + onClick?.(event); + toggleSidebar(); }} size="icon" variant="ghost" @@ -275,11 +275,11 @@ function SidebarTrigger({ <PanelLeftIcon /> <span className="sr-only">Toggle Sidebar</span> </Button> - ) + ); } function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { - const { toggleSidebar } = useSidebar() + const { toggleSidebar } = useSidebar(); return ( <button @@ -300,7 +300,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { title="Toggle Sidebar" {...props} /> - ) + ); } function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { @@ -314,7 +314,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { data-slot="sidebar-inset" {...props} /> - ) + ); } function SidebarInput({ @@ -328,7 +328,7 @@ function SidebarInput({ data-slot="sidebar-input" {...props} /> - ) + ); } function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -339,7 +339,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { data-slot="sidebar-header" {...props} /> - ) + ); } function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { @@ -350,7 +350,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { data-slot="sidebar-footer" {...props} /> - ) + ); } function SidebarSeparator({ @@ -364,7 +364,7 @@ function SidebarSeparator({ data-slot="sidebar-separator" {...props} /> - ) + ); } function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { @@ -378,7 +378,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { data-slot="sidebar-content" {...props} /> - ) + ); } function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { @@ -389,7 +389,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { data-slot="sidebar-group" {...props} /> - ) + ); } function SidebarGroupLabel({ @@ -397,7 +397,7 @@ function SidebarGroupLabel({ asChild = false, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "div" + const Comp = asChild ? Slot : "div"; return ( <Comp @@ -410,7 +410,7 @@ function SidebarGroupLabel({ data-slot="sidebar-group-label" {...props} /> - ) + ); } function SidebarGroupAction({ @@ -418,7 +418,7 @@ function SidebarGroupAction({ asChild = false, ...props }: React.ComponentProps<"button"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : "button"; return ( <Comp @@ -433,7 +433,7 @@ function SidebarGroupAction({ data-slot="sidebar-group-action" {...props} /> - ) + ); } function SidebarGroupContent({ @@ -447,7 +447,7 @@ function SidebarGroupContent({ data-slot="sidebar-group-content" {...props} /> - ) + ); } function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { @@ -458,7 +458,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { data-slot="sidebar-menu" {...props} /> - ) + ); } function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { @@ -469,7 +469,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { data-slot="sidebar-menu-item" {...props} /> - ) + ); } const sidebarMenuButtonVariants = cva( @@ -492,7 +492,7 @@ const sidebarMenuButtonVariants = cva( size: "default", }, }, -) +); function SidebarMenuButton({ asChild = false, @@ -503,12 +503,12 @@ function SidebarMenuButton({ className, ...props }: React.ComponentProps<"button"> & { - asChild?: boolean - isActive?: boolean - tooltip?: string | React.ComponentProps<typeof TooltipContent> + asChild?: boolean; + isActive?: boolean; + tooltip?: string | React.ComponentProps<typeof TooltipContent>; } & VariantProps<typeof sidebarMenuButtonVariants>) { - const Comp = asChild ? Slot : "button" - const { isMobile, state } = useSidebar() + const Comp = asChild ? Slot : "button"; + const { isMobile, state } = useSidebar(); const button = ( <Comp @@ -519,16 +519,16 @@ function SidebarMenuButton({ data-slot="sidebar-menu-button" {...props} /> - ) + ); if (!tooltip) { - return button + return button; } if (typeof tooltip === "string") { tooltip = { children: tooltip, - } + }; } return ( @@ -541,7 +541,7 @@ function SidebarMenuButton({ {...tooltip} /> </Tooltip> - ) + ); } function SidebarMenuAction({ @@ -550,10 +550,10 @@ function SidebarMenuAction({ showOnHover = false, ...props }: React.ComponentProps<"button"> & { - asChild?: boolean - showOnHover?: boolean + asChild?: boolean; + showOnHover?: boolean; }) { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : "button"; return ( <Comp @@ -573,7 +573,7 @@ function SidebarMenuAction({ data-slot="sidebar-menu-action" {...props} /> - ) + ); } function SidebarMenuBadge({ @@ -595,7 +595,7 @@ function SidebarMenuBadge({ data-slot="sidebar-menu-badge" {...props} /> - ) + ); } function SidebarMenuSkeleton({ @@ -603,12 +603,12 @@ function SidebarMenuSkeleton({ showIcon = false, ...props }: React.ComponentProps<"div"> & { - showIcon?: boolean + showIcon?: boolean; }) { // Random width between 50 to 90%. const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%` - }, []) + return `${Math.floor(Math.random() * 40) + 50}%`; + }, []); return ( <div @@ -633,7 +633,7 @@ function SidebarMenuSkeleton({ } /> </div> - ) + ); } function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { @@ -648,7 +648,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { data-slot="sidebar-menu-sub" {...props} /> - ) + ); } function SidebarMenuSubItem({ @@ -662,7 +662,7 @@ function SidebarMenuSubItem({ data-slot="sidebar-menu-sub-item" {...props} /> - ) + ); } function SidebarMenuSubButton({ @@ -672,11 +672,11 @@ function SidebarMenuSubButton({ className, ...props }: React.ComponentProps<"a"> & { - asChild?: boolean - size?: "sm" | "md" - isActive?: boolean + asChild?: boolean; + size?: "sm" | "md"; + isActive?: boolean; }) { - const Comp = asChild ? Slot : "a" + const Comp = asChild ? Slot : "a"; return ( <Comp @@ -694,7 +694,7 @@ function SidebarMenuSubButton({ data-slot="sidebar-menu-sub-button" {...props} /> - ) + ); } export { @@ -722,4 +722,4 @@ export { SidebarSeparator, SidebarTrigger, useSidebar, -} +}; diff --git a/packages/ui/components/skeleton.tsx b/packages/ui/components/skeleton.tsx index 980e9e8a..19737ccc 100644 --- a/packages/ui/components/skeleton.tsx +++ b/packages/ui/components/skeleton.tsx @@ -1,4 +1,4 @@ -import { cn } from "@lib/utils" +import { cn } from "@lib/utils"; function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) { data-slot="skeleton" {...props} /> - ) + ); } -export { Skeleton } +export { Skeleton }; diff --git a/packages/ui/components/sonner.tsx b/packages/ui/components/sonner.tsx index 06307a08..f9bc649e 100644 --- a/packages/ui/components/sonner.tsx +++ b/packages/ui/components/sonner.tsx @@ -1,10 +1,10 @@ -"use client" +"use client"; -import { useTheme } from "next-themes" -import { Toaster as Sonner, type ToasterProps } from "sonner" +import { useTheme } from "next-themes"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() + const { theme = "system" } = useTheme(); return ( <Sonner @@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => { theme={theme as ToasterProps["theme"]} {...props} /> - ) -} + ); +}; -export { Toaster } +export { Toaster }; diff --git a/packages/ui/components/table.tsx b/packages/ui/components/table.tsx index 91689860..466a599d 100644 --- a/packages/ui/components/table.tsx +++ b/packages/ui/components/table.tsx @@ -1,7 +1,7 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import type * as React from "react" +import { cn } from "@lib/utils"; +import type * as React from "react"; function Table({ className, ...props }: React.ComponentProps<"table">) { return ( @@ -15,7 +15,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) { {...props} /> </div> - ) + ); } function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { @@ -25,7 +25,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { data-slot="table-header" {...props} /> - ) + ); } function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { @@ -35,7 +35,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { data-slot="table-body" {...props} /> - ) + ); } function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { @@ -48,7 +48,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { data-slot="table-footer" {...props} /> - ) + ); } function TableRow({ className, ...props }: React.ComponentProps<"tr">) { @@ -61,7 +61,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { data-slot="table-row" {...props} /> - ) + ); } function TableHead({ className, ...props }: React.ComponentProps<"th">) { @@ -74,7 +74,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) { data-slot="table-head" {...props} /> - ) + ); } function TableCell({ className, ...props }: React.ComponentProps<"td">) { @@ -87,7 +87,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) { data-slot="table-cell" {...props} /> - ) + ); } function TableCaption({ @@ -100,7 +100,7 @@ function TableCaption({ data-slot="table-caption" {...props} /> - ) + ); } export { @@ -112,4 +112,4 @@ export { TableRow, TableCell, TableCaption, -} +}; diff --git a/packages/ui/components/tabs.tsx b/packages/ui/components/tabs.tsx index 77d27cdb..4f316dd1 100644 --- a/packages/ui/components/tabs.tsx +++ b/packages/ui/components/tabs.tsx @@ -1,8 +1,8 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as TabsPrimitive from "@radix-ui/react-tabs" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import type * as React from "react"; function Tabs({ className, @@ -14,7 +14,7 @@ function Tabs({ data-slot="tabs" {...props} /> - ) + ); } function TabsList({ @@ -30,7 +30,7 @@ function TabsList({ data-slot="tabs-list" {...props} /> - ) + ); } function TabsTrigger({ @@ -46,7 +46,7 @@ function TabsTrigger({ data-slot="tabs-trigger" {...props} /> - ) + ); } function TabsContent({ @@ -59,7 +59,7 @@ function TabsContent({ data-slot="tabs-content" {...props} /> - ) + ); } -export { Tabs, TabsList, TabsTrigger, TabsContent } +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/packages/ui/components/text-separator.tsx b/packages/ui/components/text-separator.tsx index 7263d811..87ffb772 100644 --- a/packages/ui/components/text-separator.tsx +++ b/packages/ui/components/text-separator.tsx @@ -1,7 +1,7 @@ -import { cn } from "@lib/utils" +import { cn } from "@lib/utils"; interface TextSeparatorProps extends React.ComponentProps<"div"> { - text: string + text: string; } export function TextSeparator({ @@ -20,5 +20,5 @@ export function TextSeparator({ </span> <div className="w-full h-px bg-sm-gray" /> </div> - ) + ); } diff --git a/packages/ui/components/textarea.tsx b/packages/ui/components/textarea.tsx index 6ca3b1f8..9e0e3146 100644 --- a/packages/ui/components/textarea.tsx +++ b/packages/ui/components/textarea.tsx @@ -1,5 +1,5 @@ -import { cn } from "@lib/utils" -import type * as React from "react" +import { cn } from "@lib/utils"; +import type * as React from "react"; function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { return ( @@ -11,7 +11,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { data-slot="textarea" {...props} /> - ) + ); } -export { Textarea } +export { Textarea }; diff --git a/packages/ui/components/toggle-group.tsx b/packages/ui/components/toggle-group.tsx index b8621419..5fa25b8a 100644 --- a/packages/ui/components/toggle-group.tsx +++ b/packages/ui/components/toggle-group.tsx @@ -1,17 +1,17 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" -import { toggleVariants } from "@ui/components/toggle" -import type { VariantProps } from "class-variance-authority" -import * as React from "react" +import { cn } from "@lib/utils"; +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; +import { toggleVariants } from "@ui/components/toggle"; +import type { VariantProps } from "class-variance-authority"; +import * as React from "react"; const ToggleGroupContext = React.createContext< VariantProps<typeof toggleVariants> >({ size: "default", variant: "default", -}) +}); function ToggleGroup({ className, @@ -36,7 +36,7 @@ function ToggleGroup({ {children} </ToggleGroupContext.Provider> </ToggleGroupPrimitive.Root> - ) + ); } function ToggleGroupItem({ @@ -47,7 +47,7 @@ function ToggleGroupItem({ ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) { - const context = React.useContext(ToggleGroupContext) + const context = React.useContext(ToggleGroupContext); return ( <ToggleGroupPrimitive.Item @@ -66,7 +66,7 @@ function ToggleGroupItem({ > {children} </ToggleGroupPrimitive.Item> - ) + ); } -export { ToggleGroup, ToggleGroupItem } +export { ToggleGroup, ToggleGroupItem }; diff --git a/packages/ui/components/toggle.tsx b/packages/ui/components/toggle.tsx index ca855d28..1a240825 100644 --- a/packages/ui/components/toggle.tsx +++ b/packages/ui/components/toggle.tsx @@ -1,9 +1,9 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as TogglePrimitive from "@radix-ui/react-toggle" -import { cva, type VariantProps } from "class-variance-authority" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as TogglePrimitive from "@radix-ui/react-toggle"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; const toggleVariants = cva( "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", @@ -25,7 +25,7 @@ const toggleVariants = cva( size: "default", }, }, -) +); function Toggle({ className, @@ -40,7 +40,7 @@ function Toggle({ data-slot="toggle" {...props} /> - ) + ); } -export { Toggle, toggleVariants } +export { Toggle, toggleVariants }; diff --git a/packages/ui/components/tooltip.tsx b/packages/ui/components/tooltip.tsx index b06e9c8d..32a78f06 100644 --- a/packages/ui/components/tooltip.tsx +++ b/packages/ui/components/tooltip.tsx @@ -1,8 +1,8 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import * as TooltipPrimitive from "@radix-ui/react-tooltip" -import type * as React from "react" +import { cn } from "@lib/utils"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import type * as React from "react"; function TooltipProvider({ delayDuration = 0, @@ -14,7 +14,7 @@ function TooltipProvider({ delayDuration={delayDuration} {...props} /> - ) + ); } function Tooltip({ @@ -24,13 +24,13 @@ function Tooltip({ <TooltipProvider> <TooltipPrimitive.Root data-slot="tooltip" {...props} /> </TooltipProvider> - ) + ); } function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { - return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> + return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />; } function TooltipContent({ @@ -54,7 +54,7 @@ function TooltipContent({ <TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-sm" /> </TooltipPrimitive.Content> </TooltipPrimitive.Portal> - ) + ); } -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/packages/ui/copy-button.tsx b/packages/ui/copy-button.tsx index 92ea4914..3e5fa75d 100644 --- a/packages/ui/copy-button.tsx +++ b/packages/ui/copy-button.tsx @@ -1,17 +1,17 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import { Button, type buttonVariants } from "@ui/components/button" -import type { VariantProps } from "class-variance-authority" -import { CheckIcon, ClipboardIcon } from "lucide-react" -import * as React from "react" -import { useEffect } from "react" +import { cn } from "@lib/utils"; +import { Button, type buttonVariants } from "@ui/components/button"; +import type { VariantProps } from "class-variance-authority"; +import { CheckIcon, ClipboardIcon } from "lucide-react"; +import * as React from "react"; +import { useEffect } from "react"; interface CopyButtonProps extends React.ComponentProps<"button">, VariantProps<typeof buttonVariants> { - value: string - src?: string + value: string; + src?: string; } export function CopyButton({ @@ -21,13 +21,13 @@ export function CopyButton({ variant = "ghost", ...props }: CopyButtonProps) { - const [hasCopied, setHasCopied] = React.useState(false) + const [hasCopied, setHasCopied] = React.useState(false); useEffect(() => { setTimeout(() => { - setHasCopied(false) - }, 2000) - }, []) + setHasCopied(false); + }, 2000); + }, []); return ( <Button @@ -36,8 +36,8 @@ export function CopyButton({ className, )} onClick={() => { - navigator.clipboard.writeText(value) - setHasCopied(true) + navigator.clipboard.writeText(value); + setHasCopied(true); }} size="icon" variant={variant} @@ -46,5 +46,5 @@ export function CopyButton({ <span className="sr-only">Copy</span> {hasCopied ? <CheckIcon /> : <ClipboardIcon />} </Button> - ) + ); } diff --git a/packages/ui/copyable-cell.tsx b/packages/ui/copyable-cell.tsx index d23c9c6f..6b2dbc89 100644 --- a/packages/ui/copyable-cell.tsx +++ b/packages/ui/copyable-cell.tsx @@ -1,13 +1,13 @@ -"use client" +"use client"; -import { cn } from "@lib/utils" -import { Label1Regular } from "@ui/text/label/label-1-regular" -import { AnimatePresence, motion } from "motion/react" -import * as React from "react" +import { cn } from "@lib/utils"; +import { Label1Regular } from "@ui/text/label/label-1-regular"; +import { AnimatePresence, motion } from "motion/react"; +import * as React from "react"; interface CopyableCellProps extends React.HTMLAttributes<HTMLDivElement> { - value: string - displayValue?: React.ReactNode + value: string; + displayValue?: React.ReactNode; } export function CopyableCell({ @@ -17,26 +17,26 @@ export function CopyableCell({ children, ...props }: CopyableCellProps) { - const [hasCopied, setHasCopied] = React.useState(false) + const [hasCopied, setHasCopied] = React.useState(false); React.useEffect(() => { if (hasCopied) { const timeout = setTimeout(() => { - setHasCopied(false) - }, 2000) - return () => clearTimeout(timeout) + setHasCopied(false); + }, 2000); + return () => clearTimeout(timeout); } - }, [hasCopied]) + }, [hasCopied]); const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation() + e.stopPropagation(); try { - await navigator.clipboard.writeText(value) - setHasCopied(true) + await navigator.clipboard.writeText(value); + setHasCopied(true); } catch (err) { - console.error("Failed to copy:", err) + console.error("Failed to copy:", err); } - } + }; return ( // biome-ignore lint/a11y/noStaticElementInteractions: shadcn @@ -80,5 +80,5 @@ export function CopyableCell({ )} </AnimatePresence> </div> - ) + ); } diff --git a/packages/ui/input/labeled-input.tsx b/packages/ui/input/labeled-input.tsx index 6f631392..8b465f4b 100644 --- a/packages/ui/input/labeled-input.tsx +++ b/packages/ui/input/labeled-input.tsx @@ -1,13 +1,13 @@ -import { cn } from "@lib/utils" -import { Input } from "@ui/components/input" -import { Label1Regular } from "@ui/text/label/label-1-regular" +import { cn } from "@lib/utils"; +import { Input } from "@ui/components/input"; +import { Label1Regular } from "@ui/text/label/label-1-regular"; interface LabeledInputProps extends React.ComponentProps<"div"> { - label: string - inputType: string - inputPlaceholder: string - error?: string | null - inputProps?: React.ComponentProps<typeof Input> + label: string; + inputType: string; + inputPlaceholder: string; + error?: string | null; + inputProps?: React.ComponentProps<typeof Input>; } export function LabeledInput({ @@ -38,5 +38,5 @@ export function LabeledInput({ </p> )} </div> - ) + ); } diff --git a/packages/ui/memory-graph/constants.ts b/packages/ui/memory-graph/constants.ts index fddfdee5..23193601 100644 --- a/packages/ui/memory-graph/constants.ts +++ b/packages/ui/memory-graph/constants.ts @@ -47,7 +47,7 @@ export const colors = { extends: "rgba(16, 185, 129, 0.5)", // green derives: "rgba(147, 197, 253, 0.5)", // blue }, -} +}; export const LAYOUT_CONSTANTS = { centerX: 400, @@ -57,7 +57,7 @@ export const LAYOUT_CONSTANTS = { documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius memoryClusterRadius: 300, -} +}; // Graph view settings export const GRAPH_SETTINGS = { @@ -71,7 +71,7 @@ export const GRAPH_SETTINGS = { initialPanX: 400, // Pan towards center to compensate for larger layout initialPanY: 300, // Pan towards center to compensate for larger layout }, -} +}; // Responsive positioning for different app variants export const POSITIONING = { @@ -97,4 +97,4 @@ export const POSITIONING = { viewToggle: "top-4 right-4", // Consumer has view toggle nodeDetail: "top-4 right-4", }, -} +}; diff --git a/packages/ui/memory-graph/controls.tsx b/packages/ui/memory-graph/controls.tsx index e3391210..899d239a 100644 --- a/packages/ui/memory-graph/controls.tsx +++ b/packages/ui/memory-graph/controls.tsx @@ -1,11 +1,11 @@ -"use client" +"use client"; -import { cn } from "@repo/lib/utils" -import { Button } from "@repo/ui/components/button" -import { GlassMenuEffect } from "@repo/ui/other/glass-effect" -import { Move, ZoomIn, ZoomOut } from "lucide-react" -import { memo } from "react" -import type { ControlsProps } from "./types" +import { cn } from "@repo/lib/utils"; +import { Button } from "@repo/ui/components/button"; +import { GlassMenuEffect } from "@repo/ui/other/glass-effect"; +import { Move, ZoomIn, ZoomOut } from "lucide-react"; +import { memo } from "react"; +import type { ControlsProps } from "./types"; export const Controls = memo<ControlsProps>( ({ onZoomIn, onZoomOut, onResetView, variant = "console" }) => { @@ -13,13 +13,13 @@ export const Controls = memo<ControlsProps>( // Using a reasonable default position const getPositioningClasses = () => { if (variant === "console") { - return "bottom-4 left-4" + return "bottom-4 left-4"; } if (variant === "consumer") { - return "bottom-20 right-4" + return "bottom-20 right-4"; } - return "" - } + return ""; + }; return ( <div @@ -60,8 +60,8 @@ export const Controls = memo<ControlsProps>( </div> </div> </div> - ) + ); }, -) +); -Controls.displayName = "Controls" +Controls.displayName = "Controls"; diff --git a/packages/ui/memory-graph/graph-canvas.tsx b/packages/ui/memory-graph/graph-canvas.tsx index 39d67b3f..b29288ad 100644 --- a/packages/ui/memory-graph/graph-canvas.tsx +++ b/packages/ui/memory-graph/graph-canvas.tsx @@ -1,4 +1,4 @@ -"use client" +"use client"; import { memo, @@ -7,14 +7,14 @@ import { useLayoutEffect, useMemo, useRef, -} from "react" -import { colors } from "./constants" +} from "react"; +import { colors } from "./constants"; import type { DocumentWithMemories, GraphCanvasProps, GraphNode, MemoryEntry, -} from "./types" +} from "./types"; export const GraphCanvas = memo<GraphCanvasProps>( ({ @@ -38,160 +38,160 @@ export const GraphCanvas = memo<GraphCanvasProps>( draggingNodeId, highlightDocumentIds, }) => { - const canvasRef = useRef<HTMLCanvasElement>(null) - const animationRef = useRef<number>(0) - const startTimeRef = useRef<number>(Date.now()) - const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) - const currentHoveredNode = useRef<string | null>(null) + const canvasRef = useRef<HTMLCanvasElement>(null); + const animationRef = useRef<number>(0); + const startTimeRef = useRef<number>(Date.now()); + const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const currentHoveredNode = useRef<string | null>(null); // Initialize start time once useEffect(() => { - startTimeRef.current = Date.now() - }, []) + startTimeRef.current = Date.now(); + }, []); // Efficient hit detection const getNodeAtPosition = useCallback( (x: number, y: number): string | null => { // Check from top-most to bottom-most: memory nodes are drawn after documents for (let i = nodes.length - 1; i >= 0; i--) { - const node = nodes[i]! - const screenX = node.x * zoom + panX - const screenY = node.y * zoom + panY - const nodeSize = node.size * zoom + const node = nodes[i]!; + const screenX = node.x * zoom + panX; + const screenY = node.y * zoom + panY; + const nodeSize = node.size * zoom; - const dx = x - screenX - const dy = y - screenY - const distance = Math.sqrt(dx * dx + dy * dy) + const dx = x - screenX; + const dy = y - screenY; + const distance = Math.sqrt(dx * dx + dy * dy); if (distance <= nodeSize / 2) { - return node.id + return node.id; } } - return null + return null; }, [nodes, panX, panY, zoom], - ) + ); // Handle mouse events const handleMouseMove = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return + const canvas = canvasRef.current; + if (!canvas) return; - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; - mousePos.current = { x, y } + mousePos.current = { x, y }; - const nodeId = getNodeAtPosition(x, y) + const nodeId = getNodeAtPosition(x, y); if (nodeId !== currentHoveredNode.current) { - currentHoveredNode.current = nodeId - onNodeHover(nodeId) + currentHoveredNode.current = nodeId; + onNodeHover(nodeId); } // Handle node dragging if (draggingNodeId) { - onNodeDragMove(e) + onNodeDragMove(e); } }, [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove], - ) + ); const handleMouseDown = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return + const canvas = canvasRef.current; + if (!canvas) return; - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; - const nodeId = getNodeAtPosition(x, y) + const nodeId = getNodeAtPosition(x, y); if (nodeId) { // When starting a node drag, prevent initiating pan - e.stopPropagation() - onNodeDragStart(nodeId, e) - return + e.stopPropagation(); + onNodeDragStart(nodeId, e); + return; } - onPanStart(e) + onPanStart(e); }, [getNodeAtPosition, onNodeDragStart, onPanStart], - ) + ); const handleClick = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return + const canvas = canvasRef.current; + if (!canvas) return; - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; - const nodeId = getNodeAtPosition(x, y) + const nodeId = getNodeAtPosition(x, y); if (nodeId) { - onNodeClick(nodeId) + onNodeClick(nodeId); } }, [getNodeAtPosition, onNodeClick], - ) + ); // Professional rendering function with LOD const render = useCallback(() => { - const canvas = canvasRef.current - if (!canvas) return + const canvas = canvasRef.current; + if (!canvas) return; - const ctx = canvas.getContext("2d") - if (!ctx) return + const ctx = canvas.getContext("2d"); + if (!ctx) return; - const currentTime = Date.now() - const _elapsed = currentTime - startTimeRef.current + const currentTime = Date.now(); + const _elapsed = currentTime - startTimeRef.current; // Level-of-detail optimization based on zoom - const useSimplifiedRendering = zoom < 0.3 + const useSimplifiedRendering = zoom < 0.3; // Clear canvas - ctx.clearRect(0, 0, width, height) + ctx.clearRect(0, 0, width, height); // Set high quality rendering - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = "high" + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; // Draw minimal background grid - ctx.strokeStyle = "rgba(148, 163, 184, 0.03)" // Very subtle grid - ctx.lineWidth = 1 - const gridSpacing = 100 * zoom - const offsetX = panX % gridSpacing - const offsetY = panY % gridSpacing + ctx.strokeStyle = "rgba(148, 163, 184, 0.03)"; // Very subtle grid + ctx.lineWidth = 1; + const gridSpacing = 100 * zoom; + const offsetX = panX % gridSpacing; + const offsetY = panY % gridSpacing; // Simple, clean grid lines for (let x = offsetX; x < width; x += gridSpacing) { - ctx.beginPath() - ctx.moveTo(x, 0) - ctx.lineTo(x, height) - ctx.stroke() + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); } for (let y = offsetY; y < height; y += gridSpacing) { - ctx.beginPath() - ctx.moveTo(0, y) - ctx.lineTo(width, y) - ctx.stroke() + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); } // Create node lookup map - const nodeMap = new Map(nodes.map((node) => [node.id, node])) + const nodeMap = new Map(nodes.map((node) => [node.id, node])); // Draw enhanced edges with sophisticated styling - ctx.lineCap = "round" + ctx.lineCap = "round"; edges.forEach((edge) => { - const sourceNode = nodeMap.get(edge.source) - const targetNode = nodeMap.get(edge.target) + const sourceNode = nodeMap.get(edge.source); + const targetNode = nodeMap.get(edge.target); if (sourceNode && targetNode) { - const sourceX = sourceNode.x * zoom + panX - const sourceY = sourceNode.y * zoom + panY - const targetX = targetNode.x * zoom + panX - const targetY = targetNode.y * zoom + panY + const sourceX = sourceNode.x * zoom + panX; + const sourceY = sourceNode.y * zoom + panY; + const targetX = targetNode.x * zoom + panX; + const targetY = targetNode.y * zoom + panY; // Enhanced viewport culling with edge type considerations if ( @@ -200,7 +200,7 @@ export const GraphCanvas = memo<GraphCanvasProps>( targetX < -100 || targetX > width + 100 ) { - return + return; } // Skip very weak connections when zoomed out for performance @@ -209,27 +209,27 @@ export const GraphCanvas = memo<GraphCanvasProps>( edge.edgeType === "doc-memory" && edge.visualProps.opacity < 0.3 ) { - return // Skip very weak doc-memory edges when zoomed out + return; // Skip very weak doc-memory edges when zoomed out } } // Enhanced connection styling based on edge type - let connectionColor = colors.connection.weak - let dashPattern: number[] = [] - let opacity = edge.visualProps.opacity - let lineWidth = Math.max(1, edge.visualProps.thickness * zoom) + let connectionColor = colors.connection.weak; + let dashPattern: number[] = []; + let opacity = edge.visualProps.opacity; + let lineWidth = Math.max(1, edge.visualProps.thickness * zoom); if (edge.edgeType === "doc-memory") { // Doc-memory: Solid thin lines, subtle - dashPattern = [] - connectionColor = colors.connection.memory - opacity = 0.9 - lineWidth = 1 + dashPattern = []; + connectionColor = colors.connection.memory; + opacity = 0.9; + lineWidth = 1; } else if (edge.edgeType === "doc-doc") { // Doc-doc: Thick dashed lines with strong similarity emphasis - dashPattern = useSimplifiedRendering ? [] : [10, 5] // Solid lines when zoomed out - opacity = Math.max(0, edge.similarity * 0.5) - lineWidth = Math.max(1, edge.similarity * 2) // Thicker for stronger similarity + dashPattern = useSimplifiedRendering ? [] : [10, 5]; // Solid lines when zoomed out + opacity = Math.max(0, edge.similarity * 0.5); + lineWidth = Math.max(1, edge.similarity * 2); // Thicker for stronger similarity if (edge.similarity > 0.85) connectionColor = colors.connection.strong; @@ -243,57 +243,57 @@ export const GraphCanvas = memo<GraphCanvasProps>( lineWidth = 2; } - ctx.strokeStyle = connectionColor - ctx.lineWidth = lineWidth - ctx.globalAlpha = opacity - ctx.setLineDash(dashPattern) + ctx.strokeStyle = connectionColor; + ctx.lineWidth = lineWidth; + ctx.globalAlpha = opacity; + ctx.setLineDash(dashPattern); if (edge.edgeType === "version") { // Special double-line rendering for version chains // First line (outer) - ctx.lineWidth = 3 - ctx.globalAlpha = opacity * 0.3 - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.lineTo(targetX, targetY) - ctx.stroke() + ctx.lineWidth = 3; + ctx.globalAlpha = opacity * 0.3; + ctx.beginPath(); + ctx.moveTo(sourceX, sourceY); + ctx.lineTo(targetX, targetY); + ctx.stroke(); // Second line (inner) - ctx.lineWidth = 1 - ctx.globalAlpha = opacity - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.lineTo(targetX, targetY) - ctx.stroke() + ctx.lineWidth = 1; + ctx.globalAlpha = opacity; + ctx.beginPath(); + ctx.moveTo(sourceX, sourceY); + ctx.lineTo(targetX, targetY); + ctx.stroke(); } else { // Simplified lines when zoomed out, curved when zoomed in if (useSimplifiedRendering) { // Straight lines for performance - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.lineTo(targetX, targetY) - ctx.stroke() + ctx.beginPath(); + ctx.moveTo(sourceX, sourceY); + ctx.lineTo(targetX, targetY); + ctx.stroke(); } else { // Regular curved line for doc-memory and doc-doc - const midX = (sourceX + targetX) / 2 - const midY = (sourceY + targetY) / 2 - const dx = targetX - sourceX - const dy = targetY - sourceY - const distance = Math.sqrt(dx * dx + dy * dy) + const midX = (sourceX + targetX) / 2; + const midY = (sourceY + targetY) / 2; + const dx = targetX - sourceX; + const dy = targetY - sourceY; + const distance = Math.sqrt(dx * dx + dy * dy); const controlOffset = edge.edgeType === "doc-memory" ? 15 - : Math.min(30, distance * 0.2) + : Math.min(30, distance * 0.2); - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) + ctx.beginPath(); + ctx.moveTo(sourceX, sourceY); ctx.quadraticCurveTo( midX + controlOffset * (dy / distance), midY - controlOffset * (dx / distance), targetX, targetY, - ) - ctx.stroke() + ); + ctx.stroke(); } } @@ -329,113 +329,116 @@ export const GraphCanvas = memo<GraphCanvasProps>( ctx.restore(); } } - }) + }); - ctx.globalAlpha = 1 - ctx.setLineDash([]) + ctx.globalAlpha = 1; + ctx.setLineDash([]); // Prepare highlight set from provided document IDs (customId or internal) - const highlightSet = new Set<string>(highlightDocumentIds ?? []) + const highlightSet = new Set<string>(highlightDocumentIds ?? []); // Draw nodes with enhanced styling and LOD optimization nodes.forEach((node) => { - const screenX = node.x * zoom + panX - const screenY = node.y * zoom + panY - const nodeSize = node.size * zoom + const screenX = node.x * zoom + panX; + const screenY = node.y * zoom + panY; + const nodeSize = node.size * zoom; // Enhanced viewport culling - const margin = nodeSize + 50 + const margin = nodeSize + 50; if ( screenX < -margin || screenX > width + margin || screenY < -margin || screenY > height + margin ) { - return + return; } - const isHovered = currentHoveredNode.current === node.id - const isDragging = node.isDragging + const isHovered = currentHoveredNode.current === node.id; + const isDragging = node.isDragging; const isHighlightedDocument = (() => { - if (node.type !== "document" || highlightSet.size === 0) return false - const doc = node.data as DocumentWithMemories - if (doc.customId && highlightSet.has(doc.customId)) return true - return highlightSet.has(doc.id) - })() + if (node.type !== "document" || highlightSet.size === 0) return false; + const doc = node.data as DocumentWithMemories; + if (doc.customId && highlightSet.has(doc.customId)) return true; + return highlightSet.has(doc.id); + })(); if (node.type === "document") { // Enhanced glassmorphism document styling - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 + const docWidth = nodeSize * 1.4; + const docHeight = nodeSize * 0.9; // Multi-layer glass effect ctx.fillStyle = isDragging ? colors.document.accent : isHovered ? colors.document.secondary - : colors.document.primary - ctx.globalAlpha = 1 + : colors.document.primary; + ctx.globalAlpha = 1; // Enhanced border with subtle glow ctx.strokeStyle = isDragging ? colors.document.glow : isHovered ? colors.document.accent - : colors.document.border - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1 + : colors.document.border; + ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1; // Rounded rectangle with enhanced styling - const radius = useSimplifiedRendering ? 6 : 12 - ctx.beginPath() + const radius = useSimplifiedRendering ? 6 : 12; + ctx.beginPath(); ctx.roundRect( screenX - docWidth / 2, screenY - docHeight / 2, docWidth, docHeight, radius, - ) - ctx.fill() - ctx.stroke() + ); + ctx.fill(); + ctx.stroke(); // Subtle inner highlight for glass effect (skip when zoomed out) if (!useSimplifiedRendering && (isHovered || isDragging)) { - ctx.strokeStyle = "rgba(255, 255, 255, 0.1)" - ctx.lineWidth = 1 - ctx.beginPath() + ctx.strokeStyle = "rgba(255, 255, 255, 0.1)"; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect( screenX - docWidth / 2 + 1, screenY - docHeight / 2 + 1, docWidth - 2, docHeight - 2, radius - 1, - ) - ctx.stroke() + ); + ctx.stroke(); } // Highlight ring for search hits if (isHighlightedDocument) { - ctx.save() - ctx.globalAlpha = 0.9 - ctx.strokeStyle = colors.accent.primary - ctx.lineWidth = 3 - ctx.setLineDash([6, 4]) - const ringPadding = 10 - ctx.beginPath() + ctx.save(); + ctx.globalAlpha = 0.9; + ctx.strokeStyle = colors.accent.primary; + ctx.lineWidth = 3; + ctx.setLineDash([6, 4]); + const ringPadding = 10; + ctx.beginPath(); ctx.roundRect( screenX - docWidth / 2 - ringPadding, screenY - docHeight / 2 - ringPadding, docWidth + ringPadding * 2, docHeight + ringPadding * 2, radius + 6, - ) - ctx.stroke() - ctx.setLineDash([]) - ctx.restore() + ); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); } } else { // Enhanced memory styling with status indicators const mem = node.data as MemoryEntry; - const isForgotten = mem.isForgotten || (mem.forgetAfter && new Date(mem.forgetAfter).getTime() < Date.now()); + const isForgotten = + mem.isForgotten || + (mem.forgetAfter && + new Date(mem.forgetAfter).getTime() < Date.now()); const isLatest = mem.isLatest; // Check if memory is expiring soon (within 7 days) @@ -443,86 +446,87 @@ export const GraphCanvas = memo<GraphCanvasProps>( mem.forgetAfter && !isForgotten && new Date(mem.forgetAfter).getTime() - Date.now() < - 1000 * 60 * 60 * 24 * 7 + 1000 * 60 * 60 * 24 * 7; // Check if memory is new (created within last 24 hours) const isNew = !isForgotten && - new Date(mem.createdAt).getTime() > Date.now() - 1000 * 60 * 60 * 24 + new Date(mem.createdAt).getTime() > + Date.now() - 1000 * 60 * 60 * 24; // Determine colors based on status - let fillColor = colors.memory.primary - let borderColor = colors.memory.border - let glowColor = colors.memory.glow + let fillColor = colors.memory.primary; + let borderColor = colors.memory.border; + let glowColor = colors.memory.glow; if (isForgotten) { fillColor = colors.status.forgotten; borderColor = "rgba(220,38,38,0.3)"; glowColor = "rgba(220,38,38,0.2)"; } else if (expiringSoon) { - borderColor = colors.status.expiring - glowColor = colors.accent.amber + borderColor = colors.status.expiring; + glowColor = colors.accent.amber; } else if (isNew) { - borderColor = colors.status.new - glowColor = colors.accent.emerald + borderColor = colors.status.new; + glowColor = colors.accent.emerald; } if (isDragging) { - fillColor = colors.memory.accent - borderColor = glowColor + fillColor = colors.memory.accent; + borderColor = glowColor; } else if (isHovered) { - fillColor = colors.memory.secondary + fillColor = colors.memory.secondary; } - const radius = nodeSize / 2 + const radius = nodeSize / 2; - ctx.fillStyle = fillColor - ctx.globalAlpha = isLatest ? 1 : 0.4 - ctx.strokeStyle = borderColor - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5 + ctx.fillStyle = fillColor; + ctx.globalAlpha = isLatest ? 1 : 0.4; + ctx.strokeStyle = borderColor; + ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5; if (useSimplifiedRendering) { // Simple circles when zoomed out for performance - ctx.beginPath() - ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI) - ctx.fill() - ctx.stroke() + ctx.beginPath(); + ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI); + ctx.fill(); + ctx.stroke(); } else { // HEXAGONAL memory nodes when zoomed in - const sides = 6 - ctx.beginPath() + const sides = 6; + ctx.beginPath(); for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 // Start from top - const x = screenX + radius * Math.cos(angle) - const y = screenY + radius * Math.sin(angle) + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; // Start from top + const x = screenX + radius * Math.cos(angle); + const y = screenY + radius * Math.sin(angle); if (i === 0) { - ctx.moveTo(x, y) + ctx.moveTo(x, y); } else { - ctx.lineTo(x, y) + ctx.lineTo(x, y); } } - ctx.closePath() - ctx.fill() - ctx.stroke() + ctx.closePath(); + ctx.fill(); + ctx.stroke(); // Inner highlight for glass effect if (isHovered || isDragging) { - ctx.strokeStyle = "rgba(147, 197, 253, 0.3)" - ctx.lineWidth = 1 - const innerRadius = radius - 2 - ctx.beginPath() + ctx.strokeStyle = "rgba(147, 197, 253, 0.3)"; + ctx.lineWidth = 1; + const innerRadius = radius - 2; + ctx.beginPath(); for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 - const x = screenX + innerRadius * Math.cos(angle) - const y = screenY + innerRadius * Math.sin(angle) + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; + const x = screenX + innerRadius * Math.cos(angle); + const y = screenY + innerRadius * Math.sin(angle); if (i === 0) { - ctx.moveTo(x, y) + ctx.moveTo(x, y); } else { - ctx.lineTo(x, y) + ctx.lineTo(x, y); } } - ctx.closePath() - ctx.stroke() + ctx.closePath(); + ctx.stroke(); } } @@ -540,31 +544,33 @@ export const GraphCanvas = memo<GraphCanvasProps>( ctx.stroke(); } else if (isNew) { // Small dot for new memories - ctx.fillStyle = colors.status.new - ctx.beginPath() + ctx.fillStyle = colors.status.new; + ctx.beginPath(); ctx.arc( screenX + nodeSize * 0.25, screenY - nodeSize * 0.25, Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px 0, 2 * Math.PI, - ) - ctx.fill() + ); + ctx.fill(); } } // Enhanced hover glow effect (skip when zoomed out for performance) if (!useSimplifiedRendering && (isHovered || isDragging)) { const glowColor = - node.type === "document" ? colors.document.glow : colors.memory.glow + node.type === "document" + ? colors.document.glow + : colors.memory.glow; - ctx.strokeStyle = glowColor - ctx.lineWidth = 1 - ctx.setLineDash([3, 3]) - ctx.globalAlpha = 0.6 + ctx.strokeStyle = glowColor; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.globalAlpha = 0.6; - ctx.beginPath() - const glowSize = nodeSize * 0.7 + ctx.beginPath(); + const glowSize = nodeSize * 0.7; if (node.type === "document") { ctx.roundRect( screenX - glowSize, @@ -572,33 +578,33 @@ export const GraphCanvas = memo<GraphCanvasProps>( glowSize * 2, glowSize * 1.4, 15, - ) + ); } else { // Hexagonal glow for memory nodes - const glowRadius = glowSize - const sides = 6 + const glowRadius = glowSize; + const sides = 6; for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 - const x = screenX + glowRadius * Math.cos(angle) - const y = screenY + glowRadius * Math.sin(angle) + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; + const x = screenX + glowRadius * Math.cos(angle); + const y = screenY + glowRadius * Math.sin(angle); if (i === 0) { - ctx.moveTo(x, y) + ctx.moveTo(x, y); } else { - ctx.lineTo(x, y) + ctx.lineTo(x, y); } } - ctx.closePath() + ctx.closePath(); } - ctx.stroke() - ctx.setLineDash([]) + ctx.stroke(); + ctx.setLineDash([]); } - }) + }); - ctx.globalAlpha = 1 - }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]) + ctx.globalAlpha = 1; + }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]); // Change-based rendering instead of continuous animation - const lastRenderParams = useRef<string>("") + const lastRenderParams = useRef<string>(""); // Create a render key that changes when visual state changes const renderKey = useMemo(() => { @@ -607,86 +613,96 @@ export const GraphCanvas = memo<GraphCanvasProps>( (n) => `${n.id}:${n.x}:${n.y}:${n.isDragging ? "1" : "0"}:${currentHoveredNode.current === n.id ? "1" : "0"}`, ) - .join("|") - const highlightKey = (highlightDocumentIds ?? []).join("|") - return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}` - }, [nodes, edges.length, panX, panY, zoom, width, height, highlightDocumentIds]) + .join("|"); + const highlightKey = (highlightDocumentIds ?? []).join("|"); + return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}`; + }, [ + nodes, + edges.length, + panX, + panY, + zoom, + width, + height, + highlightDocumentIds, + ]); // Only render when something actually changed useEffect(() => { if (renderKey !== lastRenderParams.current) { - lastRenderParams.current = renderKey - render() + lastRenderParams.current = renderKey; + render(); } - }, [renderKey, render]) + }, [renderKey, render]); // Cleanup any existing animation frames useEffect(() => { return () => { if (animationRef.current) { - cancelAnimationFrame(animationRef.current) + cancelAnimationFrame(animationRef.current); } - } - }, []) + }; + }, []); // Add native wheel event listener to prevent browser zoom useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return + const canvas = canvasRef.current; + if (!canvas) return; const handleNativeWheel = (e: WheelEvent) => { - e.preventDefault() - e.stopPropagation() + e.preventDefault(); + e.stopPropagation(); // Call the onWheel handler with a synthetic-like event onWheel({ deltaY: e.deltaY, deltaX: e.deltaX, - preventDefault: () => { }, - stopPropagation: () => { }, - } as React.WheelEvent) - } + preventDefault: () => {}, + stopPropagation: () => {}, + } as React.WheelEvent); + }; // Add listener with passive: false to ensure preventDefault works - canvas.addEventListener("wheel", handleNativeWheel, { passive: false }) + canvas.addEventListener("wheel", handleNativeWheel, { passive: false }); // Also prevent gesture events for touch devices const handleGesture = (e: Event) => { - e.preventDefault() - } + e.preventDefault(); + }; canvas.addEventListener("gesturestart", handleGesture, { passive: false, - }) + }); canvas.addEventListener("gesturechange", handleGesture, { passive: false, - }) - canvas.addEventListener("gestureend", handleGesture, { passive: false }) + }); + canvas.addEventListener("gestureend", handleGesture, { passive: false }); return () => { - canvas.removeEventListener("wheel", handleNativeWheel) - canvas.removeEventListener("gesturestart", handleGesture) - canvas.removeEventListener("gesturechange", handleGesture) - canvas.removeEventListener("gestureend", handleGesture) - } - }, [onWheel]) + canvas.removeEventListener("wheel", handleNativeWheel); + canvas.removeEventListener("gesturestart", handleGesture); + canvas.removeEventListener("gesturechange", handleGesture); + canvas.removeEventListener("gestureend", handleGesture); + }; + }, [onWheel]); // High-DPI handling -------------------------------------------------- - const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 + const dpr = + typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; useLayoutEffect(() => { - const canvas = canvasRef.current - if (!canvas) return + const canvas = canvasRef.current; + if (!canvas) return; // upscale backing store - canvas.style.width = `${width}px` - canvas.style.height = `${height}px` - canvas.width = width * dpr - canvas.height = height * dpr - - const ctx = canvas.getContext("2d") - ctx?.scale(dpr, dpr) - }, [width, height, dpr]) + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + canvas.width = width * dpr; + canvas.height = height * dpr; + + const ctx = canvas.getContext("2d"); + ctx?.scale(dpr, dpr); + }, [width, height, dpr]); // ----------------------------------------------------------------------- return ( @@ -698,22 +714,22 @@ export const GraphCanvas = memo<GraphCanvasProps>( onMouseDown={handleMouseDown} onMouseLeave={() => { if (draggingNodeId) { - onNodeDragEnd() + onNodeDragEnd(); } else { - onPanEnd() + onPanEnd(); } }} onMouseMove={(e) => { - handleMouseMove(e) + handleMouseMove(e); if (!draggingNodeId) { - onPanMove(e) + onPanMove(e); } }} onMouseUp={() => { if (draggingNodeId) { - onNodeDragEnd() + onNodeDragEnd(); } else { - onPanEnd() + onPanEnd(); } }} ref={canvasRef} @@ -729,8 +745,8 @@ export const GraphCanvas = memo<GraphCanvasProps>( }} width={width} /> - ) + ); }, -) +); -GraphCanvas.displayName = "GraphCanvas" +GraphCanvas.displayName = "GraphCanvas"; diff --git a/packages/ui/memory-graph/graph-webgl-canvas.tsx b/packages/ui/memory-graph/graph-webgl-canvas.tsx index 41b79343..9d775c2b 100644 --- a/packages/ui/memory-graph/graph-webgl-canvas.tsx +++ b/packages/ui/memory-graph/graph-webgl-canvas.tsx @@ -1,13 +1,13 @@ -"use client" +"use client"; -import { Application, extend } from "@pixi/react" -import { Container as PixiContainer, Graphics as PixiGraphics } from "pixi.js" -import { memo, useCallback, useEffect, useMemo, useRef } from "react" -import { colors } from "./constants" -import type { GraphCanvasProps, MemoryEntry } from "./types" +import { Application, extend } from "@pixi/react"; +import { Container as PixiContainer, Graphics as PixiGraphics } from "pixi.js"; +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; +import { colors } from "./constants"; +import type { GraphCanvasProps, MemoryEntry } from "./types"; // Register Pixi Graphics and Container so they can be used as JSX elements -extend({ Graphics: PixiGraphics, Container: PixiContainer }) +extend({ Graphics: PixiGraphics, Container: PixiContainer }); export const GraphWebGLCanvas = memo<GraphCanvasProps>( ({ @@ -30,348 +30,349 @@ export const GraphWebGLCanvas = memo<GraphCanvasProps>( onDoubleClick, draggingNodeId, }) => { - const containerRef = useRef<HTMLDivElement>(null) - const isPanningRef = useRef(false) - const currentHoveredRef = useRef<string | null>(null) - const pointerDownPosRef = useRef<{ x: number; y: number } | null>(null) - const pointerMovedRef = useRef(false) + const containerRef = useRef<HTMLDivElement>(null); + const isPanningRef = useRef(false); + const currentHoveredRef = useRef<string | null>(null); + const pointerDownPosRef = useRef<{ x: number; y: number } | null>(null); + const pointerMovedRef = useRef(false); // World container that is transformed instead of redrawing every pan/zoom - const worldContainerRef = useRef<PixiContainer | null>(null) + const worldContainerRef = useRef<PixiContainer | null>(null); // Throttled wheel handling ------------------------------------------- const pendingWheelDeltaRef = useRef<{ dx: number; dy: number }>({ dx: 0, dy: 0, - }) - const wheelRafRef = useRef<number | null>(null) + }); + const wheelRafRef = useRef<number | null>(null); // Removed bitmap caching due to black-screen issues – throttle already boosts zoom performance // Persistent graphics refs - const gridG = useRef<PixiGraphics | null>(null) - const edgesG = useRef<PixiGraphics | null>(null) - const docsG = useRef<PixiGraphics | null>(null) - const memsG = useRef<PixiGraphics | null>(null) + const gridG = useRef<PixiGraphics | null>(null); + const edgesG = useRef<PixiGraphics | null>(null); + const docsG = useRef<PixiGraphics | null>(null); + const memsG = useRef<PixiGraphics | null>(null); // ---------- Zoom bucket (reduces redraw frequency) ---------- - const zoomBucket = useMemo(() => Math.round(zoom * 4) / 4, [zoom]) + const zoomBucket = useMemo(() => Math.round(zoom * 4) / 4, [zoom]); // Redraw layers only when their data changes ---------------------- useEffect(() => { - if (gridG.current) drawGrid(gridG.current) - }, [panX, panY, zoom, width, height]) + if (gridG.current) drawGrid(gridG.current); + }, [panX, panY, zoom, width, height]); useEffect(() => { - if (edgesG.current) drawEdges(edgesG.current) - }, [edgesG.current, edges, nodes, zoomBucket]) + if (edgesG.current) drawEdges(edgesG.current); + }, [edgesG.current, edges, nodes, zoomBucket]); useEffect(() => { - if (docsG.current) drawDocuments(docsG.current) - }, [docsG.current, nodes, zoomBucket]) + if (docsG.current) drawDocuments(docsG.current); + }, [docsG.current, nodes, zoomBucket]); useEffect(() => { - if (memsG.current) drawMemories(memsG.current) - }, [memsG.current, nodes, zoomBucket]) + if (memsG.current) drawMemories(memsG.current); + }, [memsG.current, nodes, zoomBucket]); // Apply pan & zoom via world transform instead of geometry rebuilds useEffect(() => { if (worldContainerRef.current) { - worldContainerRef.current.position.set(panX, panY) - worldContainerRef.current.scale.set(zoom) + worldContainerRef.current.position.set(panX, panY); + worldContainerRef.current.scale.set(zoom); } - }, [panX, panY, zoom]) + }, [panX, panY, zoom]); // No bitmap caching – nothing to clean up /* ---------- Helpers ---------- */ const getNodeAtPosition = useCallback( (clientX: number, clientY: number): string | null => { - const rect = containerRef.current?.getBoundingClientRect() - if (!rect) return null + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return null; - const localX = clientX - rect.left - const localY = clientY - rect.top + const localX = clientX - rect.left; + const localY = clientY - rect.top; - const worldX = (localX - panX) / zoom - const worldY = (localY - panY) / zoom + const worldX = (localX - panX) / zoom; + const worldY = (localY - panY) / zoom; for (const node of nodes) { if (node.type === "document") { - const halfW = (node.size * 1.4) / 2 - const halfH = (node.size * 0.9) / 2 + const halfW = (node.size * 1.4) / 2; + const halfH = (node.size * 0.9) / 2; if ( worldX >= node.x - halfW && worldX <= node.x + halfW && worldY >= node.y - halfH && worldY <= node.y + halfH ) { - return node.id + return node.id; } } else if (node.type === "memory") { - const r = node.size / 2 - const dx = worldX - node.x - const dy = worldY - node.y + const r = node.size / 2; + const dx = worldX - node.x; + const dy = worldY - node.y; if (dx * dx + dy * dy <= r * r) { - return node.id + return node.id; } } } - return null + return null; }, [nodes, panX, panY, zoom], - ) + ); /* ---------- Grid drawing ---------- */ const drawGrid = useCallback( (g: PixiGraphics) => { - g.clear() + g.clear(); - const gridColor = 0x94a3b8 // rgb(148,163,184) - const gridAlpha = 0.03 - const gridSpacing = 100 * zoom + const gridColor = 0x94a3b8; // rgb(148,163,184) + const gridAlpha = 0.03; + const gridSpacing = 100 * zoom; // panning offsets - const offsetX = panX % gridSpacing - const offsetY = panY % gridSpacing + const offsetX = panX % gridSpacing; + const offsetY = panY % gridSpacing; - g.lineStyle(1, gridColor, gridAlpha) + g.lineStyle(1, gridColor, gridAlpha); // vertical lines for (let x = offsetX; x < width; x += gridSpacing) { - g.moveTo(x, 0) - g.lineTo(x, height) + g.moveTo(x, 0); + g.lineTo(x, height); } // horizontal lines for (let y = offsetY; y < height; y += gridSpacing) { - g.moveTo(0, y) - g.lineTo(width, y) + g.moveTo(0, y); + g.lineTo(width, y); } // Stroke to render grid lines - g.stroke() + g.stroke(); }, [panX, panY, zoom, width, height], - ) + ); /* ---------- Color parsing ---------- */ const toHexAlpha = (input: string): { hex: number; alpha: number } => { - if (!input) return { hex: 0xffffff, alpha: 1 } - const str = input.trim().toLowerCase() + if (!input) return { hex: 0xffffff, alpha: 1 }; + const str = input.trim().toLowerCase(); // rgba() or rgb() const rgbaMatch = str .replace(/\s+/g, "") - .match(/rgba?\((\d+),(\d+),(\d+)(?:,(\d*\.?\d+))?\)/i) + .match(/rgba?\((\d+),(\d+),(\d+)(?:,(\d*\.?\d+))?\)/i); if (rgbaMatch) { - const r = Number.parseInt(rgbaMatch[1] || '0') - const g = Number.parseInt(rgbaMatch[2] || '0') - const b = Number.parseInt(rgbaMatch[3] || '0') + const r = Number.parseInt(rgbaMatch[1] || "0"); + const g = Number.parseInt(rgbaMatch[2] || "0"); + const b = Number.parseInt(rgbaMatch[3] || "0"); const a = - rgbaMatch[4] !== undefined ? Number.parseFloat(rgbaMatch[4]) : 1 - return { hex: (r << 16) + (g << 8) + b, alpha: a } + rgbaMatch[4] !== undefined ? Number.parseFloat(rgbaMatch[4]) : 1; + return { hex: (r << 16) + (g << 8) + b, alpha: a }; } // #rrggbb or #rrggbbaa if (str.startsWith("#")) { - const hexBody = str.slice(1) + const hexBody = str.slice(1); if (hexBody.length === 6) { - return { hex: Number.parseInt(hexBody, 16), alpha: 1 } + return { hex: Number.parseInt(hexBody, 16), alpha: 1 }; } if (hexBody.length === 8) { - const rgb = Number.parseInt(hexBody.slice(0, 6), 16) - const aByte = Number.parseInt(hexBody.slice(6, 8), 16) - return { hex: rgb, alpha: aByte / 255 } + const rgb = Number.parseInt(hexBody.slice(0, 6), 16); + const aByte = Number.parseInt(hexBody.slice(6, 8), 16); + return { hex: rgb, alpha: aByte / 255 }; } } // 0xRRGGBB if (str.startsWith("0x")) { - return { hex: Number.parseInt(str, 16), alpha: 1 } + return { hex: Number.parseInt(str, 16), alpha: 1 }; } - return { hex: 0xffffff, alpha: 1 } - } + return { hex: 0xffffff, alpha: 1 }; + }; const drawDocuments = useCallback( (g: PixiGraphics) => { - g.clear() + g.clear(); nodes.forEach((node) => { - if (node.type !== "document") return + if (node.type !== "document") return; // World-space coordinates – container transform handles pan/zoom - const screenX = node.x - const screenY = node.y - const nodeSize = node.size + const screenX = node.x; + const screenY = node.y; + const nodeSize = node.size; - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 + const docWidth = nodeSize * 1.4; + const docHeight = nodeSize * 0.9; // Choose colors similar to canvas version const fill = node.isDragging ? colors.document.accent : node.isHovered ? colors.document.secondary - : colors.document.primary + : colors.document.primary; const strokeCol = node.isDragging ? colors.document.glow : node.isHovered ? colors.document.accent - : colors.document.border + : colors.document.border; - const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fill) - const { hex: strokeHex, alpha: strokeAlpha } = toHexAlpha(strokeCol) + const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fill); + const { hex: strokeHex, alpha: strokeAlpha } = toHexAlpha(strokeCol); // Stroke first then fill for proper shape borders const docStrokeWidth = - (node.isDragging ? 3 : node.isHovered ? 2 : 1) / zoom - g.lineStyle(docStrokeWidth, strokeHex, strokeAlpha) - g.beginFill(fillHex, fillAlpha) + (node.isDragging ? 3 : node.isHovered ? 2 : 1) / zoom; + g.lineStyle(docStrokeWidth, strokeHex, strokeAlpha); + g.beginFill(fillHex, fillAlpha); - const radius = zoom < 0.3 ? 6 : 12 + const radius = zoom < 0.3 ? 6 : 12; g.drawRoundedRect( screenX - docWidth / 2, screenY - docHeight / 2, docWidth, docHeight, radius, - ) - g.endFill() + ); + g.endFill(); // Inner highlight for glass effect (match GraphCanvas) if (zoom >= 0.3 && (node.isHovered || node.isDragging)) { - const { hex: hlHex } = toHexAlpha("#ffffff") + const { hex: hlHex } = toHexAlpha("#ffffff"); // Inner highlight stroke width constant - const innerStroke = 1 / zoom - g.lineStyle(innerStroke, hlHex, 0.1) + const innerStroke = 1 / zoom; + g.lineStyle(innerStroke, hlHex, 0.1); g.drawRoundedRect( screenX - docWidth / 2 + 1, screenY - docHeight / 2 + 1, docWidth - 2, docHeight - 2, radius - 1, - ) - g.stroke() + ); + g.stroke(); } - }) + }); }, [nodes, zoom], - ) + ); /* ---------- Memories layer ---------- */ const drawMemories = useCallback( (g: PixiGraphics) => { - g.clear() + g.clear(); nodes.forEach((node) => { - if (node.type !== "memory") return + if (node.type !== "memory") return; - const mem = node.data as MemoryEntry - const screenX = node.x - const screenY = node.y - const nodeSize = node.size + const mem = node.data as MemoryEntry; + const screenX = node.x; + const screenY = node.y; + const nodeSize = node.size; - const radius = nodeSize / 2 + const radius = nodeSize / 2; // status checks const isForgotten = mem?.isForgotten || (mem?.forgetAfter && - new Date(mem.forgetAfter).getTime() < Date.now()) - const isLatest = mem?.isLatest + new Date(mem.forgetAfter).getTime() < Date.now()); + const isLatest = mem?.isLatest; const expiringSoon = mem?.forgetAfter && !isForgotten && new Date(mem.forgetAfter).getTime() - Date.now() < - 1000 * 60 * 60 * 24 * 7 + 1000 * 60 * 60 * 24 * 7; const isNew = !isForgotten && new Date(mem?.createdAt).getTime() > - Date.now() - 1000 * 60 * 60 * 24 + Date.now() - 1000 * 60 * 60 * 24; // colours - let fillColor = colors.memory.primary - let borderColor = colors.memory.border - let glowColor = colors.memory.glow + let fillColor = colors.memory.primary; + let borderColor = colors.memory.border; + let glowColor = colors.memory.glow; if (isForgotten) { - fillColor = colors.status.forgotten - borderColor = "rgba(220,38,38,0.3)" - glowColor = "rgba(220,38,38,0.2)" + fillColor = colors.status.forgotten; + borderColor = "rgba(220,38,38,0.3)"; + glowColor = "rgba(220,38,38,0.2)"; } else if (expiringSoon) { - borderColor = colors.status.expiring - glowColor = colors.accent.amber + borderColor = colors.status.expiring; + glowColor = colors.accent.amber; } else if (isNew) { - borderColor = colors.status.new - glowColor = colors.accent.emerald + borderColor = colors.status.new; + glowColor = colors.accent.emerald; } if (node.isDragging) { - fillColor = colors.memory.accent - borderColor = glowColor + fillColor = colors.memory.accent; + borderColor = glowColor; } else if (node.isHovered) { - fillColor = colors.memory.secondary + fillColor = colors.memory.secondary; } - const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fillColor) - const { hex: borderHex, alpha: borderAlpha } = toHexAlpha(borderColor) + const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fillColor); + const { hex: borderHex, alpha: borderAlpha } = + toHexAlpha(borderColor); // Match canvas behavior: multiply by isLatest global alpha - const globalAlpha = isLatest ? 1 : 0.4 - const finalFillAlpha = globalAlpha * fillAlpha - const finalStrokeAlpha = globalAlpha * borderAlpha + const globalAlpha = isLatest ? 1 : 0.4; + const finalFillAlpha = globalAlpha * fillAlpha; + const finalStrokeAlpha = globalAlpha * borderAlpha; // Stroke first then fill for visible border const memStrokeW = - (node.isDragging ? 3 : node.isHovered ? 2 : 1.5) / zoom - g.lineStyle(memStrokeW, borderHex, finalStrokeAlpha) - g.beginFill(fillHex, finalFillAlpha) + (node.isDragging ? 3 : node.isHovered ? 2 : 1.5) / zoom; + g.lineStyle(memStrokeW, borderHex, finalStrokeAlpha); + g.beginFill(fillHex, finalFillAlpha); if (zoom < 0.3) { // simplified circle when zoomed out - g.drawCircle(screenX, screenY, radius) + g.drawCircle(screenX, screenY, radius); } else { // hexagon - const sides = 6 - const points: number[] = [] + const sides = 6; + const points: number[] = []; for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 - points.push(screenX + radius * Math.cos(angle)) - points.push(screenY + radius * Math.sin(angle)) + const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; + points.push(screenX + radius * Math.cos(angle)); + points.push(screenY + radius * Math.sin(angle)); } - g.drawPolygon(points) + g.drawPolygon(points); } - g.endFill() + g.endFill(); // Status overlays (forgotten / new) – match GraphCanvas visuals if (isForgotten) { const { hex: crossHex, alpha: crossAlpha } = toHexAlpha( "rgba(220,38,38,0.4)", - ) + ); // Cross/ dot overlay stroke widths constant - const overlayStroke = 2 / zoom - g.lineStyle(overlayStroke, crossHex, globalAlpha * crossAlpha) - const rCross = nodeSize * 0.25 - g.moveTo(screenX - rCross, screenY - rCross) - g.lineTo(screenX + rCross, screenY + rCross) - g.moveTo(screenX + rCross, screenY - rCross) - g.lineTo(screenX - rCross, screenY + rCross) - g.stroke() + const overlayStroke = 2 / zoom; + g.lineStyle(overlayStroke, crossHex, globalAlpha * crossAlpha); + const rCross = nodeSize * 0.25; + g.moveTo(screenX - rCross, screenY - rCross); + g.lineTo(screenX + rCross, screenY + rCross); + g.moveTo(screenX + rCross, screenY - rCross); + g.lineTo(screenX - rCross, screenY + rCross); + g.stroke(); } else if (isNew) { const { hex: dotHex, alpha: dotAlpha } = toHexAlpha( colors.status.new, - ) + ); // Dot scales with node (GraphCanvas behaviour) - const dotRadius = Math.max(2, nodeSize * 0.15) - g.beginFill(dotHex, globalAlpha * dotAlpha) + const dotRadius = Math.max(2, nodeSize * 0.15); + g.beginFill(dotHex, globalAlpha * dotAlpha); g.drawCircle( screenX + nodeSize * 0.25, screenY - nodeSize * 0.25, dotRadius, - ) - g.endFill() + ); + g.endFill(); } - }) + }); }, [nodes, zoom], - ) + ); /* ---------- Edges layer ---------- */ // Helper: draw dashed quadratic curve to approximate canvas setLineDash @@ -388,79 +389,79 @@ export const GraphWebGLCanvas = memo<GraphCanvasProps>( gap = 5, ) => { // Sample the curve and accumulate lines per dash to avoid overdraw - const curveLength = Math.sqrt((sx - tx) ** 2 + (sy - ty) ** 2) + const curveLength = Math.sqrt((sx - tx) ** 2 + (sy - ty) ** 2); const totalSamples = Math.max( 20, Math.min(120, Math.floor(curveLength / 10)), - ) - let prevX = sx - let prevY = sy - let distanceSinceToggle = 0 - let drawSegment = true - let hasActiveDash = false - let dashStartX = sx - let dashStartY = sy + ); + let prevX = sx; + let prevY = sy; + let distanceSinceToggle = 0; + let drawSegment = true; + let hasActiveDash = false; + let dashStartX = sx; + let dashStartY = sy; for (let i = 1; i <= totalSamples; i++) { - const t = i / totalSamples - const mt = 1 - t - const x = mt * mt * sx + 2 * mt * t * cx + t * t * tx - const y = mt * mt * sy + 2 * mt * t * cy + t * t * ty + const t = i / totalSamples; + const mt = 1 - t; + const x = mt * mt * sx + 2 * mt * t * cx + t * t * tx; + const y = mt * mt * sy + 2 * mt * t * cy + t * t * ty; - const dx = x - prevX - const dy = y - prevY - const segLen = Math.sqrt(dx * dx + dy * dy) - distanceSinceToggle += segLen + const dx = x - prevX; + const dy = y - prevY; + const segLen = Math.sqrt(dx * dx + dy * dy); + distanceSinceToggle += segLen; if (drawSegment) { if (!hasActiveDash) { - dashStartX = prevX - dashStartY = prevY - hasActiveDash = true + dashStartX = prevX; + dashStartY = prevY; + hasActiveDash = true; } } - const threshold = drawSegment ? dash : gap + const threshold = drawSegment ? dash : gap; if (distanceSinceToggle >= threshold) { // end of current phase if (drawSegment && hasActiveDash) { - g.moveTo(dashStartX, dashStartY) - g.lineTo(prevX, prevY) - g.stroke() - hasActiveDash = false + g.moveTo(dashStartX, dashStartY); + g.lineTo(prevX, prevY); + g.stroke(); + hasActiveDash = false; } - distanceSinceToggle = 0 - drawSegment = !drawSegment + distanceSinceToggle = 0; + drawSegment = !drawSegment; // If we transition into draw mode, start a new dash at current segment start if (drawSegment) { - dashStartX = prevX - dashStartY = prevY - hasActiveDash = true + dashStartX = prevX; + dashStartY = prevY; + hasActiveDash = true; } } - prevX = x - prevY = y + prevX = x; + prevY = y; } // Flush any active dash at the end if (drawSegment && hasActiveDash) { - g.moveTo(dashStartX, dashStartY) - g.lineTo(prevX, prevY) - g.stroke() + g.moveTo(dashStartX, dashStartY); + g.lineTo(prevX, prevY); + g.stroke(); } }, [], - ) + ); const drawEdges = useCallback( (g: PixiGraphics) => { - g.clear() + g.clear(); // Match GraphCanvas LOD behaviour - const useSimplified = zoom < 0.3 + const useSimplified = zoom < 0.3; // quick node lookup - const nodeMap = new Map(nodes.map((n) => [n.id, n])) + const nodeMap = new Map(nodes.map((n) => [n.id, n])); edges.forEach((edge) => { // Skip very weak doc-memory edges when zoomed out – behaviour copied from GraphCanvas @@ -469,119 +470,119 @@ export const GraphWebGLCanvas = memo<GraphCanvasProps>( edge.edgeType === "doc-memory" && (edge.visualProps?.opacity ?? 1) < 0.3 ) { - return + return; } - const source = nodeMap.get(edge.source) - const target = nodeMap.get(edge.target) - if (!source || !target) return + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) return; - const sx = source.x - const sy = source.y - const tx = target.x - const ty = target.y + const sx = source.x; + const sy = source.y; + const tx = target.x; + const ty = target.y; // No viewport culling here because container transform handles visibility - let lineWidth = Math.max(1, edge.visualProps?.thickness ?? 1) + let lineWidth = Math.max(1, edge.visualProps?.thickness ?? 1); // Use opacity exactly as provided to match GraphCanvas behaviour - let opacity = edge.visualProps.opacity - let col = edge.color || colors.connection.weak + let opacity = edge.visualProps.opacity; + let col = edge.color || colors.connection.weak; if (edge.edgeType === "doc-memory") { - lineWidth = 1 - opacity = 0.9 - col = colors.connection.memory + lineWidth = 1; + opacity = 0.9; + col = colors.connection.memory; - if (useSimplified && opacity < 0.3) return + if (useSimplified && opacity < 0.3) return; } else if (edge.edgeType === "doc-doc") { - opacity = Math.max(0, edge.similarity * 0.5) - lineWidth = Math.max(1, edge.similarity * 2) - col = colors.connection.medium - if (edge.similarity > 0.85) col = colors.connection.strong + opacity = Math.max(0, edge.similarity * 0.5); + lineWidth = Math.max(1, edge.similarity * 2); + col = colors.connection.medium; + if (edge.similarity > 0.85) col = colors.connection.strong; } else if (edge.edgeType === "version") { - col = edge.color || colors.relations.updates - opacity = 0.8 - lineWidth = 2 + col = edge.color || colors.relations.updates; + opacity = 0.8; + lineWidth = 2; } - const { hex: strokeHex, alpha: colorAlpha } = toHexAlpha(col) - const finalEdgeAlpha = Math.max(0, Math.min(1, opacity * colorAlpha)) + const { hex: strokeHex, alpha: colorAlpha } = toHexAlpha(col); + const finalEdgeAlpha = Math.max(0, Math.min(1, opacity * colorAlpha)); // Always use round line caps (same as Canvas 2D) - const screenLineWidth = lineWidth / zoom - g.lineStyle(screenLineWidth, strokeHex, finalEdgeAlpha) + const screenLineWidth = lineWidth / zoom; + g.lineStyle(screenLineWidth, strokeHex, finalEdgeAlpha); if (edge.edgeType === "version") { // double line effect to match canvas (outer thicker, faint + inner thin) - g.lineStyle(3 / zoom, strokeHex, finalEdgeAlpha * 0.3) - g.moveTo(sx, sy) - g.lineTo(tx, ty) - g.stroke() + g.lineStyle(3 / zoom, strokeHex, finalEdgeAlpha * 0.3); + g.moveTo(sx, sy); + g.lineTo(tx, ty); + g.stroke(); - g.lineStyle(1 / zoom, strokeHex, finalEdgeAlpha) - g.moveTo(sx, sy) - g.lineTo(tx, ty) - g.stroke() + g.lineStyle(1 / zoom, strokeHex, finalEdgeAlpha); + g.moveTo(sx, sy); + g.lineTo(tx, ty); + g.stroke(); // arrow head - const angle = Math.atan2(ty - sy, tx - sx) - const arrowLen = Math.max(6 / zoom, 8) - const nodeRadius = target.size / 2 - const ax = tx - Math.cos(angle) * (nodeRadius + 2) - const ay = ty - Math.sin(angle) * (nodeRadius + 2) + const angle = Math.atan2(ty - sy, tx - sx); + const arrowLen = Math.max(6 / zoom, 8); + const nodeRadius = target.size / 2; + const ax = tx - Math.cos(angle) * (nodeRadius + 2); + const ay = ty - Math.sin(angle) * (nodeRadius + 2); - g.moveTo(ax, ay) + g.moveTo(ax, ay); g.lineTo( ax - arrowLen * Math.cos(angle - Math.PI / 6), ay - arrowLen * Math.sin(angle - Math.PI / 6), - ) - g.moveTo(ax, ay) + ); + g.moveTo(ax, ay); g.lineTo( ax - arrowLen * Math.cos(angle + Math.PI / 6), ay - arrowLen * Math.sin(angle + Math.PI / 6), - ) - g.stroke() + ); + g.stroke(); } else { // straight line when zoomed out; dashed curved when zoomed in for doc-doc if (useSimplified) { - g.moveTo(sx, sy) - g.lineTo(tx, ty) - g.stroke() + g.moveTo(sx, sy); + g.lineTo(tx, ty); + g.stroke(); } else { - const midX = (sx + tx) / 2 - const midY = (sy + ty) / 2 - const dx = tx - sx - const dy = ty - sy - const dist = Math.sqrt(dx * dx + dy * dy) + const midX = (sx + tx) / 2; + const midY = (sy + ty) / 2; + const dx = tx - sx; + const dy = ty - sy; + const dist = Math.sqrt(dx * dx + dy * dy); const ctrlOffset = - edge.edgeType === "doc-memory" ? 15 : Math.min(30, dist * 0.2) + edge.edgeType === "doc-memory" ? 15 : Math.min(30, dist * 0.2); - const cx = midX + ctrlOffset * (dy / dist) - const cy = midY - ctrlOffset * (dx / dist) + const cx = midX + ctrlOffset * (dy / dist); + const cy = midY - ctrlOffset * (dx / dist); if (edge.edgeType === "doc-doc") { if (useSimplified) { // Straight line when zoomed out (no dash) - g.moveTo(sx, sy) - g.quadraticCurveTo(cx, cy, tx, ty) - g.stroke() + g.moveTo(sx, sy); + g.quadraticCurveTo(cx, cy, tx, ty); + g.stroke(); } else { // Dash lengths scale with zoom to keep screen size constant - const dash = 10 / zoom - const gap = 5 / zoom - drawDashedQuadratic(g, sx, sy, cx, cy, tx, ty, dash, gap) + const dash = 10 / zoom; + const gap = 5 / zoom; + drawDashedQuadratic(g, sx, sy, cx, cy, tx, ty, dash, gap); } } else { - g.moveTo(sx, sy) - g.quadraticCurveTo(cx, cy, tx, ty) - g.stroke() + g.moveTo(sx, sy); + g.quadraticCurveTo(cx, cy, tx, ty); + g.stroke(); } } } - }) + }); }, [edges, nodes, zoom, width, drawDashedQuadratic], - ) + ); /* ---------- pointer handlers (unchanged) ---------- */ // Pointer move (pan or drag) @@ -592,27 +593,27 @@ export const GraphWebGLCanvas = memo<GraphCanvasProps>( clientY: e.clientY, preventDefault: () => {}, stopPropagation: () => {}, - } as React.MouseEvent + } as React.MouseEvent; if (draggingNodeId) { // Node dragging handled elsewhere (future steps) - onNodeDragMove(mouseEvent) + onNodeDragMove(mouseEvent); } else if (isPanningRef.current) { - onPanMove(mouseEvent) + onPanMove(mouseEvent); } // Track movement for distinguishing click vs drag/pan if (pointerDownPosRef.current) { - const dx = e.clientX - pointerDownPosRef.current.x - const dy = e.clientY - pointerDownPosRef.current.y - if (Math.sqrt(dx * dx + dy * dy) > 3) pointerMovedRef.current = true + const dx = e.clientX - pointerDownPosRef.current.x; + const dy = e.clientY - pointerDownPosRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) > 3) pointerMovedRef.current = true; } // Hover detection - const nodeId = getNodeAtPosition(e.clientX, e.clientY) + const nodeId = getNodeAtPosition(e.clientX, e.clientY); if (nodeId !== currentHoveredRef.current) { - currentHoveredRef.current = nodeId - onNodeHover(nodeId) + currentHoveredRef.current = nodeId; + onNodeHover(nodeId); } }, [ @@ -622,7 +623,7 @@ export const GraphWebGLCanvas = memo<GraphCanvasProps>( onNodeHover, getNodeAtPosition, ], - ) + ); const handlePointerDown = useCallback( (e: React.PointerEvent<HTMLDivElement>) => { @@ -631,92 +632,92 @@ export const GraphWebGLCanvas = memo<GraphCanvasProps>( clientY: e.clientY, preventDefault: () => {}, stopPropagation: () => {}, - } as React.MouseEvent + } as React.MouseEvent; - const nodeId = getNodeAtPosition(e.clientX, e.clientY) + const nodeId = getNodeAtPosition(e.clientX, e.clientY); if (nodeId) { - onNodeDragStart(nodeId, mouseEvent) + onNodeDragStart(nodeId, mouseEvent); // drag handled externally } else { - onPanStart(mouseEvent) - isPanningRef.current = true + onPanStart(mouseEvent); + isPanningRef.current = true; } - pointerDownPosRef.current = { x: e.clientX, y: e.clientY } - pointerMovedRef.current = false + pointerDownPosRef.current = { x: e.clientX, y: e.clientY }; + pointerMovedRef.current = false; }, [onPanStart, onNodeDragStart, getNodeAtPosition], - ) + ); const handlePointerUp = useCallback( (e: React.PointerEvent<HTMLDivElement>) => { - const wasPanning = isPanningRef.current - if (draggingNodeId) onNodeDragEnd() - else if (wasPanning) onPanEnd() + const wasPanning = isPanningRef.current; + if (draggingNodeId) onNodeDragEnd(); + else if (wasPanning) onPanEnd(); // Consider it a click if not panning and movement was minimal if (!wasPanning && !pointerMovedRef.current) { - const nodeId = getNodeAtPosition(e.clientX, e.clientY) - if (nodeId) onNodeClick(nodeId) + const nodeId = getNodeAtPosition(e.clientX, e.clientY); + if (nodeId) onNodeClick(nodeId); } - isPanningRef.current = false - pointerDownPosRef.current = null - pointerMovedRef.current = false + isPanningRef.current = false; + pointerDownPosRef.current = null; + pointerMovedRef.current = false; }, [draggingNodeId, onNodeDragEnd, onPanEnd, getNodeAtPosition, onNodeClick], - ) + ); // Click handler – opens detail panel const handleClick = useCallback( (e: React.MouseEvent<HTMLDivElement>) => { - if (isPanningRef.current) return - const nodeId = getNodeAtPosition(e.clientX, e.clientY) - if (nodeId) onNodeClick(nodeId) + if (isPanningRef.current) return; + const nodeId = getNodeAtPosition(e.clientX, e.clientY); + if (nodeId) onNodeClick(nodeId); }, [getNodeAtPosition, onNodeClick], - ) + ); // Click handled in pointer up to avoid duplicate events const handleWheel = useCallback( (e: React.WheelEvent<HTMLDivElement>) => { - e.preventDefault() - e.stopPropagation() + e.preventDefault(); + e.stopPropagation(); // Accumulate deltas - pendingWheelDeltaRef.current.dx += e.deltaX - pendingWheelDeltaRef.current.dy += e.deltaY + pendingWheelDeltaRef.current.dx += e.deltaX; + pendingWheelDeltaRef.current.dy += e.deltaY; // Schedule a single update per frame if (wheelRafRef.current === null) { wheelRafRef.current = requestAnimationFrame(() => { - const { dx, dy } = pendingWheelDeltaRef.current - pendingWheelDeltaRef.current = { dx: 0, dy: 0 } + const { dx, dy } = pendingWheelDeltaRef.current; + pendingWheelDeltaRef.current = { dx: 0, dy: 0 }; onWheel({ deltaY: dy, deltaX: dx, preventDefault: () => {}, stopPropagation: () => {}, - } as React.WheelEvent) + } as React.WheelEvent); - wheelRafRef.current = null + wheelRafRef.current = null; // nothing else – caching removed - }) + }); } }, [onWheel], - ) + ); // Cleanup any pending RAF on unmount useEffect(() => { return () => { if (wheelRafRef.current !== null) { - cancelAnimationFrame(wheelRafRef.current) + cancelAnimationFrame(wheelRafRef.current); } - } - }, []) + }; + }, []); return ( <div @@ -726,15 +727,15 @@ export const GraphWebGLCanvas = memo<GraphCanvasProps>( } onKeyDown={(ev) => { if (ev.key === "Enter") - handleClick(ev as unknown as React.MouseEvent<HTMLDivElement>) + handleClick(ev as unknown as React.MouseEvent<HTMLDivElement>); }} onPointerDown={handlePointerDown} onPointerLeave={() => { - if (draggingNodeId) onNodeDragEnd() - if (isPanningRef.current) onPanEnd() - isPanningRef.current = false - pointerDownPosRef.current = null - pointerMovedRef.current = false + if (draggingNodeId) onNodeDragEnd(); + if (isPanningRef.current) onPanEnd(); + isPanningRef.current = false; + pointerDownPosRef.current = null; + pointerMovedRef.current = false; }} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} @@ -774,8 +775,8 @@ export const GraphWebGLCanvas = memo<GraphCanvasProps>( </pixiContainer> </Application> </div> - ) + ); }, -) +); -GraphWebGLCanvas.displayName = "GraphWebGLCanvas" +GraphWebGLCanvas.displayName = "GraphWebGLCanvas"; diff --git a/packages/ui/memory-graph/hooks/use-graph-data.ts b/packages/ui/memory-graph/hooks/use-graph-data.ts index f77f8446..3e9fa5cc 100644 --- a/packages/ui/memory-graph/hooks/use-graph-data.ts +++ b/packages/ui/memory-graph/hooks/use-graph-data.ts @@ -1,12 +1,12 @@ -"use client" +"use client"; import { calculateSemanticSimilarity, getConnectionVisualProps, getMagicalConnectionColor, -} from "@repo/lib/similarity" -import { useMemo } from "react" -import { colors, LAYOUT_CONSTANTS } from "../constants" +} from "@repo/lib/similarity"; +import { useMemo } from "react"; +import { colors, LAYOUT_CONSTANTS } from "../constants"; import type { DocumentsResponse, DocumentWithMemories, @@ -14,7 +14,7 @@ import type { GraphNode, MemoryEntry, MemoryRelation, -} from "../types" +} from "../types"; export function useGraphData( data: DocumentsResponse | null, @@ -23,10 +23,10 @@ export function useGraphData( draggingNodeId: string | null, ) { return useMemo(() => { - if (!data?.documents) return { nodes: [], edges: [] } + if (!data?.documents) return { nodes: [], edges: [] }; - const allNodes: GraphNode[] = [] - const allEdges: GraphEdge[] = [] + const allNodes: GraphNode[] = []; + const allEdges: GraphEdge[] = []; // Filter documents that have memories in selected space const filteredDocuments = data.documents @@ -41,68 +41,68 @@ export function useGraphData( selectedSpace, ), })) - .filter((doc) => doc.memoryEntries.length > 0) + .filter((doc) => doc.memoryEntries.length > 0); // Group documents by space for better clustering - const documentsBySpace = new Map<string, typeof filteredDocuments>() + const documentsBySpace = new Map<string, typeof filteredDocuments>(); filteredDocuments.forEach((doc) => { const docSpace = doc.memoryEntries[0]?.spaceContainerTag ?? doc.memoryEntries[0]?.spaceId ?? - "default" + "default"; if (!documentsBySpace.has(docSpace)) { - documentsBySpace.set(docSpace, []) + documentsBySpace.set(docSpace, []); } - const spaceDocsArr = documentsBySpace.get(docSpace) + const spaceDocsArr = documentsBySpace.get(docSpace); if (spaceDocsArr) { - spaceDocsArr.push(doc) + spaceDocsArr.push(doc); } - }) + }); // Enhanced Layout with Space Separation const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } = - LAYOUT_CONSTANTS + LAYOUT_CONSTANTS; /* 1. Build DOCUMENT nodes with space-aware clustering */ - const documentNodes: GraphNode[] = [] - let spaceIndex = 0 + const documentNodes: GraphNode[] = []; + let spaceIndex = 0; documentsBySpace.forEach((spaceDocs) => { - const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2 - const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing - const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing - const spaceCenterX = centerX + spaceOffsetX - const spaceCenterY = centerY + spaceOffsetY + const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2; + const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing; + const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing; + const spaceCenterX = centerX + spaceOffsetX; + const spaceCenterY = centerY + spaceOffsetY; spaceDocs.forEach((doc, docIndex) => { // Create proper circular layout with concentric rings - const docsPerRing = 6 // Start with 6 docs in inner ring - let currentRing = 0 - let docsInCurrentRing = docsPerRing - let totalDocsInPreviousRings = 0 + const docsPerRing = 6; // Start with 6 docs in inner ring + let currentRing = 0; + let docsInCurrentRing = docsPerRing; + let totalDocsInPreviousRings = 0; // Find which ring this document belongs to while (totalDocsInPreviousRings + docsInCurrentRing <= docIndex) { - totalDocsInPreviousRings += docsInCurrentRing - currentRing++ - docsInCurrentRing = docsPerRing + currentRing * 4 // Each ring has more docs + totalDocsInPreviousRings += docsInCurrentRing; + currentRing++; + docsInCurrentRing = docsPerRing + currentRing * 4; // Each ring has more docs } // Position within the ring - const positionInRing = docIndex - totalDocsInPreviousRings - const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2 + const positionInRing = docIndex - totalDocsInPreviousRings; + const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2; // Radius increases significantly with each ring - const baseRadius = documentSpacing * 0.8 + const baseRadius = documentSpacing * 0.8; const radius = currentRing === 0 ? baseRadius - : baseRadius + currentRing * documentSpacing * 1.2 + : baseRadius + currentRing * documentSpacing * 1.2; - const defaultX = spaceCenterX + Math.cos(angleInRing) * radius - const defaultY = spaceCenterY + Math.sin(angleInRing) * radius + const defaultX = spaceCenterX + Math.cos(angleInRing) * radius; + const defaultY = spaceCenterY + Math.sin(angleInRing) * radius; - const customPos = nodePositions.get(doc.id) + const customPos = nodePositions.get(doc.id); documentNodes.push({ id: doc.id, @@ -114,80 +114,81 @@ export function useGraphData( color: colors.document.primary, isHovered: false, isDragging: draggingNodeId === doc.id, - } satisfies GraphNode) - }) + } satisfies GraphNode); + }); - spaceIndex++ - }) + spaceIndex++; + }); /* 2. Gentle document collision avoidance with dampening */ - const minDocDist = LAYOUT_CONSTANTS.minDocDist + const minDocDist = LAYOUT_CONSTANTS.minDocDist; // Reduced iterations and gentler repulsion for smoother movement for (let iter = 0; iter < 2; iter++) { documentNodes.forEach((nodeA) => { documentNodes.forEach((nodeB) => { - if (nodeA.id >= nodeB.id) return + if (nodeA.id >= nodeB.id) return; // Only repel documents in the same space const spaceA = (nodeA.data as DocumentWithMemories).memoryEntries[0] ?.spaceContainerTag ?? (nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ?? - "default" + "default"; const spaceB = (nodeB.data as DocumentWithMemories).memoryEntries[0] ?.spaceContainerTag ?? (nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ?? - "default" + "default"; - if (spaceA !== spaceB) return + if (spaceA !== spaceB) return; - const dx = nodeB.x - nodeA.x - const dy = nodeB.y - nodeA.y - const dist = Math.sqrt(dx * dx + dy * dy) || 1 + const dx = nodeB.x - nodeA.x; + const dy = nodeB.y - nodeA.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; if (dist < minDocDist) { // Much gentler push with dampening - const push = (minDocDist - dist) / 8 - const dampening = Math.max(0.1, Math.min(1, dist / minDocDist)) - const smoothPush = push * dampening * 0.5 - - const nx = dx / dist - const ny = dy / dist - nodeA.x -= nx * smoothPush - nodeA.y -= ny * smoothPush - nodeB.x += nx * smoothPush - nodeB.y += ny * smoothPush + const push = (minDocDist - dist) / 8; + const dampening = Math.max(0.1, Math.min(1, dist / minDocDist)); + const smoothPush = push * dampening * 0.5; + + const nx = dx / dist; + const ny = dy / dist; + nodeA.x -= nx * smoothPush; + nodeA.y -= ny * smoothPush; + nodeB.x += nx * smoothPush; + nodeB.y += ny * smoothPush; } - }) - }) + }); + }); } - allNodes.push(...documentNodes) + allNodes.push(...documentNodes); /* 3. Add memories around documents WITH doc-memory connections */ documentNodes.forEach((docNode) => { - const memoryNodeMap = new Map<string, GraphNode>() - const doc = docNode.data as DocumentWithMemories + const memoryNodeMap = new Map<string, GraphNode>(); + const doc = docNode.data as DocumentWithMemories; doc.memoryEntries.forEach((memory, memIndex) => { - const memoryId = `${memory.id}` - const customMemPos = nodePositions.get(memoryId) + const memoryId = `${memory.id}`; + const customMemPos = nodePositions.get(memoryId); - const clusterAngle = (memIndex / doc.memoryEntries.length) * Math.PI * 2 - const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7 - const distance = clusterRadius * variation + const clusterAngle = + (memIndex / doc.memoryEntries.length) * Math.PI * 2; + const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7; + const distance = clusterRadius * variation; const seed = - memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36) - const offsetX = Math.sin(seed) * 0.5 * 40 - const offsetY = Math.cos(seed) * 0.5 * 40 + memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36); + const offsetX = Math.sin(seed) * 0.5 * 40; + const offsetY = Math.cos(seed) * 0.5 * 40; const defaultMemX = - docNode.x + Math.cos(clusterAngle) * distance + offsetX + docNode.x + Math.cos(clusterAngle) * distance + offsetX; const defaultMemY = - docNode.y + Math.sin(clusterAngle) * distance + offsetY + docNode.y + Math.sin(clusterAngle) * distance + offsetY; if (!memoryNodeMap.has(memoryId)) { const memoryNode: GraphNode = { @@ -203,9 +204,9 @@ export function useGraphData( color: colors.memory.primary, isHovered: false, isDragging: draggingNodeId === memoryId, - } - memoryNodeMap.set(memoryId, memoryNode) - allNodes.push(memoryNode) + }; + memoryNodeMap.set(memoryId, memoryNode); + allNodes.push(memoryNode); } // Create doc-memory edge with similarity @@ -217,35 +218,39 @@ export function useGraphData( visualProps: getConnectionVisualProps(1), color: colors.connection.memory, edgeType: "doc-memory", - }) - }) - }) + }); + }); + }); // Build mapping of memoryId -> nodeId for version chains - const memNodeIdMap = new Map<string, string>() + const memNodeIdMap = new Map<string, string>(); allNodes.forEach((n) => { if (n.type === "memory") { - memNodeIdMap.set((n.data as MemoryEntry).id, n.id) + memNodeIdMap.set((n.data as MemoryEntry).id, n.id); } - }) + }); // Add version-chain edges (old -> new) data.documents.forEach((doc) => { doc.memoryEntries.forEach((mem: MemoryEntry) => { // Support both new object structure and legacy array/single parent fields - let parentRelations: Record<string, MemoryRelation> = {} - - if (mem.memoryRelations && typeof mem.memoryRelations === 'object' && Object.keys(mem.memoryRelations).length > 0) { - parentRelations = mem.memoryRelations + let parentRelations: Record<string, MemoryRelation> = {}; + + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + parentRelations = mem.memoryRelations; } else if (mem.parentMemoryId) { parentRelations = { [mem.parentMemoryId]: "updates" as MemoryRelation, - } + }; } Object.entries(parentRelations).forEach(([pid, relationType]) => { - const fromId = memNodeIdMap.get(pid) - const toId = memNodeIdMap.get(mem.id) - if (fromId && toId) { + const fromId = memNodeIdMap.get(pid); + const toId = memNodeIdMap.get(mem.id); + if (fromId && toId) { allEdges.push({ id: `version-${fromId}-${toId}`, source: fromId, @@ -261,25 +266,25 @@ export function useGraphData( color: colors.relations[relationType] ?? colors.relations.updates, edgeType: "version", relationType: relationType as MemoryRelation, - }) + }); } - }) - }) - }) + }); + }); + }); // Document-to-document similarity edges for (let i = 0; i < filteredDocuments.length; i++) { - const docI = filteredDocuments[i] - if (!docI) continue + const docI = filteredDocuments[i]; + if (!docI) continue; for (let j = i + 1; j < filteredDocuments.length; j++) { - const docJ = filteredDocuments[j] - if (!docJ) continue + const docJ = filteredDocuments[j]; + if (!docJ) continue; const sim = calculateSemanticSimilarity( docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null, docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null, - ) + ); if (sim > 0.725) { allEdges.push({ id: `doc-doc-${docI.id}-${docJ.id}`, @@ -289,11 +294,11 @@ export function useGraphData( visualProps: getConnectionVisualProps(sim), color: getMagicalConnectionColor(sim, 200), edgeType: "doc-doc", - }) + }); } } } - return { nodes: allNodes, edges: allEdges } - }, [data, selectedSpace, nodePositions, draggingNodeId]) + return { nodes: allNodes, edges: allEdges }; + }, [data, selectedSpace, nodePositions, draggingNodeId]); } diff --git a/packages/ui/memory-graph/hooks/use-graph-interactions.ts b/packages/ui/memory-graph/hooks/use-graph-interactions.ts index 3d599765..62216068 100644 --- a/packages/ui/memory-graph/hooks/use-graph-interactions.ts +++ b/packages/ui/memory-graph/hooks/use-graph-interactions.ts @@ -1,231 +1,231 @@ -"use client" +"use client"; -import { useCallback, useState } from "react" -import { GRAPH_SETTINGS } from "../constants" -import type { GraphNode } from "../types" +import { useCallback, useState } from "react"; +import { GRAPH_SETTINGS } from "../constants"; +import type { GraphNode } from "../types"; export function useGraphInteractions( variant: "console" | "consumer" = "console", ) { - const settings = GRAPH_SETTINGS[variant] - - const [panX, setPanX] = useState(settings.initialPanX) - const [panY, setPanY] = useState(settings.initialPanY) - const [zoom, setZoom] = useState(settings.initialZoom) - const [isPanning, setIsPanning] = useState(false) - const [panStart, setPanStart] = useState({ x: 0, y: 0 }) - const [hoveredNode, setHoveredNode] = useState<string | null>(null) - const [selectedNode, setSelectedNode] = useState<string | null>(null) - const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null) + const settings = GRAPH_SETTINGS[variant]; + + const [panX, setPanX] = useState(settings.initialPanX); + const [panY, setPanY] = useState(settings.initialPanY); + const [zoom, setZoom] = useState(settings.initialZoom); + const [isPanning, setIsPanning] = useState(false); + const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [hoveredNode, setHoveredNode] = useState<string | null>(null); + const [selectedNode, setSelectedNode] = useState<string | null>(null); + const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null); const [dragStart, setDragStart] = useState({ x: 0, y: 0, nodeX: 0, nodeY: 0, - }) + }); const [nodePositions, setNodePositions] = useState< Map<string, { x: number; y: number }> - >(new Map()) + >(new Map()); // Node drag handlers const handleNodeDragStart = useCallback( (nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => { - const node = nodes?.find((n) => n.id === nodeId) - if (!node) return + const node = nodes?.find((n) => n.id === nodeId); + if (!node) return; - setDraggingNodeId(nodeId) + setDraggingNodeId(nodeId); setDragStart({ x: e.clientX, y: e.clientY, nodeX: node.x, nodeY: node.y, - }) + }); }, [], - ) + ); const handleNodeDragMove = useCallback( (e: React.MouseEvent) => { - if (!draggingNodeId) return + if (!draggingNodeId) return; - const deltaX = (e.clientX - dragStart.x) / zoom - const deltaY = (e.clientY - dragStart.y) / zoom + const deltaX = (e.clientX - dragStart.x) / zoom; + const deltaY = (e.clientY - dragStart.y) / zoom; - const newX = dragStart.nodeX + deltaX - const newY = dragStart.nodeY + deltaY + const newX = dragStart.nodeX + deltaX; + const newY = dragStart.nodeY + deltaY; setNodePositions((prev) => new Map(prev).set(draggingNodeId, { x: newX, y: newY }), - ) + ); }, [draggingNodeId, dragStart, zoom], - ) + ); const handleNodeDragEnd = useCallback(() => { - setDraggingNodeId(null) - }, []) + setDraggingNodeId(null); + }, []); // Pan handlers const handlePanStart = useCallback( (e: React.MouseEvent) => { - setIsPanning(true) - setPanStart({ x: e.clientX - panX, y: e.clientY - panY }) + setIsPanning(true); + setPanStart({ x: e.clientX - panX, y: e.clientY - panY }); }, [panX, panY], - ) + ); const handlePanMove = useCallback( (e: React.MouseEvent) => { - if (!isPanning || draggingNodeId) return + if (!isPanning || draggingNodeId) return; - const newPanX = e.clientX - panStart.x - const newPanY = e.clientY - panStart.y - setPanX(newPanX) - setPanY(newPanY) + const newPanX = e.clientX - panStart.x; + const newPanY = e.clientY - panStart.y; + setPanX(newPanX); + setPanY(newPanY); }, [isPanning, panStart, draggingNodeId], - ) + ); const handlePanEnd = useCallback(() => { - setIsPanning(false) - }, []) + setIsPanning(false); + }, []); // Zoom handlers const handleWheel = useCallback((e: React.WheelEvent) => { - e.preventDefault() - const delta = e.deltaY > 0 ? 0.97 : 1.03 - setZoom((prev) => Math.max(0.1, Math.min(2, prev * delta))) - }, []) + e.preventDefault(); + const delta = e.deltaY > 0 ? 0.97 : 1.03; + setZoom((prev) => Math.max(0.1, Math.min(2, prev * delta))); + }, []); const zoomIn = useCallback(() => { - setZoom((prev) => Math.min(2, prev * 1.1)) - }, []) + setZoom((prev) => Math.min(2, prev * 1.1)); + }, []); const zoomOut = useCallback(() => { - setZoom((prev) => Math.max(0.1, prev / 1.1)) - }, []) + setZoom((prev) => Math.max(0.1, prev / 1.1)); + }, []); const resetView = useCallback(() => { - setPanX(settings.initialPanX) - setPanY(settings.initialPanY) - setZoom(settings.initialZoom) - setNodePositions(new Map()) - }, [settings]) + setPanX(settings.initialPanX); + setPanY(settings.initialPanY); + setZoom(settings.initialZoom); + setNodePositions(new Map()); + }, [settings]); // Auto-fit graph to viewport - const autoFitToViewport = useCallback( - ( - nodes: GraphNode[], - viewportWidth: number, - viewportHeight: number, - options?: { occludedRightPx?: number; animate?: boolean }, - ) => { - if (nodes.length === 0) return + const autoFitToViewport = useCallback( + ( + nodes: GraphNode[], + viewportWidth: number, + viewportHeight: number, + options?: { occludedRightPx?: number; animate?: boolean }, + ) => { + if (nodes.length === 0) return; // Find the bounds of all nodes let minX = Number.POSITIVE_INFINITY, - maxX = Number.NEGATIVE_INFINITY + maxX = Number.NEGATIVE_INFINITY; let minY = Number.POSITIVE_INFINITY, - maxY = Number.NEGATIVE_INFINITY + maxY = Number.NEGATIVE_INFINITY; nodes.forEach((node) => { - minX = Math.min(minX, node.x - node.size / 2) - maxX = Math.max(maxX, node.x + node.size / 2) - minY = Math.min(minY, node.y - node.size / 2) - maxY = Math.max(maxY, node.y + node.size / 2) - }) + minX = Math.min(minX, node.x - node.size / 2); + maxX = Math.max(maxX, node.x + node.size / 2); + minY = Math.min(minY, node.y - node.size / 2); + maxY = Math.max(maxY, node.y + node.size / 2); + }); // Calculate the center of the content - const contentCenterX = (minX + maxX) / 2 - const contentCenterY = (minY + maxY) / 2 + const contentCenterX = (minX + maxX) / 2; + const contentCenterY = (minY + maxY) / 2; // Calculate the size of the content - const contentWidth = maxX - minX - const contentHeight = maxY - minY - - // Add padding (20% on each side) - const paddingFactor = 1.4 - const paddedWidth = contentWidth * paddingFactor - const paddedHeight = contentHeight * paddingFactor - - // Account for occluded area on the right (e.g., chat panel) - const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0) - const availableWidth = Math.max(1, viewportWidth - occludedRightPx) - - // Calculate the zoom needed to fit the content within available width - const zoomX = availableWidth / paddedWidth - const zoomY = viewportHeight / paddedHeight - const newZoom = Math.min(Math.max(0.1, Math.min(zoomX, zoomY)), 2) - - // Calculate pan to center the content within available area - const availableCenterX = (availableWidth / 2) - const newPanX = availableCenterX - contentCenterX * newZoom - const newPanY = viewportHeight / 2 - contentCenterY * newZoom - - // Apply the new view (optional animation) - if (options?.animate) { - const steps = 8 - const durationMs = 160 // snappy - const intervalMs = Math.max(1, Math.floor(durationMs / steps)) - const startZoom = zoom - const startPanX = panX - const startPanY = panY - let i = 0 - const ease = (t: number) => 1 - Math.pow(1 - t, 2) // ease-out quad - const timer = setInterval(() => { - i++ - const t = ease(i / steps) - setZoom(startZoom + (newZoom - startZoom) * t) - setPanX(startPanX + (newPanX - startPanX) * t) - setPanY(startPanY + (newPanY - startPanY) * t) - if (i >= steps) clearInterval(timer) - }, intervalMs) - } else { - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) - } + const contentWidth = maxX - minX; + const contentHeight = maxY - minY; + + // Add padding (20% on each side) + const paddingFactor = 1.4; + const paddedWidth = contentWidth * paddingFactor; + const paddedHeight = contentHeight * paddingFactor; + + // Account for occluded area on the right (e.g., chat panel) + const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0); + const availableWidth = Math.max(1, viewportWidth - occludedRightPx); + + // Calculate the zoom needed to fit the content within available width + const zoomX = availableWidth / paddedWidth; + const zoomY = viewportHeight / paddedHeight; + const newZoom = Math.min(Math.max(0.1, Math.min(zoomX, zoomY)), 2); + + // Calculate pan to center the content within available area + const availableCenterX = availableWidth / 2; + const newPanX = availableCenterX - contentCenterX * newZoom; + const newPanY = viewportHeight / 2 - contentCenterY * newZoom; + + // Apply the new view (optional animation) + if (options?.animate) { + const steps = 8; + const durationMs = 160; // snappy + const intervalMs = Math.max(1, Math.floor(durationMs / steps)); + const startZoom = zoom; + const startPanX = panX; + const startPanY = panY; + let i = 0; + const ease = (t: number) => 1 - (1 - t) ** 2; // ease-out quad + const timer = setInterval(() => { + i++; + const t = ease(i / steps); + setZoom(startZoom + (newZoom - startZoom) * t); + setPanX(startPanX + (newPanX - startPanX) * t); + setPanY(startPanY + (newPanY - startPanY) * t); + if (i >= steps) clearInterval(timer); + }, intervalMs); + } else { + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); + } }, - [zoom, panX, panY], - ) + [zoom, panX, panY], + ); // Node interaction handlers const handleNodeHover = useCallback((nodeId: string | null) => { - setHoveredNode(nodeId) - }, []) + setHoveredNode(nodeId); + }, []); const handleNodeClick = useCallback( (nodeId: string) => { - setSelectedNode(selectedNode === nodeId ? null : nodeId) + setSelectedNode(selectedNode === nodeId ? null : nodeId); }, [selectedNode], - ) + ); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { - const canvas = e.currentTarget as HTMLCanvasElement - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top + const canvas = e.currentTarget as HTMLCanvasElement; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; // Calculate new zoom (zoom in by 1.5x) - const zoomFactor = 1.5 - const newZoom = Math.min(2, zoom * zoomFactor) + const zoomFactor = 1.5; + const newZoom = Math.min(2, zoom * zoomFactor); // Calculate the world position of the clicked point - const worldX = (x - panX) / zoom - const worldY = (y - panY) / zoom + const worldX = (x - panX) / zoom; + const worldY = (y - panY) / zoom; // Calculate new pan to keep the clicked point in the same screen position - const newPanX = x - worldX * newZoom - const newPanY = y - worldY * newZoom + const newPanX = x - worldX * newZoom; + const newPanY = y - worldY * newZoom; - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) + setZoom(newZoom); + setPanX(newPanX); + setPanY(newPanY); }, [zoom, panX, panY], - ) + ); return { // State @@ -253,5 +253,5 @@ export function useGraphInteractions( resetView, autoFitToViewport, setSelectedNode, - } + }; } diff --git a/packages/ui/memory-graph/legend.tsx b/packages/ui/memory-graph/legend.tsx index b65881ff..81c634f3 100644 --- a/packages/ui/memory-graph/legend.tsx +++ b/packages/ui/memory-graph/legend.tsx @@ -1,45 +1,45 @@ -"use client" +"use client"; -import { useIsMobile } from "@hooks/use-mobile" -import { cn } from "@repo/lib/utils" +import { useIsMobile } from "@hooks/use-mobile"; +import { cn } from "@repo/lib/utils"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, -} from "@repo/ui/components/collapsible" -import { GlassMenuEffect } from "@repo/ui/other/glass-effect" -import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react" -import { memo, useEffect, useState } from "react" -import { colors } from "./constants" -import type { GraphEdge, GraphNode, LegendProps } from "./types" +} from "@repo/ui/components/collapsible"; +import { GlassMenuEffect } from "@repo/ui/other/glass-effect"; +import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react"; +import { memo, useEffect, useState } from "react"; +import { colors } from "./constants"; +import type { GraphEdge, GraphNode, LegendProps } from "./types"; // Cookie utility functions for legend state const setCookie = (name: string, value: string, days = 365) => { - if (typeof document === "undefined") return - const expires = new Date() - expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000) - document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/` -} + if (typeof document === "undefined") return; + const expires = new Date(); + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`; +}; const getCookie = (name: string): string | null => { - if (typeof document === "undefined") return null - const nameEQ = `${name}=` - const ca = document.cookie.split(";") + if (typeof document === "undefined") return null; + const nameEQ = `${name}=`; + const ca = document.cookie.split(";"); for (let i = 0; i < ca.length; i++) { - let c = ca[i] - if (!c) continue - while (c.charAt(0) === " ") c = c.substring(1, c.length) - if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length) + let c = ca[i]; + if (!c) continue; + while (c.charAt(0) === " ") c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); } - return null -} + return null; +}; interface ExtendedLegendProps extends LegendProps { - id?: string - nodes?: GraphNode[] - edges?: GraphEdge[] - isLoading?: boolean - isExperimental?: boolean + id?: string; + nodes?: GraphNode[]; + edges?: GraphEdge[]; + isLoading?: boolean; + isExperimental?: boolean; } export const Legend = memo(function Legend({ @@ -50,63 +50,63 @@ export const Legend = memo(function Legend({ isLoading = false, isExperimental = false, }: ExtendedLegendProps) { - const isMobile = useIsMobile() - const [isExpanded, setIsExpanded] = useState(true) - const [isInitialized, setIsInitialized] = useState(false) + const isMobile = useIsMobile(); + const [isExpanded, setIsExpanded] = useState(true); + const [isInitialized, setIsInitialized] = useState(false); - const relationData = isExperimental ? [ - ["updates", colors.relations.updates], - ["extends", colors.relations.extends], - ["derives", colors.relations.derives], - ] : [ - ["updates", colors.relations.updates], - ] + const relationData = isExperimental + ? [ + ["updates", colors.relations.updates], + ["extends", colors.relations.extends], + ["derives", colors.relations.derives], + ] + : [["updates", colors.relations.updates]]; // Load saved preference on client side useEffect(() => { if (!isInitialized) { - const savedState = getCookie("legendCollapsed") + const savedState = getCookie("legendCollapsed"); if (savedState === "true") { - setIsExpanded(false) + setIsExpanded(false); } else if (savedState === "false") { - setIsExpanded(true) + setIsExpanded(true); } else { // Default: collapsed on mobile, expanded on desktop - setIsExpanded(!isMobile) + setIsExpanded(!isMobile); } - setIsInitialized(true) + setIsInitialized(true); } - }, [isInitialized, isMobile]) + }, [isInitialized, isMobile]); // Save to cookie when state changes const handleToggleExpanded = (expanded: boolean) => { - setIsExpanded(expanded) - setCookie("legendCollapsed", expanded ? "false" : "true") - } + setIsExpanded(expanded); + setCookie("legendCollapsed", expanded ? "false" : "true"); + }; // Use explicit classes that Tailwind can detect const getPositioningClasses = () => { if (variant === "console") { // Both desktop and mobile use same positioning for console - return "bottom-4 right-4" + return "bottom-4 right-4"; } if (variant === "consumer") { - return isMobile ? "bottom-48 left-4" : "top-18 right-4" + return isMobile ? "bottom-48 left-4" : "top-18 right-4"; } - return "" - } + return ""; + }; const getMobileSize = () => { - if (!isMobile) return "" - return isExpanded ? "max-w-xs" : "w-16 h-12" - } + if (!isMobile) return ""; + return isExpanded ? "max-w-xs" : "w-16 h-12"; + }; const hexagonClipPath = - "polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)" + "polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)"; // Calculate stats - const memoryCount = nodes.filter((n) => n.type === "memory").length - const documentCount = nodes.filter((n) => n.type === "document").length + const memoryCount = nodes.filter((n) => n.type === "memory").length; + const documentCount = nodes.filter((n) => n.type === "document").length; return ( <div @@ -114,7 +114,7 @@ export const Legend = memo(function Legend({ "absolute z-10 rounded-xl overflow-hidden w-fit h-fit", getPositioningClasses(), getMobileSize(), - isMobile && "hidden md:block" + isMobile && "hidden md:block", )} id={id} > @@ -271,13 +271,14 @@ export const Legend = memo(function Legend({ Relations </div> <div className="space-y-1.5"> - {(isExperimental ? [ - ["updates", colors.relations.updates], - ["extends", colors.relations.extends], - ["derives", colors.relations.derives], - ] : [ - ["updates", colors.relations.updates], - ]).map(([label, color]) => ( + {(isExperimental + ? [ + ["updates", colors.relations.updates], + ["extends", colors.relations.extends], + ["derives", colors.relations.derives], + ] + : [["updates", colors.relations.updates]] + ).map(([label, color]) => ( <div className="flex items-center gap-2" key={label}> <div className="w-4 h-0 border-t-2 flex-shrink-0" @@ -317,7 +318,7 @@ export const Legend = memo(function Legend({ </div> </Collapsible> </div> - ) -}) + ); +}); -Legend.displayName = "Legend" +Legend.displayName = "Legend"; diff --git a/packages/ui/memory-graph/loading-indicator.tsx b/packages/ui/memory-graph/loading-indicator.tsx index 924ec487..f4a1930a 100644 --- a/packages/ui/memory-graph/loading-indicator.tsx +++ b/packages/ui/memory-graph/loading-indicator.tsx @@ -1,20 +1,20 @@ -"use client" +"use client"; -import { cn } from "@repo/lib/utils" -import { GlassMenuEffect } from "@repo/ui/other/glass-effect" -import { Sparkles } from "lucide-react" -import { memo } from "react" -import type { LoadingIndicatorProps } from "./types" +import { cn } from "@repo/lib/utils"; +import { GlassMenuEffect } from "@repo/ui/other/glass-effect"; +import { Sparkles } from "lucide-react"; +import { memo } from "react"; +import type { LoadingIndicatorProps } from "./types"; export const LoadingIndicator = memo<LoadingIndicatorProps>( ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => { // Use explicit classes that Tailwind can detect const getPositioningClasses = () => { // Both variants use the same positioning for loadingIndicator - return "top-20 left-4" - } + return "top-20 left-4"; + }; - if (!isLoading && !isLoadingMore) return null + if (!isLoading && !isLoadingMore) return null; return ( <div @@ -37,8 +37,8 @@ export const LoadingIndicator = memo<LoadingIndicatorProps>( </div> </div> </div> - ) + ); }, -) +); -LoadingIndicator.displayName = "LoadingIndicator" +LoadingIndicator.displayName = "LoadingIndicator"; diff --git a/packages/ui/memory-graph/memory-graph.tsx b/packages/ui/memory-graph/memory-graph.tsx index 7a870ce7..75ada513 100644 --- a/packages/ui/memory-graph/memory-graph.tsx +++ b/packages/ui/memory-graph/memory-graph.tsx @@ -1,18 +1,18 @@ -"use client" - -import { GlassMenuEffect } from "@repo/ui/other/glass-effect" -import { AnimatePresence } from "motion/react" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { colors } from "./constants" -import { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas" -import { useGraphData } from "./hooks/use-graph-data" -import { useGraphInteractions } from "./hooks/use-graph-interactions" -import { Legend } from "./legend" -import { LoadingIndicator } from "./loading-indicator" -import { NodeDetailPanel } from "./node-detail-panel" -import { SpacesDropdown } from "./spaces-dropdown" - -import type { MemoryGraphProps } from "./types" +"use client"; + +import { GlassMenuEffect } from "@repo/ui/other/glass-effect"; +import { AnimatePresence } from "motion/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { colors } from "./constants"; +import { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas"; +import { useGraphData } from "./hooks/use-graph-data"; +import { useGraphInteractions } from "./hooks/use-graph-interactions"; +import { Legend } from "./legend"; +import { LoadingIndicator } from "./loading-indicator"; +import { NodeDetailPanel } from "./node-detail-panel"; +import { SpacesDropdown } from "./spaces-dropdown"; + +import type { MemoryGraphProps } from "./types"; export const MemoryGraph = ({ children, @@ -32,24 +32,24 @@ export const MemoryGraph = ({ autoLoadOnViewport = true, isExperimental = false, }: MemoryGraphProps) => { - const [selectedSpace, setSelectedSpace] = useState<string>("all") - const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) - const containerRef = useRef<HTMLDivElement>(null) + const [selectedSpace, setSelectedSpace] = useState<string>("all"); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const containerRef = useRef<HTMLDivElement>(null); // Create data object with dummy pagination to satisfy type requirements const data = useMemo(() => { return documents && documents.length > 0 ? { - documents, - pagination: { - currentPage: 1, - limit: documents.length, - totalItems: documents.length, - totalPages: 1, - }, - } - : null - }, [documents]) + documents, + pagination: { + currentPage: 1, + limit: documents.length, + totalItems: documents.length, + totalPages: 1, + }, + } + : null; + }, [documents]); // Graph interactions with variant-specific settings const { @@ -73,7 +73,7 @@ export const MemoryGraph = ({ handleDoubleClick, setSelectedNode, autoFitToViewport, - } = useGraphInteractions(variant) + } = useGraphInteractions(variant); // Graph data const { nodes, edges } = useGraphData( @@ -81,12 +81,14 @@ export const MemoryGraph = ({ selectedSpace, nodePositions, draggingNodeId, - ) + ); // Auto-fit once per unique highlight set to show the full graph for context - const lastFittedHighlightKeyRef = useRef<string>("") + const lastFittedHighlightKeyRef = useRef<string>(""); useEffect(() => { - const highlightKey = highlightsVisible ? highlightDocumentIds.join("|") : "" + const highlightKey = highlightsVisible + ? highlightDocumentIds.join("|") + : ""; if ( highlightKey && highlightKey !== lastFittedHighlightKeyRef.current && @@ -94,13 +96,24 @@ export const MemoryGraph = ({ containerSize.height > 0 && nodes.length > 0 ) { - autoFitToViewport(nodes, containerSize.width, containerSize.height, { occludedRightPx, animate: true }) - lastFittedHighlightKeyRef.current = highlightKey + autoFitToViewport(nodes, containerSize.width, containerSize.height, { + occludedRightPx, + animate: true, + }); + lastFittedHighlightKeyRef.current = highlightKey; } - }, [highlightsVisible, highlightDocumentIds, containerSize.width, containerSize.height, nodes.length, occludedRightPx, autoFitToViewport]) + }, [ + highlightsVisible, + highlightDocumentIds, + containerSize.width, + containerSize.height, + nodes.length, + occludedRightPx, + autoFitToViewport, + ]); // Auto-fit graph when component mounts or nodes change significantly - const hasAutoFittedRef = useRef(false) + const hasAutoFittedRef = useRef(false); useEffect(() => { // Only auto-fit once when we have nodes and container size if ( @@ -111,8 +124,8 @@ export const MemoryGraph = ({ ) { // For consumer variant, auto-fit to show all content if (variant === "consumer") { - autoFitToViewport(nodes, containerSize.width, containerSize.height) - hasAutoFittedRef.current = true + autoFitToViewport(nodes, containerSize.width, containerSize.height); + hasAutoFittedRef.current = true; } } }, [ @@ -121,35 +134,35 @@ export const MemoryGraph = ({ containerSize.height, variant, autoFitToViewport, - ]) + ]); // Reset auto-fit flag when nodes array becomes empty (switching views) useEffect(() => { if (nodes.length === 0) { - hasAutoFittedRef.current = false + hasAutoFittedRef.current = false; } - }, [nodes.length]) + }, [nodes.length]); // Extract unique spaces from memories and calculate counts const { availableSpaces, spaceMemoryCounts } = useMemo(() => { - if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} } + if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} }; - const spaceSet = new Set<string>() - const counts: Record<string, number> = {} + const spaceSet = new Set<string>(); + const counts: Record<string, number> = {}; data.documents.forEach((doc) => { doc.memoryEntries.forEach((memory) => { - const spaceId = memory.spaceContainerTag || memory.spaceId || "default" - spaceSet.add(spaceId) - counts[spaceId] = (counts[spaceId] || 0) + 1 - }) - }) + const spaceId = memory.spaceContainerTag || memory.spaceId || "default"; + spaceSet.add(spaceId); + counts[spaceId] = (counts[spaceId] || 0) + 1; + }); + }); return { availableSpaces: Array.from(spaceSet).sort(), spaceMemoryCounts: counts, - } - }, [data]) + }; + }, [data]); // Handle container resize useEffect(() => { @@ -158,28 +171,28 @@ export const MemoryGraph = ({ setContainerSize({ width: containerRef.current.clientWidth, height: containerRef.current.clientHeight, - }) + }); } - } + }; - updateSize() - window.addEventListener("resize", updateSize) - return () => window.removeEventListener("resize", updateSize) - }, []) + updateSize(); + window.addEventListener("resize", updateSize); + return () => window.removeEventListener("resize", updateSize); + }, []); // Enhanced node drag start that includes nodes data const handleNodeDragStartWithNodes = useCallback( (nodeId: string, e: React.MouseEvent) => { - handleNodeDragStart(nodeId, e, nodes) + handleNodeDragStart(nodeId, e, nodes); }, [handleNodeDragStart, nodes], - ) + ); // Get selected node data const selectedNodeData = useMemo(() => { - if (!selectedNode) return null - return nodes.find((n) => n.id === selectedNode) || null - }, [selectedNode, nodes]) + if (!selectedNode) return null; + return nodes.find((n) => n.id === selectedNode) || null; + }, [selectedNode, nodes]); // Viewport-based loading: load more when most documents are visible (optional) const checkAndLoadMore = useCallback(() => { @@ -189,7 +202,7 @@ export const MemoryGraph = ({ !data?.documents || data.documents.length === 0 ) - return + return; // Calculate viewport bounds const viewportBounds = { @@ -197,26 +210,26 @@ export const MemoryGraph = ({ right: (-panX + containerSize.width) / zoom + 200, top: -panY / zoom - 200, bottom: (-panY + containerSize.height) / zoom + 200, - } + }; // Count visible documents const visibleDocuments = data.documents.filter((doc) => { const docNodes = nodes.filter( (node) => node.type === "document" && node.data.id === doc.id, - ) + ); return docNodes.some( (node) => node.x >= viewportBounds.left && node.x <= viewportBounds.right && node.y >= viewportBounds.top && node.y <= viewportBounds.bottom, - ) - }) + ); + }); // If 80% or more of documents are visible, load more - const visibilityRatio = visibleDocuments.length / data.documents.length + const visibilityRatio = visibleDocuments.length / data.documents.length; if (visibilityRatio >= 0.8) { - loadMoreDocuments() + loadMoreDocuments(); } }, [ isLoadingMore, @@ -229,35 +242,35 @@ export const MemoryGraph = ({ containerSize.height, nodes, loadMoreDocuments, - ]) + ]); // Throttled version to avoid excessive checks - const lastLoadCheckRef = useRef(0) + const lastLoadCheckRef = useRef(0); const throttledCheckAndLoadMore = useCallback(() => { - const now = Date.now() + const now = Date.now(); if (now - lastLoadCheckRef.current > 1000) { // Check at most once per second - lastLoadCheckRef.current = now - checkAndLoadMore() + lastLoadCheckRef.current = now; + checkAndLoadMore(); } - }, [checkAndLoadMore]) + }, [checkAndLoadMore]); // Monitor viewport changes to trigger loading useEffect(() => { - if (!autoLoadOnViewport) return - throttledCheckAndLoadMore() - }, [throttledCheckAndLoadMore, autoLoadOnViewport]) + if (!autoLoadOnViewport) return; + throttledCheckAndLoadMore(); + }, [throttledCheckAndLoadMore, autoLoadOnViewport]); // Initial load trigger when graph is first rendered useEffect(() => { - if (!autoLoadOnViewport) return + if (!autoLoadOnViewport) return; if (data?.documents && data.documents.length > 0 && hasMore) { // Start loading more documents after initial render setTimeout(() => { - throttledCheckAndLoadMore() - }, 500) // Small delay to allow initial layout + throttledCheckAndLoadMore(); + }, 500); // Small delay to allow initial layout } - }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]) + }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]); if (error) { return ( @@ -274,7 +287,7 @@ export const MemoryGraph = ({ </div> </div> </div> - ) + ); } return ( @@ -364,5 +377,5 @@ export const MemoryGraph = ({ )} </div> </div> - ) -} + ); +}; diff --git a/packages/ui/memory-graph/node-detail-panel.tsx b/packages/ui/memory-graph/node-detail-panel.tsx index 5463c025..0fdc4801 100644 --- a/packages/ui/memory-graph/node-detail-panel.tsx +++ b/packages/ui/memory-graph/node-detail-panel.tsx @@ -1,12 +1,12 @@ -"use client" +"use client"; -import { cn } from "@repo/lib/utils" -import { Badge } from "@repo/ui/components/badge" -import { Button } from "@repo/ui/components/button" -import { GlassMenuEffect } from "@repo/ui/other/glass-effect" -import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react" -import { motion } from "motion/react" -import { memo } from "react" +import { cn } from "@repo/lib/utils"; +import { Badge } from "@repo/ui/components/badge"; +import { Button } from "@repo/ui/components/button"; +import { GlassMenuEffect } from "@repo/ui/other/glass-effect"; +import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react"; +import { motion } from "motion/react"; +import { memo } from "react"; import { GoogleDocs, GoogleDrive, @@ -19,73 +19,73 @@ import { NotionDoc, OneDrive, PDF, -} from "../assets/icons" -import { HeadingH3Bold } from "../text/heading/heading-h3-bold" +} from "../assets/icons"; +import { HeadingH3Bold } from "../text/heading/heading-h3-bold"; import type { DocumentWithMemories, MemoryEntry, NodeDetailPanelProps, -} from "./types" +} from "./types"; const formatDocumentType = (type: string) => { // Special case for PDF - if (type.toLowerCase() === "pdf") return "PDF" + if (type.toLowerCase() === "pdf") return "PDF"; // Replace underscores with spaces and capitalize each word return type .split("_") .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" ") -} + .join(" "); +}; const getDocumentIcon = (type: string) => { - const iconProps = { className: "w-5 h-5 text-slate-300" } + const iconProps = { className: "w-5 h-5 text-slate-300" }; switch (type) { case "google_doc": - return <GoogleDocs {...iconProps} /> + return <GoogleDocs {...iconProps} />; case "google_sheet": - return <GoogleSheets {...iconProps} /> + return <GoogleSheets {...iconProps} />; case "google_slide": - return <GoogleSlides {...iconProps} /> + return <GoogleSlides {...iconProps} />; case "google_drive": - return <GoogleDrive {...iconProps} /> + return <GoogleDrive {...iconProps} />; case "notion": case "notion_doc": - return <NotionDoc {...iconProps} /> + return <NotionDoc {...iconProps} />; case "word": case "microsoft_word": - return <MicrosoftWord {...iconProps} /> + return <MicrosoftWord {...iconProps} />; case "excel": case "microsoft_excel": - return <MicrosoftExcel {...iconProps} /> + return <MicrosoftExcel {...iconProps} />; case "powerpoint": case "microsoft_powerpoint": - return <MicrosoftPowerpoint {...iconProps} /> + return <MicrosoftPowerpoint {...iconProps} />; case "onenote": case "microsoft_onenote": - return <MicrosoftOneNote {...iconProps} /> + return <MicrosoftOneNote {...iconProps} />; case "onedrive": - return <OneDrive {...iconProps} /> + return <OneDrive {...iconProps} />; case "pdf": - return <PDF {...iconProps} /> + return <PDF {...iconProps} />; default: - return <FileText {...iconProps} /> + return <FileText {...iconProps} />; } -} +}; export const NodeDetailPanel = memo<NodeDetailPanelProps>( ({ node, onClose, variant = "console" }) => { // Use explicit classes that Tailwind can detect const getPositioningClasses = () => { // Both variants use the same positioning for nodeDetail - return "top-4 right-4" - } + return "top-4 right-4"; + }; - if (!node) return null + if (!node) return null; - const isDocument = node.type === "document" - const data = node.data + const isDocument = node.type === "document"; + const data = node.data; return ( <motion.div @@ -185,17 +185,17 @@ export const NodeDetailPanel = memo<NodeDetailPanelProps>( <a className="text-sm text-indigo-400 hover:text-indigo-300 mt-1 flex items-center gap-1" href={(() => { - const doc = data as DocumentWithMemories + const doc = data as DocumentWithMemories; if (doc.type === "google_doc" && doc.customId) { - return `https://docs.google.com/document/d/${doc.customId}` + return `https://docs.google.com/document/d/${doc.customId}`; } if (doc.type === "google_sheet" && doc.customId) { - return `https://docs.google.com/spreadsheets/d/${doc.customId}` + return `https://docs.google.com/spreadsheets/d/${doc.customId}`; } if (doc.type === "google_slide" && doc.customId) { - return `https://docs.google.com/presentation/d/${doc.customId}` + return `https://docs.google.com/presentation/d/${doc.customId}`; } - return doc.url ?? undefined + return doc.url ?? undefined; })()} rel="noopener noreferrer" target="_blank" @@ -223,10 +223,14 @@ export const NodeDetailPanel = memo<NodeDetailPanelProps>( {(data as MemoryEntry).forgetAfter && ( <p className="text-xs text-slate-400 mt-1"> Expires:{" "} - {(data as MemoryEntry).forgetAfter ? new Date( - (data as MemoryEntry).forgetAfter!, - ).toLocaleDateString() : ''}{" "} - {'forgetReason' in data && data.forgetReason && `- ${data.forgetReason}`} + {(data as MemoryEntry).forgetAfter + ? new Date( + (data as MemoryEntry).forgetAfter!, + ).toLocaleDateString() + : ""}{" "} + {"forgetReason" in data && + data.forgetReason && + `- ${data.forgetReason}`} </p> )} </div> @@ -257,8 +261,8 @@ export const NodeDetailPanel = memo<NodeDetailPanelProps>( </div> </motion.div> </motion.div> - ) + ); }, -) +); -NodeDetailPanel.displayName = "NodeDetailPanel" +NodeDetailPanel.displayName = "NodeDetailPanel"; diff --git a/packages/ui/memory-graph/spaces-dropdown.tsx b/packages/ui/memory-graph/spaces-dropdown.tsx index 484b2486..72d5f261 100644 --- a/packages/ui/memory-graph/spaces-dropdown.tsx +++ b/packages/ui/memory-graph/spaces-dropdown.tsx @@ -1,15 +1,15 @@ -"use client" +"use client"; -import { cn } from "@repo/lib/utils" -import { Badge } from "@repo/ui/components/badge" -import { ChevronDown, Eye } from "lucide-react" -import { memo, useEffect, useRef, useState } from "react" -import type { SpacesDropdownProps } from "./types" +import { cn } from "@repo/lib/utils"; +import { Badge } from "@repo/ui/components/badge"; +import { ChevronDown, Eye } from "lucide-react"; +import { memo, useEffect, useRef, useState } from "react"; +import type { SpacesDropdownProps } from "./types"; export const SpacesDropdown = memo<SpacesDropdownProps>( ({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => { - const [isOpen, setIsOpen] = useState(false) - const dropdownRef = useRef<HTMLDivElement>(null) + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef<HTMLDivElement>(null); // Close dropdown when clicking outside useEffect(() => { @@ -18,18 +18,19 @@ export const SpacesDropdown = memo<SpacesDropdownProps>( dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { - setIsOpen(false) + setIsOpen(false); } - } + }; - document.addEventListener("mousedown", handleClickOutside) - return () => document.removeEventListener("mousedown", handleClickOutside) - }, []) + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + }, []); const totalMemories = Object.values(spaceMemoryCounts).reduce( (sum, count) => sum + count, 0, - ) + ); return ( <div className="relative" ref={dropdownRef}> @@ -77,8 +78,8 @@ export const SpacesDropdown = memo<SpacesDropdownProps>( : "text-slate-200 hover:bg-slate-700/50", )} onClick={() => { - onSpaceChange("all") - setIsOpen(false) + onSpaceChange("all"); + setIsOpen(false); }} type="button" > @@ -97,8 +98,8 @@ export const SpacesDropdown = memo<SpacesDropdownProps>( )} key={space} onClick={() => { - onSpaceChange(space) - setIsOpen(false) + onSpaceChange(space); + setIsOpen(false); }} type="button" > @@ -112,8 +113,8 @@ export const SpacesDropdown = memo<SpacesDropdownProps>( </div> )} </div> - ) + ); }, -) +); -SpacesDropdown.displayName = "SpacesDropdown" +SpacesDropdown.displayName = "SpacesDropdown"; diff --git a/packages/ui/memory-graph/types.ts b/packages/ui/memory-graph/types.ts index 7911482e..f1af3ac2 100644 --- a/packages/ui/memory-graph/types.ts +++ b/packages/ui/memory-graph/types.ts @@ -1,121 +1,121 @@ -import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" -import type { z } from "zod" +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"; +import type { z } from "zod"; export type DocumentsResponse = z.infer< typeof DocumentsWithMemoriesResponseSchema -> -export type DocumentWithMemories = DocumentsResponse["documents"][0] -export type MemoryEntry = DocumentWithMemories["memoryEntries"][0] +>; +export type DocumentWithMemories = DocumentsResponse["documents"][0]; +export type MemoryEntry = DocumentWithMemories["memoryEntries"][0]; export interface GraphNode { - id: string - type: "document" | "memory" - x: number - y: number - data: DocumentWithMemories | MemoryEntry - size: number - color: string - isHovered: boolean - isDragging: boolean + id: string; + type: "document" | "memory"; + x: number; + y: number; + data: DocumentWithMemories | MemoryEntry; + size: number; + color: string; + isHovered: boolean; + isDragging: boolean; } -export type MemoryRelation = "updates" | "extends" | "derives" +export type MemoryRelation = "updates" | "extends" | "derives"; export interface GraphEdge { - id: string - source: string - target: string - similarity: number + id: string; + source: string; + target: string; + similarity: number; visualProps: { - opacity: number - thickness: number - glow: number - pulseDuration: number - } - color: string - edgeType: "doc-memory" | "doc-doc" | "version" - relationType?: MemoryRelation + opacity: number; + thickness: number; + glow: number; + pulseDuration: number; + }; + color: string; + edgeType: "doc-memory" | "doc-doc" | "version"; + relationType?: MemoryRelation; } export interface SpacesDropdownProps { - selectedSpace: string - availableSpaces: string[] - spaceMemoryCounts: Record<string, number> - onSpaceChange: (space: string) => void + selectedSpace: string; + availableSpaces: string[]; + spaceMemoryCounts: Record<string, number>; + onSpaceChange: (space: string) => void; } export interface NodeDetailPanelProps { - node: GraphNode | null - onClose: () => void - variant?: "console" | "consumer" + node: GraphNode | null; + onClose: () => void; + variant?: "console" | "consumer"; } export interface GraphCanvasProps { - nodes: GraphNode[] - edges: GraphEdge[] - panX: number - panY: number - zoom: number - width: number - height: number - onNodeHover: (nodeId: string | null) => void - onNodeClick: (nodeId: string) => void - onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void - onNodeDragMove: (e: React.MouseEvent) => void - onNodeDragEnd: () => void - onPanStart: (e: React.MouseEvent) => void - onPanMove: (e: React.MouseEvent) => void - onPanEnd: () => void - onWheel: (e: React.WheelEvent) => void - onDoubleClick: (e: React.MouseEvent) => void - draggingNodeId: string | null + nodes: GraphNode[]; + edges: GraphEdge[]; + panX: number; + panY: number; + zoom: number; + width: number; + height: number; + onNodeHover: (nodeId: string | null) => void; + onNodeClick: (nodeId: string) => void; + onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void; + onNodeDragMove: (e: React.MouseEvent) => void; + onNodeDragEnd: () => void; + onPanStart: (e: React.MouseEvent) => void; + onPanMove: (e: React.MouseEvent) => void; + onPanEnd: () => void; + onWheel: (e: React.WheelEvent) => void; + onDoubleClick: (e: React.MouseEvent) => void; + draggingNodeId: string | null; // Optional list of document IDs (customId or internal id) to highlight - highlightDocumentIds?: string[] - isExperimental?: boolean + highlightDocumentIds?: string[]; + isExperimental?: boolean; } export interface MemoryGraphProps { - children?: React.ReactNode - documents: DocumentWithMemories[] - isLoading: boolean - isLoadingMore: boolean - error: Error | null - totalLoaded: number - hasMore: boolean - loadMoreDocuments: () => Promise<void> + children?: React.ReactNode; + documents: DocumentWithMemories[]; + isLoading: boolean; + isLoadingMore: boolean; + error: Error | null; + totalLoaded: number; + hasMore: boolean; + loadMoreDocuments: () => Promise<void>; // App-specific props - showSpacesSelector?: boolean // true for console, false for consumer - variant?: "console" | "consumer" // for different positioning and styling - legendId?: string // Optional ID for the legend component + showSpacesSelector?: boolean; // true for console, false for consumer + variant?: "console" | "consumer"; // for different positioning and styling + legendId?: string; // Optional ID for the legend component // Optional document highlight list (document custom IDs) - highlightDocumentIds?: string[] + highlightDocumentIds?: string[]; // Whether highlights are currently visible (e.g., chat open) - highlightsVisible?: boolean + highlightsVisible?: boolean; // Pixels occluded on the right side of the viewport (e.g., chat panel) - occludedRightPx?: number + occludedRightPx?: number; // Whether to auto-load more documents based on viewport visibility - autoLoadOnViewport?: boolean - isExperimental?: boolean + autoLoadOnViewport?: boolean; + isExperimental?: boolean; } export interface LegendProps { - variant?: "console" | "consumer" - nodes?: GraphNode[] - edges?: GraphEdge[] - isLoading?: boolean - hoveredNode?: string | null + variant?: "console" | "consumer"; + nodes?: GraphNode[]; + edges?: GraphEdge[]; + isLoading?: boolean; + hoveredNode?: string | null; } export interface LoadingIndicatorProps { - isLoading: boolean - isLoadingMore: boolean - totalLoaded: number - variant?: "console" | "consumer" + isLoading: boolean; + isLoadingMore: boolean; + totalLoaded: number; + variant?: "console" | "consumer"; } export interface ControlsProps { - onZoomIn: () => void - onZoomOut: () => void - onResetView: () => void - variant?: "console" | "consumer" + onZoomIn: () => void; + onZoomOut: () => void; + onResetView: () => void; + variant?: "console" | "consumer"; } diff --git a/packages/ui/other/anonymous-auth.tsx b/packages/ui/other/anonymous-auth.tsx index 009902f9..32db8664 100644 --- a/packages/ui/other/anonymous-auth.tsx +++ b/packages/ui/other/anonymous-auth.tsx @@ -1,53 +1,53 @@ -"use client" +"use client"; -import { authClient } from "@lib/auth" -import { useRouter } from "next/navigation" -import { useEffect } from "react" +import { authClient } from "@lib/auth"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; export const AnonymousAuth = ({ dashboardPath = "/dashboard", loginPath = "/login", }) => { - const router = useRouter() + const router = useRouter(); useEffect(() => { const createAnonymousSession = async () => { - const session = await authClient.getSession() + const session = await authClient.getSession(); if (!session?.session) { console.debug( "[ANONYMOUS_AUTH] No session found, creating anonymous session...", - ) + ); try { // Create anonymous session - console.debug("[ANONYMOUS_AUTH] Calling signIn.anonymous()...") - const res = await authClient.signIn.anonymous() + console.debug("[ANONYMOUS_AUTH] Calling signIn.anonymous()..."); + const res = await authClient.signIn.anonymous(); if (!res.token) { - throw new Error("Failed to get anonymous token") + throw new Error("Failed to get anonymous token"); } // Get the new session console.debug( "[ANONYMOUS_AUTH] Getting new session with anonymous token...", - ) - const newSession = await authClient.getSession() + ); + const newSession = await authClient.getSession(); - console.debug("[ANONYMOUS_AUTH] New session retrieved:", newSession) + console.debug("[ANONYMOUS_AUTH] New session retrieved:", newSession); if (!newSession?.session || !newSession?.user) { console.error( "[ANONYMOUS_AUTH] Failed to create anonymous session - missing session or user", - ) - throw new Error("Failed to create anonymous session") + ); + throw new Error("Failed to create anonymous session"); } // Get the user's organization console.debug( "[ANONYMOUS_AUTH] Fetching organizations for anonymous user...", - ) - const orgs = await authClient.organization.list() + ); + const orgs = await authClient.organization.list(); console.debug("[ANONYMOUS_AUTH] Organizations retrieved:", { count: orgs?.length || 0, @@ -56,43 +56,43 @@ export const AnonymousAuth = ({ name: o.name, slug: o.slug, })), - }) + }); - const org = orgs?.[0] + const org = orgs?.[0]; if (!org) { console.error( "[ANONYMOUS_AUTH] No organization found for anonymous user", - ) - throw new Error("Failed to get organization for anonymous user") + ); + throw new Error("Failed to get organization for anonymous user"); } // Redirect to the organization dashboard console.debug( `[ANONYMOUS_AUTH] Redirecting anonymous user to /${org.slug}${dashboardPath}`, - ) - router.push(dashboardPath) + ); + router.push(dashboardPath); } catch (error) { console.error( "[ANONYMOUS_AUTH] Anonymous session creation error:", error, - ) + ); console.error("[ANONYMOUS_AUTH] Error details:", { message: error instanceof Error ? error.message : "Unknown error", stack: error instanceof Error ? error.stack : undefined, - }) - router.push(loginPath) + }); + router.push(loginPath); } } else if (session.session) { // Session exists, handle organization routing console.debug( "[ANONYMOUS_AUTH] Session exists, checking organization...", - ) + ); if (!session.session.activeOrganizationId) { console.debug( "[ANONYMOUS_AUTH] No active organization ID, fetching organizations...", - ) - const orgs = await authClient.organization.list() + ); + const orgs = await authClient.organization.list(); console.debug("[ANONYMOUS_AUTH] Organizations for existing user:", { count: orgs?.length || 0, @@ -101,50 +101,50 @@ export const AnonymousAuth = ({ name: o.name, slug: o.slug, })), - }) + }); if (orgs?.[0]) { console.debug( `[ANONYMOUS_AUTH] Setting active organization to ${orgs[0].id}`, - ) + ); await authClient.organization.setActive({ organizationId: orgs[0].id, - }) + }); console.debug( `[ANONYMOUS_AUTH] Redirecting to /${orgs[0].slug}${dashboardPath}`, - ) - router.push(dashboardPath) + ); + router.push(dashboardPath); } } else { console.debug( `[ANONYMOUS_AUTH] Active organization ID: ${session.session.activeOrganizationId}`, - ) + ); console.debug( "[ANONYMOUS_AUTH] Fetching full organization details...", - ) + ); const org = await authClient.organization.getFullOrganization({ query: { organizationId: session.session.activeOrganizationId, }, - }) + }); console.debug("[ANONYMOUS_AUTH] Full organization retrieved:", { id: org.id, name: org.name, slug: org.slug, - }) + }); console.debug( `[ANONYMOUS_AUTH] Redirecting to /${org.slug}${dashboardPath}`, - ) - router.push(dashboardPath) + ); + router.push(dashboardPath); } } - } + }; - createAnonymousSession() - }, [router.push]) + createAnonymousSession(); + }, [router.push]); // Return null as this component only handles the redirect logic - return null -} + return null; +}; diff --git a/packages/ui/other/glass-effect.tsx b/packages/ui/other/glass-effect.tsx index 7f735134..089aa864 100644 --- a/packages/ui/other/glass-effect.tsx +++ b/packages/ui/other/glass-effect.tsx @@ -1,6 +1,6 @@ interface GlassMenuEffectProps { - rounded?: string - className?: string + rounded?: string; + className?: string; } export function GlassMenuEffect({ @@ -14,5 +14,5 @@ export function GlassMenuEffect({ className={`absolute inset-0 backdrop-blur-md bg-white/5 border border-white/10 ${rounded}`} /> </div> - ) + ); } diff --git a/packages/ui/package.json b/packages/ui/package.json index 5785e713..2a1bfd1b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,36 +1,36 @@ { - "name": "@repo/ui", - "version": "0.0.0", - "private": true, - "type": "module", - "dependencies": { - "@pixi/react": "^8.0.3", - "@radix-ui/react-accordion": "^1.2.11", - "@radix-ui/react-alert-dialog": "^1.1.14", - "@radix-ui/react-avatar": "^1.1.10", - "@radix-ui/react-checkbox": "^1.3.2", - "@radix-ui/react-collapsible": "^1.1.11", - "@radix-ui/react-dialog": "^1.1.14", - "@radix-ui/react-dropdown-menu": "^2.1.15", - "@radix-ui/react-label": "^2.1.7", - "@radix-ui/react-popover": "^1.1.14", - "@radix-ui/react-progress": "^1.1.7", - "@radix-ui/react-scroll-area": "^1.2.9", - "@radix-ui/react-select": "^2.2.5", - "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.3", - "@radix-ui/react-tabs": "^1.1.12", - "@radix-ui/react-toggle": "^1.1.9", - "@radix-ui/react-toggle-group": "^1.1.10", - "@radix-ui/react-tooltip": "^1.2.7", - "class-variance-authority": "^0.7.1", - "cmdk": "^1.1.1", - "embla-carousel-react": "^8.6.0", - "lucide-react": "^0.525.0", - "next-themes": "^0.4.6", - "pixi.js": "^8.12.0", - "recharts": "2.15.4", - "sonner": "^2.0.6", - "vaul": "^1.1.2" - } -}
\ No newline at end of file + "name": "@repo/ui", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@pixi/react": "^8.0.3", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-toggle": "^1.1.9", + "@radix-ui/react-toggle-group": "^1.1.10", + "@radix-ui/react-tooltip": "^1.2.7", + "class-variance-authority": "^0.7.1", + "cmdk": "^1.1.1", + "embla-carousel-react": "^8.6.0", + "lucide-react": "^0.525.0", + "next-themes": "^0.4.6", + "pixi.js": "^8.12.0", + "recharts": "2.15.4", + "sonner": "^2.0.6", + "vaul": "^1.1.2" + } +} diff --git a/packages/ui/pages/login.tsx b/packages/ui/pages/login.tsx index 8ad3531b..c14ba4ea 100644 --- a/packages/ui/pages/login.tsx +++ b/packages/ui/pages/login.tsx @@ -1,25 +1,25 @@ -"use client" +"use client"; -import { signIn } from "@lib/auth" -import { usePostHog } from "@lib/posthog" -import { LogoFull } from "@repo/ui/assets/Logo" -import { TextSeparator } from "@repo/ui/components/text-separator" -import { ExternalAuthButton } from "@ui/button/external-auth" -import { Button } from "@ui/components/button" +import { signIn } from "@lib/auth"; +import { usePostHog } from "@lib/posthog"; +import { LogoFull } from "@repo/ui/assets/Logo"; +import { TextSeparator } from "@repo/ui/components/text-separator"; +import { ExternalAuthButton } from "@ui/button/external-auth"; +import { Button } from "@ui/components/button"; import { Carousel, CarouselContent, CarouselItem, -} from "@ui/components/carousel" -import { LabeledInput } from "@ui/input/labeled-input" -import { HeadingH1Medium } from "@ui/text/heading/heading-h1-medium" -import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium" -import { Label1Regular } from "@ui/text/label/label-1-regular" -import { Title1Bold } from "@ui/text/title/title-1-bold" -import Autoplay from "embla-carousel-autoplay" -import Image from "next/image" -import { useRouter, useSearchParams } from "next/navigation" -import { useState } from "react" +} from "@ui/components/carousel"; +import { LabeledInput } from "@ui/input/labeled-input"; +import { HeadingH1Medium } from "@ui/text/heading/heading-h1-medium"; +import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"; +import { Label1Regular } from "@ui/text/label/label-1-regular"; +import { Title1Bold } from "@ui/text/title/title-1-bold"; +import Autoplay from "embla-carousel-autoplay"; +import Image from "next/image"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; export function LoginPage({ heroText = "The unified memory API for the AI era.", @@ -28,74 +28,74 @@ export function LoginPage({ "Trusted by Open Source, enterprise and developers.", ], }) { - const [email, setEmail] = useState("") - const [submittedEmail, setSubmittedEmail] = useState<string | null>(null) - const [isLoading, setIsLoading] = useState(false) - const [isLoadingEmail, setIsLoadingEmail] = useState(false) - const [error, setError] = useState<string | null>(null) - const router = useRouter() + const [email, setEmail] = useState(""); + const [submittedEmail, setSubmittedEmail] = useState<string | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingEmail, setIsLoadingEmail] = useState(false); + const [error, setError] = useState<string | null>(null); + const router = useRouter(); - const posthog = usePostHog() + const posthog = usePostHog(); - const params = useSearchParams() + const params = useSearchParams(); const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault() - setIsLoading(true) - setIsLoadingEmail(true) - setError(null) + e.preventDefault(); + setIsLoading(true); + setIsLoadingEmail(true); + setError(null); // Track login attempt posthog.capture("login_attempt", { method: "magic_link", email_domain: email.split("@")[1] || "unknown", - }) + }); try { await signIn.magicLink({ callbackURL: window.location.origin, email, - }) - setSubmittedEmail(email) + }); + setSubmittedEmail(email); // Track successful magic link send posthog.capture("login_magic_link_sent", { email_domain: email.split("@")[1] || "unknown", - }) + }); } catch (error) { - console.error(error) + console.error(error); // Track login failure posthog.capture("login_failed", { method: "magic_link", error: error instanceof Error ? error.message : "Unknown error", email_domain: email.split("@")[1] || "unknown", - }) + }); setError( error instanceof Error ? error.message : "Failed to send login link. Please try again.", - ) - setIsLoading(false) - setIsLoadingEmail(false) - return + ); + setIsLoading(false); + setIsLoadingEmail(false); + return; } - setIsLoading(false) - setIsLoadingEmail(false) - } + setIsLoading(false); + setIsLoadingEmail(false); + }; const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => { - event.preventDefault() - setIsLoading(true) + event.preventDefault(); + setIsLoading(true); - const formData = new FormData(event.currentTarget) - const token = formData.get("token") as string + const formData = new FormData(event.currentTarget); + const token = formData.get("token") as string; router.push( `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(window.location.host)}`, - ) - } + ); + }; return ( <section className="min-h-screen flex flex-col lg:grid lg:grid-cols-12 items-center justify-center p-4 sm:p-6 md:p-8 lg:px-[5rem] lg:py-[3.125rem] gap-6 lg:gap-[5rem] max-w-[400rem] mx-auto"> @@ -197,8 +197,8 @@ export function LoginPage({ disabled: isLoading, id: "email", onChange: (e) => { - setEmail(e.target.value) - error && setError(null) + setEmail(e.target.value); + error && setError(null); }, required: true, value: email, @@ -256,20 +256,20 @@ export function LoginPage({ authProvider="Google" disabled={isLoading} onClick={() => { - if (isLoading) return - setIsLoading(true) + if (isLoading) return; + setIsLoading(true); posthog.capture("login_attempt", { method: "social", provider: "google", - }) + }); signIn .social({ callbackURL: window.location.origin, provider: "google", }) .finally(() => { - setIsLoading(false) - }) + setIsLoading(false); + }); }} /> ) : null} @@ -309,20 +309,20 @@ export function LoginPage({ authProvider="Github" disabled={isLoading} onClick={() => { - if (isLoading) return - setIsLoading(true) + if (isLoading) return; + setIsLoading(true); posthog.capture("login_attempt", { method: "social", provider: "github", - }) + }); signIn .social({ callbackURL: window.location.origin, provider: "github", }) .finally(() => { - setIsLoading(false) - }) + setIsLoading(false); + }); }} /> ) : null} @@ -350,5 +350,5 @@ export function LoginPage({ </div> )} </section> - ) + ); } diff --git a/packages/ui/text/heading/heading-h1-bold.tsx b/packages/ui/text/heading/heading-h1-bold.tsx index c8019a0b..b76f3b9b 100644 --- a/packages/ui/text/heading/heading-h1-bold.tsx +++ b/packages/ui/text/heading/heading-h1-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function HeadingH1Bold({ className, asChild, ...props }: React.ComponentProps<"h1"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h1" + const Comp = asChild ? Root : "h1"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function HeadingH1Bold({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/heading/heading-h1-medium.tsx b/packages/ui/text/heading/heading-h1-medium.tsx index 52ddda1c..5724e1f1 100644 --- a/packages/ui/text/heading/heading-h1-medium.tsx +++ b/packages/ui/text/heading/heading-h1-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function HeadingH1Medium({ className, asChild, ...props }: React.ComponentProps<"h1"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h1" + const Comp = asChild ? Root : "h1"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function HeadingH1Medium({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/heading/heading-h2-bold.tsx b/packages/ui/text/heading/heading-h2-bold.tsx index 3da9a399..6711de50 100644 --- a/packages/ui/text/heading/heading-h2-bold.tsx +++ b/packages/ui/text/heading/heading-h2-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function HeadingH2Bold({ className, asChild, ...props }: React.ComponentProps<"h2"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h2" + const Comp = asChild ? Root : "h2"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function HeadingH2Bold({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/heading/heading-h2-medium.tsx b/packages/ui/text/heading/heading-h2-medium.tsx index 6324fe86..afac0a42 100644 --- a/packages/ui/text/heading/heading-h2-medium.tsx +++ b/packages/ui/text/heading/heading-h2-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function HeadingH2Medium({ className, asChild, ...props }: React.ComponentProps<"h2"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h2" + const Comp = asChild ? Root : "h2"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function HeadingH2Medium({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/heading/heading-h3-bold.tsx b/packages/ui/text/heading/heading-h3-bold.tsx index bb97b323..be15a33c 100644 --- a/packages/ui/text/heading/heading-h3-bold.tsx +++ b/packages/ui/text/heading/heading-h3-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function HeadingH3Bold({ className, asChild, ...props }: React.ComponentProps<"h3"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h3" + const Comp = asChild ? Root : "h3"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function HeadingH3Bold({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/heading/heading-h3-medium.tsx b/packages/ui/text/heading/heading-h3-medium.tsx index de1a5919..cdaa24a2 100644 --- a/packages/ui/text/heading/heading-h3-medium.tsx +++ b/packages/ui/text/heading/heading-h3-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function HeadingH3Medium({ className, asChild, ...props }: React.ComponentProps<"h3"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h3" + const Comp = asChild ? Root : "h3"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function HeadingH3Medium({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/heading/heading-h4-bold.tsx b/packages/ui/text/heading/heading-h4-bold.tsx index 271e86db..5e99c031 100644 --- a/packages/ui/text/heading/heading-h4-bold.tsx +++ b/packages/ui/text/heading/heading-h4-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function HeadingH4Bold({ className, asChild, ...props }: React.ComponentProps<"h4"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h4" + const Comp = asChild ? Root : "h4"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function HeadingH4Bold({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/heading/heading-h4-medium.tsx b/packages/ui/text/heading/heading-h4-medium.tsx index 83e1ec66..1a536508 100644 --- a/packages/ui/text/heading/heading-h4-medium.tsx +++ b/packages/ui/text/heading/heading-h4-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function HeadingH4Medium({ className, asChild, ...props }: React.ComponentProps<"h4"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h4" + const Comp = asChild ? Root : "h4"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function HeadingH4Medium({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/label/label-1-medium.tsx b/packages/ui/text/label/label-1-medium.tsx index a2aa10fe..e599f3e7 100644 --- a/packages/ui/text/label/label-1-medium.tsx +++ b/packages/ui/text/label/label-1-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Label1Medium({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p" + const Comp = asChild ? Root : "p"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Label1Medium({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/label/label-1-regular.tsx b/packages/ui/text/label/label-1-regular.tsx index e740c754..ad9ea319 100644 --- a/packages/ui/text/label/label-1-regular.tsx +++ b/packages/ui/text/label/label-1-regular.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Label1Regular({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p" + const Comp = asChild ? Root : "p"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Label1Regular({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/label/label-2-medium.tsx b/packages/ui/text/label/label-2-medium.tsx index 0418ae2b..28aff2af 100644 --- a/packages/ui/text/label/label-2-medium.tsx +++ b/packages/ui/text/label/label-2-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Label2Medium({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p" + const Comp = asChild ? Root : "p"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Label2Medium({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/label/label-2-regular.tsx b/packages/ui/text/label/label-2-regular.tsx index f1a5bbe5..0cade081 100644 --- a/packages/ui/text/label/label-2-regular.tsx +++ b/packages/ui/text/label/label-2-regular.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Label2Regular({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p" + const Comp = asChild ? Root : "p"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Label2Regular({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/label/label-3-medium.tsx b/packages/ui/text/label/label-3-medium.tsx index 59fdf71b..247207d6 100644 --- a/packages/ui/text/label/label-3-medium.tsx +++ b/packages/ui/text/label/label-3-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Label3Medium({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p" + const Comp = asChild ? Root : "p"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Label3Medium({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/label/label-3-regular.tsx b/packages/ui/text/label/label-3-regular.tsx index 147c7e23..8095d6ab 100644 --- a/packages/ui/text/label/label-3-regular.tsx +++ b/packages/ui/text/label/label-3-regular.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Label3Regular({ className, asChild, ...props }: React.ComponentProps<"p"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "p" + const Comp = asChild ? Root : "p"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Label3Regular({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/title/title-1-bold.tsx b/packages/ui/text/title/title-1-bold.tsx index 7ae48df6..a87e637b 100644 --- a/packages/ui/text/title/title-1-bold.tsx +++ b/packages/ui/text/title/title-1-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Title1Bold({ className, asChild, ...props }: React.ComponentProps<"h1"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h1" + const Comp = asChild ? Root : "h1"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Title1Bold({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/title/title-1-medium.tsx b/packages/ui/text/title/title-1-medium.tsx index da231407..2ac13520 100644 --- a/packages/ui/text/title/title-1-medium.tsx +++ b/packages/ui/text/title/title-1-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Title1Medium({ className, asChild, ...props }: React.ComponentProps<"h1"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h1" + const Comp = asChild ? Root : "h1"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Title1Medium({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/title/title-2-bold.tsx b/packages/ui/text/title/title-2-bold.tsx index b32dcdbd..38bbe34e 100644 --- a/packages/ui/text/title/title-2-bold.tsx +++ b/packages/ui/text/title/title-2-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Title2Bold({ className, asChild, ...props }: React.ComponentProps<"h2"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h2" + const Comp = asChild ? Root : "h2"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Title2Bold({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/title/title-2-medium.tsx b/packages/ui/text/title/title-2-medium.tsx index d931cff7..c5a5deae 100644 --- a/packages/ui/text/title/title-2-medium.tsx +++ b/packages/ui/text/title/title-2-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Title2Medium({ className, asChild, ...props }: React.ComponentProps<"h2"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h2" + const Comp = asChild ? Root : "h2"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Title2Medium({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/title/title-3-bold.tsx b/packages/ui/text/title/title-3-bold.tsx index 6a4a6008..cf9ab777 100644 --- a/packages/ui/text/title/title-3-bold.tsx +++ b/packages/ui/text/title/title-3-bold.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Title3Bold({ className, asChild, ...props }: React.ComponentProps<"h3"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h3" + const Comp = asChild ? Root : "h3"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Title3Bold({ )} {...props} /> - ) + ); } diff --git a/packages/ui/text/title/title-3-medium.tsx b/packages/ui/text/title/title-3-medium.tsx index 5e1b13f0..f862e618 100644 --- a/packages/ui/text/title/title-3-medium.tsx +++ b/packages/ui/text/title/title-3-medium.tsx @@ -1,12 +1,12 @@ -import { cn } from "@lib/utils" -import { Root } from "@radix-ui/react-slot" +import { cn } from "@lib/utils"; +import { Root } from "@radix-ui/react-slot"; export function Title3Medium({ className, asChild, ...props }: React.ComponentProps<"h3"> & { asChild?: boolean }) { - const Comp = asChild ? Root : "h3" + const Comp = asChild ? Root : "h3"; return ( <Comp className={cn( @@ -15,5 +15,5 @@ export function Title3Medium({ )} {...props} /> - ) + ); } diff --git a/packages/validation/api.ts b/packages/validation/api.ts index 060c3d69..91ac5e09 100644 --- a/packages/validation/api.ts +++ b/packages/validation/api.ts @@ -1,11 +1,11 @@ import { z } from "zod" import "zod-openapi/extend" import { + MetadataSchema as BaseMetadataSchema, DocumentSchema, MemoryEntrySchema, OrganizationSettingsSchema, RequestTypeEnum, - MetadataSchema as BaseMetadataSchema, } from "./schemas" export const MetadataSchema = BaseMetadataSchema @@ -47,90 +47,96 @@ const exampleMemory = { url: "https://example.com/article", } as const -export const MemorySchema = z.object({ - id: z.string().openapi({ - description: "Unique identifier of the memory.", - example: "acxV5LHMEsG2hMSNb4umbn", - }), - customId: z.string().nullable().optional().openapi({ - description: - "Optional custom ID of the memory. This could be an ID from your database that will uniquely identify this memory.", - example: "mem_abc123", - }), - connectionId: z.string().nullable().optional().openapi({ - description: - "Optional ID of connection the memory was created from. This is useful for identifying the source of the memory.", - example: "conn_123", - }), - content: z.string().nullable().optional().openapi({ - description: - "The content to extract and process into a memory. This can be a URL to a website, a PDF, an image, or a video. \n\nPlaintext: Any plaintext format\n\nURL: A URL to a website, PDF, image, or video\n\nWe automatically detect the content type from the url's response format.", - examples: [ - "This is a detailed article about machine learning concepts...", - "https://example.com/article", - "https://youtube.com/watch?v=abc123", - "https://example.com/audio.mp3", - "https://aws-s3.com/bucket/file.pdf", - "https://example.com/image.jpg", - ], - }), - metadata: MetadataSchema.nullable().optional().openapi({ - description: - "Optional metadata for the memory. This is used to store additional information about the memory. You can use this to store any additional information you need about the memory. Metadata can be filtered through. Keys must be strings and are case sensitive. Values can be strings, numbers, or booleans. You cannot nest objects.", - example: exampleMetadata, - }), - source: z.string().nullable().optional().openapi({ - description: "Source of the memory", - example: "web", - }), - status: DocumentSchema.shape.status.openapi({ - description: "Status of the memory", - example: "done", - }), - summary: z.string().nullable().optional().openapi({ - description: "Summary of the memory content", - example: - "A comprehensive guide to understanding the basics of machine learning and its applications.", - }), - title: z.string().nullable().optional().openapi({ - description: "Title of the memory", - example: "Introduction to Machine Learning", - }), - type: DocumentSchema.shape.type.openapi({ - description: "Type of the memory", - example: "text", - }), - url: z.string().nullable().optional().openapi({ - description: "URL of the memory", - example: "https://example.com/article", - }), - createdAt: z.string().openapi({ - description: "Creation timestamp", - example: new Date().toISOString(), - format: "date-time", - }), - updatedAt: z.string().openapi({ - description: "Last update timestamp", - example: new Date().toISOString(), - format: "date-time", - }), - containerTags: z - .array(z.string()) - .optional() - .readonly() - .openapi({ +export const MemorySchema = z + .object({ + id: z.string().openapi({ + description: "Unique identifier of the memory.", + example: "acxV5LHMEsG2hMSNb4umbn", + }), + customId: z.string().nullable().optional().openapi({ description: - "Optional tags this memory should be containerized by. This can be an ID for your user, a project ID, or any other identifier you wish to use to group memories.", - example: ["user_123", "project_123"] as const, + "Optional custom ID of the memory. This could be an ID from your database that will uniquely identify this memory.", + example: "mem_abc123", }), - chunkCount: z.number().default(0).openapi({ - description: "Number of chunks in the memory", - example: 10, - }), -}).openapi({ - description: "Memory object", - example: exampleMemory, -}) + connectionId: z.string().nullable().optional().openapi({ + description: + "Optional ID of connection the memory was created from. This is useful for identifying the source of the memory.", + example: "conn_123", + }), + content: z + .string() + .nullable() + .optional() + .openapi({ + description: + "The content to extract and process into a memory. This can be a URL to a website, a PDF, an image, or a video. \n\nPlaintext: Any plaintext format\n\nURL: A URL to a website, PDF, image, or video\n\nWe automatically detect the content type from the url's response format.", + examples: [ + "This is a detailed article about machine learning concepts...", + "https://example.com/article", + "https://youtube.com/watch?v=abc123", + "https://example.com/audio.mp3", + "https://aws-s3.com/bucket/file.pdf", + "https://example.com/image.jpg", + ], + }), + metadata: MetadataSchema.nullable().optional().openapi({ + description: + "Optional metadata for the memory. This is used to store additional information about the memory. You can use this to store any additional information you need about the memory. Metadata can be filtered through. Keys must be strings and are case sensitive. Values can be strings, numbers, or booleans. You cannot nest objects.", + example: exampleMetadata, + }), + source: z.string().nullable().optional().openapi({ + description: "Source of the memory", + example: "web", + }), + status: DocumentSchema.shape.status.openapi({ + description: "Status of the memory", + example: "done", + }), + summary: z.string().nullable().optional().openapi({ + description: "Summary of the memory content", + example: + "A comprehensive guide to understanding the basics of machine learning and its applications.", + }), + title: z.string().nullable().optional().openapi({ + description: "Title of the memory", + example: "Introduction to Machine Learning", + }), + type: DocumentSchema.shape.type.openapi({ + description: "Type of the memory", + example: "text", + }), + url: z.string().nullable().optional().openapi({ + description: "URL of the memory", + example: "https://example.com/article", + }), + createdAt: z.string().openapi({ + description: "Creation timestamp", + example: new Date().toISOString(), + format: "date-time", + }), + updatedAt: z.string().openapi({ + description: "Last update timestamp", + example: new Date().toISOString(), + format: "date-time", + }), + containerTags: z + .array(z.string()) + .optional() + .readonly() + .openapi({ + description: + "Optional tags this memory should be containerized by. This can be an ID for your user, a project ID, or any other identifier you wish to use to group memories.", + example: ["user_123", "project_123"] as const, + }), + chunkCount: z.number().default(0).openapi({ + description: "Number of chunks in the memory", + example: 10, + }), + }) + .openapi({ + description: "Memory object", + example: exampleMemory, + }) export const MemoryUpdateSchema = z.object({ containerTags: z @@ -698,7 +704,8 @@ export const MemorySearchResult = z.object({ .array( z.object({ relation: z.enum(["updates", "extends", "derives"]).openapi({ - description: "Relation type between this memory and its parent/child", + description: + "Relation type between this memory and its parent/child", example: "updates", }), version: z.number().nullable().optional().openapi({ @@ -725,7 +732,8 @@ export const MemorySearchResult = z.object({ .array( z.object({ relation: z.enum(["updates", "extends", "derives"]).openapi({ - description: "Relation type between this memory and its parent/child", + description: + "Relation type between this memory and its parent/child", example: "extends", }), version: z.number().nullable().optional().openapi({ @@ -1027,48 +1035,48 @@ export const AnalyticsMemoryResponseSchema = z.object({ totalMemories: z.number(), }) -export const MemoryEntryAPISchema = MemoryEntrySchema - .extend({ - sourceAddedAt: z.date().nullable(), // From join relationship - sourceRelevanceScore: z.number().nullable(), // From join relationship - sourceMetadata: z.record(z.unknown()).nullable(), // From join relationship - spaceContainerTag: z.string().nullable(), // From join relationship +export const MemoryEntryAPISchema = MemoryEntrySchema.extend({ + sourceAddedAt: z.date().nullable(), // From join relationship + sourceRelevanceScore: z.number().nullable(), // From join relationship + sourceMetadata: z.record(z.unknown()).nullable(), // From join relationship + spaceContainerTag: z.string().nullable(), // From join relationship +}).openapi({ + description: "Memory entry with source relationship data", +}) + +// Extended document schema with memory entries +export const DocumentWithMemoriesSchema = z + .object({ + id: DocumentSchema.shape.id, + customId: DocumentSchema.shape.customId, + contentHash: DocumentSchema.shape.contentHash, + orgId: DocumentSchema.shape.orgId, + userId: DocumentSchema.shape.userId, + connectionId: DocumentSchema.shape.connectionId, + title: DocumentSchema.shape.title, + content: DocumentSchema.shape.content, + summary: DocumentSchema.shape.summary, + url: DocumentSchema.shape.url, + source: DocumentSchema.shape.source, + type: DocumentSchema.shape.type, + status: DocumentSchema.shape.status, + metadata: DocumentSchema.shape.metadata, + processingMetadata: DocumentSchema.shape.processingMetadata, + raw: DocumentSchema.shape.raw, + tokenCount: DocumentSchema.shape.tokenCount, + wordCount: DocumentSchema.shape.wordCount, + chunkCount: DocumentSchema.shape.chunkCount, + averageChunkSize: DocumentSchema.shape.averageChunkSize, + summaryEmbedding: DocumentSchema.shape.summaryEmbedding, + summaryEmbeddingModel: DocumentSchema.shape.summaryEmbeddingModel, + createdAt: DocumentSchema.shape.createdAt, + updatedAt: DocumentSchema.shape.updatedAt, + memoryEntries: z.array(MemoryEntryAPISchema), }) .openapi({ - description: "Memory entry with source relationship data", + description: "Document with associated memory entries", }) -// Extended document schema with memory entries -export const DocumentWithMemoriesSchema = z.object({ - id: DocumentSchema.shape.id, - customId: DocumentSchema.shape.customId, - contentHash: DocumentSchema.shape.contentHash, - orgId: DocumentSchema.shape.orgId, - userId: DocumentSchema.shape.userId, - connectionId: DocumentSchema.shape.connectionId, - title: DocumentSchema.shape.title, - content: DocumentSchema.shape.content, - summary: DocumentSchema.shape.summary, - url: DocumentSchema.shape.url, - source: DocumentSchema.shape.source, - type: DocumentSchema.shape.type, - status: DocumentSchema.shape.status, - metadata: DocumentSchema.shape.metadata, - processingMetadata: DocumentSchema.shape.processingMetadata, - raw: DocumentSchema.shape.raw, - tokenCount: DocumentSchema.shape.tokenCount, - wordCount: DocumentSchema.shape.wordCount, - chunkCount: DocumentSchema.shape.chunkCount, - averageChunkSize: DocumentSchema.shape.averageChunkSize, - summaryEmbedding: DocumentSchema.shape.summaryEmbedding, - summaryEmbeddingModel: DocumentSchema.shape.summaryEmbeddingModel, - createdAt: DocumentSchema.shape.createdAt, - updatedAt: DocumentSchema.shape.updatedAt, - memoryEntries: z.array(MemoryEntryAPISchema), -}).openapi({ - description: "Document with associated memory entries", -}) - export const DocumentsWithMemoriesResponseSchema = z .object({ documents: z.array(DocumentWithMemoriesSchema), @@ -1212,14 +1220,14 @@ export const ProjectSchema = z format: "date-time", }), updatedAt: z.string().openapi({ - description: "Last update timestamp", - example: new Date().toISOString(), - format: "date-time", - }), - isExperimental: z.boolean().openapi({ - description: "Whether the project (space) is in experimental mode", - example: false, - }), + description: "Last update timestamp", + example: new Date().toISOString(), + format: "date-time", + }), + isExperimental: z.boolean().openapi({ + description: "Whether the project (space) is in experimental mode", + example: false, + }), documentCount: z.number().optional().openapi({ description: "Number of documents in this project", example: 42, diff --git a/packages/validation/package.json b/packages/validation/package.json index e217993d..ea9f5fc1 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { - "name": "@repo/validation", - "version": "0.0.0", - "private": true, - "type": "module" -}
\ No newline at end of file + "name": "@repo/validation", + "version": "0.0.0", + "private": true, + "type": "module" +} diff --git a/packages/validation/schemas.ts b/packages/validation/schemas.ts index 1cd2701c..92524164 100644 --- a/packages/validation/schemas.ts +++ b/packages/validation/schemas.ts @@ -10,7 +10,7 @@ export type Visibility = z.infer<typeof VisibilityEnum> export const DocumentTypeEnum = z.enum([ "text", - "pdf", + "pdf", "tweet", "google_doc", "google_slide", @@ -26,7 +26,7 @@ export type DocumentType = z.infer<typeof DocumentTypeEnum> export const DocumentStatusEnum = z.enum([ "unknown", "queued", - "extracting", + "extracting", "chunking", "embedding", "indexing", @@ -125,7 +125,7 @@ export type Chunk = z.infer<typeof ChunkSchema> export const ConnectionProviderEnum = z.enum([ "notion", - "google-drive", + "google-drive", "onedrive", ]) export type ConnectionProvider = z.infer<typeof ConnectionProviderEnum> @@ -153,22 +153,22 @@ export const ConnectionSchema = z.object({ email: z.string().nullable().optional(), documentLimit: z.number().default(10000), containerTags: z.array(z.string()).nullable().optional(), - + // Token management accessToken: z.string().nullable().optional(), refreshToken: z.string().nullable().optional(), expiresAt: z.coerce.date().nullable().optional(), - + // Provider-specific metadata metadata: z.record(z.unknown()), - + createdAt: z.coerce.date(), }) export type Connection = z.infer<typeof ConnectionSchema> export const RequestTypeEnum = z.enum([ "add", - "search", + "search", "fast_search", "request", "update", @@ -186,31 +186,31 @@ export const ApiRequestSchema = z.object({ keyId: z.string().nullable().optional(), statusCode: z.number(), duration: z.number().nullable().optional(), // duration in ms - + // Request/Response data input: z.record(z.unknown()).nullable().optional(), output: z.record(z.unknown()).nullable().optional(), - + // Token usage tracking originalTokens: z.number().nullable().optional(), finalTokens: z.number().nullable().optional(), tokensSaved: z.number().nullable().optional(), // computed field - + // Cost tracking costSavedUSD: z.number().nullable().optional(), - + // Chat specific fields model: z.string().nullable().optional(), provider: z.string().nullable().optional(), conversationId: z.string().nullable().optional(), - + // Flags contextModified: z.boolean().default(false), - + // Metadata metadata: MetadataSchema.nullable().optional(), origin: z.string().default("api"), - + createdAt: z.coerce.date(), }) export type ApiRequest = z.infer<typeof ApiRequestSchema> @@ -224,23 +224,19 @@ export const SpaceSchema = z.object({ containerTag: z.string().nullable().optional(), visibility: VisibilityEnum.default("private"), isExperimental: z.boolean().default(false), - + // Content indexing contentTextIndex: z.record(z.unknown()).default({}), // KnowledgeBase type indexSize: z.number().nullable().optional(), - + metadata: MetadataSchema.nullable().optional(), - + createdAt: z.coerce.date(), updatedAt: z.coerce.date(), }) export type Space = z.infer<typeof SpaceSchema> -export const MemoryRelationEnum = z.enum([ - "updates", - "extends", - "derives", -]) +export const MemoryRelationEnum = z.enum(["updates", "extends", "derives"]) export type MemoryRelation = z.infer<typeof MemoryRelationEnum> export const MemoryEntrySchema = z.object({ @@ -249,33 +245,33 @@ export const MemoryEntrySchema = z.object({ spaceId: z.string(), orgId: z.string(), userId: z.string().nullable().optional(), - + // Version control version: z.number().default(1), isLatest: z.boolean().default(true), parentMemoryId: z.string().nullable().optional(), rootMemoryId: z.string().nullable().optional(), - + // Memory relationships memoryRelations: z.record(MemoryRelationEnum).default({}), - + // Source tracking sourceCount: z.number().default(1), - + // Status flags isInference: z.boolean().default(false), isForgotten: z.boolean().default(false), forgetAfter: z.coerce.date().nullable().optional(), forgetReason: z.string().nullable().optional(), - + // Embeddings memoryEmbedding: z.array(z.number()).nullable().optional(), memoryEmbeddingModel: z.string().nullable().optional(), memoryEmbeddingNew: z.array(z.number()).nullable().optional(), memoryEmbeddingNewModel: z.string().nullable().optional(), - + metadata: z.record(z.unknown()).nullable().optional(), - + createdAt: z.coerce.date(), updatedAt: z.coerce.date(), }) @@ -296,12 +292,7 @@ export const MemoryDocumentSourceSchema = z.object({ }) export type MemoryDocumentSource = z.infer<typeof MemoryDocumentSourceSchema> -export const SpaceRoleEnum = z.enum([ - "owner", - "admin", - "editor", - "viewer", -]) +export const SpaceRoleEnum = z.enum(["owner", "admin", "editor", "viewer"]) export type SpaceRole = z.infer<typeof SpaceRoleEnum> export const SpacesToMembersSchema = z.object({ @@ -317,28 +308,28 @@ export type SpacesToMembers = z.infer<typeof SpacesToMembersSchema> export const OrganizationSettingsSchema = z.object({ id: z.string(), orgId: z.string(), - + // LLM Filtering shouldLLMFilter: z.boolean().default(false), filterPrompt: z.string().nullable().optional(), includeItems: z.array(z.string()).nullable().optional(), excludeItems: z.array(z.string()).nullable().optional(), - + // Google Drive custom keys googleDriveCustomKeyEnabled: z.boolean().default(false), googleDriveClientId: z.string().nullable().optional(), googleDriveClientSecret: z.string().nullable().optional(), - + // Notion custom keys notionCustomKeyEnabled: z.boolean().default(false), notionClientId: z.string().nullable().optional(), notionClientSecret: z.string().nullable().optional(), - + // OneDrive custom keys onedriveCustomKeyEnabled: z.boolean().default(false), onedriveClientId: z.string().nullable().optional(), onedriveClientSecret: z.string().nullable().optional(), - + updatedAt: z.coerce.date(), }) export type OrganizationSettings = z.infer<typeof OrganizationSettingsSchema> @@ -347,7 +338,7 @@ export const schemas = { // Base types MetadataSchema, VisibilityEnum, - + // Content DocumentTypeEnum, DocumentStatusEnum, @@ -356,16 +347,16 @@ export const schemas = { DocumentSchema, ChunkTypeEnum, ChunkSchema, - + // Connections ConnectionProviderEnum, ConnectionStateSchema, ConnectionSchema, - + // Analytics RequestTypeEnum, ApiRequestSchema, - + // Spaces and Memory SpaceSchema, MemoryRelationEnum, @@ -374,7 +365,7 @@ export const schemas = { MemoryDocumentSourceSchema, SpaceRoleEnum, SpacesToMembersSchema, - + // Auth OrganizationSettingsSchema, -} as const
\ No newline at end of file +} as const @@ -1,21 +1,21 @@ { - "$schema": "https://turborepo.com/schema.json", - "ui": "tui", - "tasks": { - "build": { - "dependsOn": ["^build"], - "inputs": ["$TURBO_DEFAULT$", ".env*"], - "outputs": [".next/**", "!.next/cache/**"] - }, - "lint": { - "dependsOn": ["^lint"] - }, - "check-types": { - "dependsOn": ["^check-types"] - }, - "dev": { - "cache": false, - "persistent": true - } - } + "$schema": "https://turborepo.com/schema.json", + "ui": "tui", + "tasks": { + "build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": [".next/**", "!.next/cache/**"] + }, + "lint": { + "dependsOn": ["^lint"] + }, + "check-types": { + "dependsOn": ["^check-types"] + }, + "dev": { + "cache": false, + "persistent": true + } + } } |