Enhanced Text Editor and format fixes with biome (#371)

This commit is contained in:
Mahesh Sanikommu 2025-08-21 08:40:44 -07:00 committed by GitHub
parent 890def8f89
commit 9e1e392a2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
149 changed files with 7812 additions and 6732 deletions

View file

@ -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.",
]}
/>
)
);
}

View file

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

View file

@ -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',
}
],
}
}
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",
},
],
};
}

View file

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

View file

@ -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 { MemoryListView } from "@/components/memory-list-view";
import Menu from "@/components/menu";
import type { TourStep } from "@/components/tour";
import { TourAlertDialog, useTour } from "@/components/tour";
import { useProject } from "@/stores";
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 { 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";
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 { 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, useProject } from "@/stores";
import { useGraphHighlights } from "@/stores/highlights";
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,100 +497,109 @@ 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>
</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>
{/* 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 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>
<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>
<Menu />
</div>
</div>
<div>
<Menu />
</div>
</div>
{/* Floating Open Chat Button */}
{!isOpen && !isMobile && (
<motion.div
@ -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 />
</>
);
}

View file

@ -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 [referralData, setReferralData] = useState<{
referrerName?: string
valid: boolean
} | null>(null)
const [copiedLink, setCopiedLink] = useState(false)
const router = useRouter();
const params = useParams();
const referralCode = params.code as string;
const referralLink = `https://supermemory.ai/ref/${referralCode}`
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);
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>
)
}
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@repo/ui/components/sheet"
} 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 { 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"
} from "@repo/ui/components/drawer";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} 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 {
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]
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()
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 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}
/>
</>
)
}
);
};

View file

@ -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);
}
}}
/>
)}
</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 />}
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>
</div>
</motion.div>
)}
</AnimatePresence>
</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>
</div>
</div>
</Drawer.Content>
@ -612,7 +615,7 @@ function Menu({ id }: { id?: string }) {
/>
)}
</>
)
);
}
export default Menu
export default Menu;

View file

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

View file

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

View file

@ -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)} />
)
}
return <Loader2 className={cn("size-4 animate-spin", className)} />;
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,322 +1,446 @@
'use client'
"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'
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)
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' })
}
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
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()
}, [])
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])
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)
}
function enableAutoScroll() {
setIsAutoScroll(true);
}
useEffect(function autoScrollOnNewContent() {
if (isAutoScroll) scrollToBottom('auto')
}, [isAutoScroll, ...triggerKeys])
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)
}
function recomputeDistanceFromBottom() {
const container = scrollContainerRef.current;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
setIsFarFromBottom(distanceFromBottom > 100);
}
useEffect(() => {
recomputeDistanceFromBottom()
}, [...triggerKeys])
useEffect(() => {
recomputeDistanceFromBottom();
}, [...triggerKeys]);
function onScroll() {
recomputeDistanceFromBottom()
}
function onScroll() {
recomputeDistanceFromBottom();
}
return { scrollContainerRef, bottomRef, isAutoScroll, isFarFromBottom, onScroll, enableAutoScroll, scrollToBottom } as const
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 { selectedProject } = useProject();
const {
currentChatId,
setCurrentChatId,
setConversation,
getCurrentConversation,
setConversationTitle,
getCurrentChat,
} = usePersistentChat();
const activeChatIdRef = useRef<string | null>(null)
const shouldGenerateTitleRef = useRef<boolean>(false)
const activeChatIdRef = useRef<string | null>(null);
const shouldGenerateTitleRef = useRef<boolean>(false);
const { setDocumentIds } = useGraphHighlights()
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
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)
}
}
},
})
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(() => {
activeChatIdRef.current = currentChatId ?? id ?? null;
}, [currentChatId, id]);
useEffect(() => {
if (id && id !== currentChatId) {
setCurrentChatId(id)
}
}, [id, currentChatId, setCurrentChatId])
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 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])
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())
}
})
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])
// 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])
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>
)
}
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>
);
}
}
}
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>
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>
<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>
</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>
</>
)
<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>
</>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +1,2 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import 'tailwindcss';
@plugin "@tailwindcss/typography";

View file

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

View file

@ -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"
* hasnt 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]);
}

View file

@ -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;
}
return size;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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).*)",
],
}
};

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
}
};
export default config
export default config;

View file

@ -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: {},
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 },
},
}
})
},
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
})()
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,
},
},
},
}
})
},
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 },
},
}
})
},
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",
},
),
)
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 { 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 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 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
const currentChatId = projectState?.currentChatId ?? null;
function setCurrentChatId(chatId: string | null): void {
setCurrentChatIdRaw(projectId, chatId)
}
function setCurrentChatId(chatId: string | null): void {
setCurrentChatIdRaw(projectId, chatId);
}
function setConversation(chatId: string, messages: UIMessage[]): void {
setConversationRaw(projectId, chatId, messages)
}
function setConversation(chatId: string, messages: UIMessage[]): void {
setConversationRaw(projectId, chatId, messages);
}
function deleteConversation(chatId: string): void {
deleteConversationRaw(projectId, chatId)
}
function deleteConversation(chatId: string): void {
deleteConversationRaw(projectId, chatId);
}
function setConversationTitle(chatId: string, title: string | undefined): void {
setConversationTitleRaw(projectId, chatId, title)
}
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 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 }
}
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,
}
return {
conversations,
currentChatId,
setCurrentChatId,
setConversation,
deleteConversation,
setConversationTitle,
getCurrentConversation,
getCurrentChat,
};
}

View file

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

View file

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

75
biome.json Normal file
View file

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

BIN
bun.lockb

Binary file not shown.

View file

@ -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": "bun@1.2.17",
"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": "bun@1.2.17",
"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"
}
}

View file

@ -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/**"],
},
]

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
{
"name": "@repo/hooks",
"version": "0.0.0",
"private": true
}
"name": "@repo/hooks",
"version": "0.0.0",
"private": true
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,55 +1,55 @@
"use client"
"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 { 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"
} 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"
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,
}
};

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more