diff options
| author | Dhravya Shah <[email protected]> | 2025-10-01 18:11:49 -0700 |
|---|---|---|
| committer | Dhravya Shah <[email protected]> | 2025-10-01 18:11:49 -0700 |
| commit | 514f6a6f41dd4a8c4e02ed7965b562984991e4dd (patch) | |
| tree | cd33545eb6a4b78bb50d070156a37e0b41b2a612 /apps/web/components | |
| parent | feat: Add memory vs rag and migration section to docs (diff) | |
| parent | ui: fix progress bar thickness on regular browser (#445) (diff) | |
| download | archived-supermemory-514f6a6f41dd4a8c4e02ed7965b562984991e4dd.tar.xz archived-supermemory-514f6a6f41dd4a8c4e02ed7965b562984991e4dd.zip | |
Merge branch 'main' of https://github.com/supermemoryai/supermemory
Diffstat (limited to 'apps/web/components')
31 files changed, 3487 insertions, 2558 deletions
diff --git a/apps/web/components/chat-input.tsx b/apps/web/components/chat-input.tsx new file mode 100644 index 00000000..c5fd0c5c --- /dev/null +++ b/apps/web/components/chat-input.tsx @@ -0,0 +1,82 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { generateId } from "@lib/generate-id" +import { usePersistentChat } from "@/stores/chat" +import { ArrowUp } from "lucide-react" +import { Button } from "@ui/components/button" +import { ProjectSelector } from "./project-selector" +import { useAuth } from "@lib/auth-context" + +export function ChatInput() { + const [message, setMessage] = useState("") + const router = useRouter() + const { setCurrentChatId } = usePersistentChat() + const { user } = useAuth() + + const handleSend = () => { + if (!message.trim()) return + + const newChatId = generateId() + + setCurrentChatId(newChatId) + + // Store the initial message in sessionStorage for the chat page to pick up + sessionStorage.setItem(`chat-initial-${newChatId}`, message.trim()) + + router.push(`/chat/${newChatId}`) + + setMessage("") + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + return ( + <div className="flex-1 flex items-center justify-center px-4"> + <div className="w-full max-w-4xl"> + <div className="text-start mb-4"> + <h2 className="text-3xl font-bold text-foreground"> + Welcome, <span className="text-primary">{user?.name}</span> + </h2> + </div> + <div className="relative"> + <form + className="flex flex-col items-end bg-card border border-border rounded-[14px] shadow-lg" + onSubmit={(e) => { + e.preventDefault() + if (!message.trim()) return + handleSend() + }} + > + <textarea + value={message} + onChange={(e) => setMessage(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask your supermemory..." + className="w-full text-foreground placeholder-muted-foreground rounded-md outline-none resize-none text-base leading-relaxed px-6 py-4 bg-transparent" + rows={2} + /> + <div className="flex items-center gap-2 w-full justify-between bg-accent py-2 px-3 rounded-b-[14px]"> + <ProjectSelector /> + <Button + onClick={handleSend} + disabled={!message.trim()} + className="text-primary-foreground border-0 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed !bg-primary h-8 w-8" + variant="outline" + size="icon" + > + <ArrowUp className="size-3.5" /> + </Button> + </div> + </form> + </div> + </div> + </div> + ) +} diff --git a/apps/web/components/connect-ai-modal.tsx b/apps/web/components/connect-ai-modal.tsx index b9759d4e..b576934f 100644 --- a/apps/web/components/connect-ai-modal.tsx +++ b/apps/web/components/connect-ai-modal.tsx @@ -193,7 +193,7 @@ export function ConnectAIModal({ <DialogTrigger asChild>{children}</DialogTrigger> <DialogContent className="sm:max-w-4xl"> <DialogHeader> - <DialogTitle>Connect Supermemory to Your AI</DialogTitle> + <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, @@ -205,9 +205,7 @@ export function ConnectAIModal({ {/* Step 1: Client Selection */} <div className="space-y-4"> <div className="flex items-center gap-3"> - <div - className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${"bg-white/10 text-white/60"}`} - > + <div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium bg-muted text-muted-foreground"> 1 </div> <h3 className="text-sm font-medium">Select Your AI Client</h3> @@ -220,8 +218,8 @@ export function ConnectAIModal({ <button className={`pr-3 pl-1 rounded-full border cursor-pointer transition-all ${ selectedClient === key - ? "border-blue-500 bg-blue-500/10" - : "border-white/10 hover:border-white/20 hover:bg-white/5" + ? "border-primary bg-primary/10" + : "border-border hover:border-border/60 hover:bg-muted/50" }`} key={key} onClick={() => @@ -233,7 +231,7 @@ export function ConnectAIModal({ <div className="w-8 h-8 flex items-center justify-center"> <Image alt={clientName} - className="rounded object-contain text-white fill-white" + className="rounded object-contain" height={20} onError={(e) => { const target = e.target as HTMLImageElement @@ -245,7 +243,7 @@ export function ConnectAIModal({ ) { const fallback = document.createElement("span") fallback.className = - "fallback-text text-sm font-bold text-white/40" + "fallback-text text-sm font-bold text-muted-foreground" fallback.textContent = clientName .substring(0, 2) .toUpperCase() @@ -260,7 +258,7 @@ export function ConnectAIModal({ width={20} /> </div> - <span className="text-sm font-medium text-white/80"> + <span className="text-sm font-medium text-foreground/80"> {clientName} </span> </div> @@ -274,7 +272,7 @@ export function ConnectAIModal({ <div className="space-y-4"> <div className="flex justify-between"> <div className="flex items-center gap-3"> - <div className="w-8 h-8 rounded-full bg-white/10 text-white/60 flex items-center justify-center text-sm font-medium"> + <div className="w-8 h-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-sm font-medium"> 2 </div> <h3 className="text-sm font-medium"> @@ -294,12 +292,12 @@ export function ConnectAIModal({ > {/* Tabs */} <div className="flex justify-end"> - <div className="flex bg-white/5 rounded-full p-1 border border-white/10"> + <div className="flex bg-muted/50 rounded-full p-1 border border-border"> <button className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${ cursorInstallTab === "oneClick" - ? "bg-white/10 text-white border border-white/20" - : "text-white/60 hover:text-white/80" + ? "bg-background text-foreground border border-border shadow-sm" + : "text-muted-foreground hover:text-foreground" }`} onClick={() => setCursorInstallTab("oneClick")} type="button" @@ -309,8 +307,8 @@ export function ConnectAIModal({ <button className={`px-3 py-1.5 text-xs font-medium rounded-full transition-all ${ cursorInstallTab === "manual" - ? "bg-white/10 text-white border border-white/20" - : "text-white/60 hover:text-white/80" + ? "bg-background text-foreground border border-border shadow-sm" + : "text-muted-foreground hover:text-foreground" }`} onClick={() => setCursorInstallTab("manual")} type="button" @@ -329,11 +327,11 @@ export function ConnectAIModal({ <div className="space-y-4"> <div className="flex flex-col items-center gap-4 p-6 border border-green-500/20 rounded-lg bg-green-500/5"> <div className="text-center"> - <p className="text-sm text-white/80 mb-2"> + <p className="text-sm text-foreground/80 mb-2"> Click the button below to automatically install and configure Supermemory in Cursor </p> - <p className="text-xs text-white/50"> + <p className="text-xs text-muted-foreground"> This will install the MCP server without any additional setup required </p> @@ -353,13 +351,13 @@ export function ConnectAIModal({ /> </a> </div> - <p className="text-xs text-white/40 text-center"> + <p className="text-xs text-muted-foreground/60 text-center"> Make sure you have Cursor installed on your system </p> </div> ) : ( <div className="space-y-4"> - <p className="text-sm text-white/70"> + <p className="text-sm text-muted-foreground"> Choose a project and follow the installation steps below </p> <div className="max-w-md"> @@ -371,17 +369,11 @@ export function ConnectAIModal({ <SelectTrigger className="w-full"> <SelectValue placeholder="Select project" /> </SelectTrigger> - <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10"> - <SelectItem - className="text-white hover:bg-white/10" - value="none" - > + <SelectContent> + <SelectItem value="none"> Auto-select project </SelectItem> - <SelectItem - className="text-white hover:bg-white/10" - value="sm_project_default" - > + <SelectItem value="sm_project_default"> Default Project </SelectItem> {projects @@ -391,7 +383,6 @@ export function ConnectAIModal({ ) .map((project: Project) => ( <SelectItem - className="text-white hover:bg-white/10" key={project.id} value={project.containerTag} > @@ -426,7 +417,7 @@ export function ConnectAIModal({ <CopyIcon className="size-4" /> </Button> </div> - <p className="text-xs text-white/50"> + <p className="text-xs text-muted-foreground"> Use this URL to configure supermemory in your AI assistant </p> </div> @@ -440,17 +431,9 @@ export function ConnectAIModal({ <SelectTrigger className="w-full"> <SelectValue placeholder="Select project" /> </SelectTrigger> - <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10"> - <SelectItem - className="text-white hover:bg-white/10" - value="none" - > - Auto-select project - </SelectItem> - <SelectItem - className="text-white hover:bg-white/10" - value="sm_project_default" - > + <SelectContent> + <SelectItem value="none">Auto-select project</SelectItem> + <SelectItem value="sm_project_default"> Default Project </SelectItem> {projects @@ -460,7 +443,6 @@ export function ConnectAIModal({ ) .map((project: Project) => ( <SelectItem - className="text-white hover:bg-white/10" key={project.id} value={project.containerTag} > @@ -480,7 +462,7 @@ export function ConnectAIModal({ (selectedClient !== "cursor" || cursorInstallTab === "manual") && ( <div className="space-y-4"> <div className="flex items-center gap-3"> - <div className="w-8 h-8 rounded-full bg-white/10 text-white/60 flex items-center justify-center text-sm font-medium"> + <div className="w-8 h-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-sm font-medium"> 3 </div> <h3 className="text-sm font-medium"> @@ -506,7 +488,7 @@ export function ConnectAIModal({ </Button> </div> - <p className="text-xs text-white/50"> + <p className="text-xs text-muted-foreground"> {selectedClient === "cursor" && cursorInstallTab === "manual" ? "Copy and run this command in your terminal for manual installation (or switch to the one-click option above)" : "Copy and run this command in your terminal to install the MCP server"} @@ -518,19 +500,19 @@ export function ConnectAIModal({ {!selectedClient && ( <div className="space-y-4"> <div className="flex items-center gap-3"> - <div className="w-8 h-8 rounded-full bg-white/10 text-white/60 flex items-center justify-center text-sm font-medium"> + <div className="w-8 h-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-sm font-medium"> 3 </div> <h3 className="text-sm font-medium">Installation Command</h3> </div> <div className="relative"> - <div className="w-full h-10 bg-white/5 border border-white/10 rounded-md flex items-center px-3"> - <div className="w-full h-4 bg-white/20 rounded animate-pulse blur-sm" /> + <div className="w-full h-10 bg-muted border border-border rounded-md flex items-center px-3"> + <div className="w-full h-4 bg-muted-foreground/20 rounded animate-pulse blur-sm" /> </div> </div> - <p className="text-xs text-white/30"> + <p className="text-xs text-muted-foreground/50"> Select a client above to see the installation command </p> </div> @@ -539,18 +521,18 @@ export function ConnectAIModal({ <div className="gap-2 hidden"> <div> <label - className="text-sm font-medium text-white/80 block mb-2" + className="text-sm font-medium text-foreground/80 block mb-2" htmlFor="mcp-server-url-desktop" > MCP Server URL </label> - <p className="text-xs text-white/50 mt-2"> + <p className="text-xs text-muted-foreground mt-2"> Use this URL to configure supermemory in your AI assistant </p> </div> - <div className="p-1 bg-white/5 rounded-lg border border-white/10 items-center flex px-2"> + <div className="p-1 bg-muted rounded-lg border border-border items-center flex px-2"> <CopyableCell - className="font-mono text-xs text-blue-400" + className="font-mono text-xs text-primary" value="https://api.supermemory.ai/mcp" /> </div> @@ -599,11 +581,11 @@ export function ConnectAIModal({ onOpenChange={setIsMigrateDialogOpen} open={isMigrateDialogOpen} > - <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white"> + <DialogContent className="sm:max-w-2xl bg-popover border-border text-popover-foreground"> <div> <DialogHeader> <DialogTitle>Migrate from MCP v1</DialogTitle> - <DialogDescription className="text-white/60"> + <DialogDescription className="text-muted-foreground"> Migrate your MCP documents from the legacy system. </DialogDescription> </DialogHeader> @@ -623,7 +605,7 @@ export function ConnectAIModal({ {({ state, handleChange, handleBlur }) => ( <> <Input - className="bg-white/5 border-white/10 text-white" + className="bg-input border-border text-foreground" id="mcpUrl" onBlur={handleBlur} onChange={(e) => handleChange(e.target.value)} @@ -631,14 +613,14 @@ export function ConnectAIModal({ value={state.value} /> {state.meta.errors.length > 0 && ( - <p className="text-sm text-red-400 mt-1"> + <p className="text-sm text-destructive mt-1"> {state.meta.errors.join(", ")} </p> )} </> )} </mcpMigrationForm.Field> - <p className="text-xs text-white/50"> + <p className="text-xs text-muted-foreground"> Enter your old MCP Link in the format: <br /> <span className="font-mono"> https://mcp.supermemory.ai/userId/sse @@ -648,7 +630,6 @@ export function ConnectAIModal({ </div> <div className="flex justify-end gap-3 mt-4"> <Button - className="bg-white/5 hover:bg-white/10 border-white/10 text-white" onClick={() => { setIsMigrateDialogOpen(false) mcpMigrationForm.reset() @@ -659,7 +640,6 @@ export function ConnectAIModal({ Cancel </Button> <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" disabled={ migrateMCPMutation.isPending || !mcpMigrationForm.state.canSubmit diff --git a/apps/web/components/content-cards/google-docs.tsx b/apps/web/components/content-cards/google-docs.tsx new file mode 100644 index 00000000..22f06f77 --- /dev/null +++ b/apps/web/components/content-cards/google-docs.tsx @@ -0,0 +1,164 @@ +"use client" + +import { Card, CardContent } from "@repo/ui/components/card" +import { Badge } from "@repo/ui/components/badge" +import { ExternalLink, FileText, Brain } from "lucide-react" +import { useState } from "react" +import { cn } from "@lib/utils" +import { colors } from "@repo/ui/memory-graph/constants" +import { getPastelBackgroundColor } from "../memories-utils" + +interface GoogleDocsCardProps { + title: string + url: string | null | undefined + description?: string | null + className?: string + onClick?: () => void + showExternalLink?: boolean + activeMemories?: Array<{ id: string; isForgotten?: boolean }> + lastModified?: string | Date +} + +export const GoogleDocsCard = ({ + title, + url, + description, + className, + onClick, + showExternalLink = true, + activeMemories, + lastModified, +}: GoogleDocsCardProps) => { + const [imageError, setImageError] = useState(false) + + const handleCardClick = () => { + if (onClick) { + onClick() + } else if (url) { + window.open(url, "_blank", "noopener,noreferrer") + } + } + + const handleExternalLinkClick = (e: React.MouseEvent) => { + e.stopPropagation() + if (url) { + window.open(url, "_blank", "noopener,noreferrer") + } + } + + return ( + <Card + className={cn( + "cursor-pointer transition-all hover:shadow-md group overflow-hidden relative py-4", + className, + )} + onClick={handleCardClick} + style={{ + backgroundColor: getPastelBackgroundColor(url || title || "googledocs"), + }} + > + <CardContent className="p-0"> + <div className="px-4 border-b border-white/10"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <svg + className="w-4 h-4" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 87.3 78" + aria-label="Google Docs" + > + <title>Google Docs</title> + <path + fill="#0066da" + d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3L27.5 53H0c0 1.55.4 3.1 1.2 4.5z" + /> + <path + fill="#00ac47" + d="M43.65 25 29.9 1.2c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44A9.06 9.06 0 0 0 0 53h27.5z" + /> + <path + fill="#ea4335" + d="M73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75L86.1 57.5c.8-1.4 1.2-2.95 1.2-4.5H59.798l5.852 11.5z" + /> + <path + fill="#00832d" + d="M43.65 25 57.4 1.2C56.05.4 54.5 0 52.9 0H34.4c-1.6 0-3.15.45-4.5 1.2z" + /> + <path + fill="#2684fc" + d="M59.8 53H27.5L13.75 76.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" + /> + <path + fill="#ffba00" + d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3L43.65 25 59.8 53h27.45c0-1.55-.4-3.1-1.2-4.5z" + /> + </svg> + <div className="flex flex-col"> + <span className="text-xs text-muted-foreground"> + Google Docs + </span> + </div> + </div> + {showExternalLink && ( + <button + onClick={handleExternalLinkClick} + className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-white/10 flex-shrink-0" + type="button" + aria-label="Open in Google Docs" + > + <ExternalLink className="w-4 h-4" /> + </button> + )} + </div> + </div> + + <div className="px-4 space-y-2"> + <div className="flex items-start justify-between gap-2"> + <h3 className="font-semibold text-sm line-clamp-2 leading-tight flex-1"> + {title || "Untitled Document"} + </h3> + </div> + + {description && ( + <p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed"> + {description} + </p> + )} + + <div className="flex items-center justify-between text-xs text-muted-foreground"> + <div className="flex items-center gap-1"> + <FileText className="w-3 h-3" /> + <span>Google Workspace</span> + </div> + {lastModified && ( + <span className="truncate"> + Modified{" "} + {lastModified instanceof Date + ? lastModified.toLocaleDateString() + : new Date(lastModified).toLocaleDateString()} + </span> + )} + </div> + + {activeMemories && activeMemories.length > 0 && ( + <div> + <Badge + className="text-xs text-accent-foreground" + style={{ + backgroundColor: colors.memory.secondary, + }} + variant="secondary" + > + <Brain className="w-3 h-3 mr-1" /> + {activeMemories.length}{" "} + {activeMemories.length === 1 ? "memory" : "memories"} + </Badge> + </div> + )} + </div> + </CardContent> + </Card> + ) +} + +GoogleDocsCard.displayName = "GoogleDocsCard" diff --git a/apps/web/components/content-cards/note.tsx b/apps/web/components/content-cards/note.tsx new file mode 100644 index 00000000..e7703d9b --- /dev/null +++ b/apps/web/components/content-cards/note.tsx @@ -0,0 +1,133 @@ +import { Badge } from "@repo/ui/components/badge" +import { Card, CardContent, CardHeader } from "@repo/ui/components/card" + +import { colors } from "@repo/ui/memory-graph/constants" +import { Brain, ExternalLink } from "lucide-react" +import { cn } from "@lib/utils" +import { + formatDate, + getPastelBackgroundColor, + getSourceUrl, +} from "../memories-utils" +import { MCPIcon } from "../menu" +import { analytics } from "@/lib/analytics" +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" +import type { z } from "zod" + +type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema> +type DocumentWithMemories = DocumentsResponse["documents"][0] + +interface NoteCardProps { + document: DocumentWithMemories + width: number + activeMemories: Array<{ id: string; isForgotten?: boolean }> + forgottenMemories: Array<{ id: string; isForgotten?: boolean }> + onOpenDetails: (document: DocumentWithMemories) => void + onDelete: (document: DocumentWithMemories) => void +} + +export const NoteCard = ({ + document, + width, + activeMemories, + forgottenMemories, + onOpenDetails, +}: NoteCardProps) => { + return ( + <Card + className="w-full p-4 transition-all cursor-pointer group relative overflow-hidden gap-2 shadow-xs" + onClick={() => { + analytics.documentCardClicked() + onOpenDetails(document) + }} + style={{ + backgroundColor: getPastelBackgroundColor( + document.id || document.title || "note", + ), + width: width, + }} + > + <CardHeader className="relative z-10 px-0 pb-0"> + <div className="flex items-center justify-between gap-2"> + <div className="flex items-center gap-1"> + <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") + }} + style={{ + backgroundColor: "rgba(255, 255, 255, 0.05)", + color: colors.text.secondary, + }} + type="button" + > + <ExternalLink className="w-3 h-3" /> + </button> + )} + <div className="flex items-center gap-2 text-[10px] text-muted-foreground"> + <span>{formatDate(document.createdAt)}</span> + </div> + </div> + </CardHeader> + <CardContent className="relative z-10 px-0"> + {document.content && ( + <p + className="text-xs line-clamp-6" + style={{ color: colors.text.muted }} + > + {document.content} + </p> + )} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2 flex-wrap"> + {activeMemories.length > 0 && ( + <Badge + className="text-xs text-accent-foreground mt-2" + style={{ + backgroundColor: colors.memory.secondary, + }} + variant="secondary" + > + <Brain className="w-3 h-3 mr-1" /> + {activeMemories.length}{" "} + {activeMemories.length === 1 ? "memory" : "memories"} + </Badge> + )} + {forgottenMemories.length > 0 && ( + <Badge + className="text-xs mt-2" + style={{ + borderColor: "rgba(255, 255, 255, 0.2)", + color: colors.text.muted, + }} + variant="outline" + > + {forgottenMemories.length} forgotten + </Badge> + )} + {document.source === "mcp" && ( + <Badge variant="outline" className="mt-2"> + <MCPIcon className="w-3 h-3 mr-1" /> + MCP + </Badge> + )} + </div> + </div> + </CardContent> + </Card> + ) +} + +NoteCard.displayName = "NoteCard" diff --git a/apps/web/components/content-cards/tweet.tsx b/apps/web/components/content-cards/tweet.tsx new file mode 100644 index 00000000..3f46d6cc --- /dev/null +++ b/apps/web/components/content-cards/tweet.tsx @@ -0,0 +1,105 @@ +import { Suspense } from "react" +import type { Tweet } from "react-tweet/api" +import { + type TwitterComponents, + TweetContainer, + TweetHeader, + TweetInReplyTo, + TweetBody, + TweetMedia, + TweetInfo, + QuotedTweet, + TweetNotFound, + TweetSkeleton, + enrichTweet, +} from "react-tweet" +import { Badge } from "@repo/ui/components/badge" +import { Brain } from "lucide-react" +import { colors } from "@repo/ui/memory-graph/constants" +import { getPastelBackgroundColor } from "../memories-utils" + +type MyTweetProps = { + tweet: Tweet + components?: TwitterComponents +} + +const MyTweet = ({ tweet: t, components }: MyTweetProps) => { + const parsedTweet = typeof t === "string" ? JSON.parse(t) : t + const tweet = enrichTweet(parsedTweet) + return ( + <TweetContainer className="pb-5"> + <TweetHeader tweet={tweet} components={components} /> + {tweet.in_reply_to_status_id_str && <TweetInReplyTo tweet={tweet} />} + <TweetBody tweet={tweet} /> + {tweet.mediaDetails?.length ? ( + <TweetMedia tweet={tweet} components={components} /> + ) : null} + {tweet.quoted_tweet && <QuotedTweet tweet={tweet.quoted_tweet} />} + <TweetInfo tweet={tweet} /> + </TweetContainer> + ) +} + +const TweetContent = ({ + components, + tweet, +}: { + components: TwitterComponents + tweet: Tweet +}) => { + if (!tweet) { + const NotFound = components?.TweetNotFound || TweetNotFound + return <NotFound /> + } + + return <MyTweet tweet={tweet} components={components} /> +} + +const CustomTweet = ({ + fallback = <TweetSkeleton />, + ...props +}: { + components: TwitterComponents + tweet: Tweet + fallback?: React.ReactNode +}) => ( + <Suspense fallback={fallback}> + <TweetContent {...props} /> + </Suspense> +) + +export const TweetCard = ({ + data, + activeMemories, +}: { + data: Tweet + activeMemories?: Array<{ id: string; isForgotten?: boolean }> +}) => { + return ( + <div + className="relative transition-all" + style={{ + backgroundColor: getPastelBackgroundColor(data.id_str || "tweet"), + }} + > + <CustomTweet components={{}} tweet={data} /> + {activeMemories && activeMemories.length > 0 && ( + <div className="absolute bottom-2 left-4 z-10"> + <Badge + className="text-xs text-accent-foreground" + style={{ + backgroundColor: colors.memory.secondary, + }} + variant="secondary" + > + <Brain className="w-3 h-3 mr-1" /> + {activeMemories.length}{" "} + {activeMemories.length === 1 ? "memory" : "memories"} + </Badge> + </div> + )} + </div> + ) +} + +TweetCard.displayName = "TweetCard" diff --git a/apps/web/components/content-cards/website.tsx b/apps/web/components/content-cards/website.tsx new file mode 100644 index 00000000..f36cd247 --- /dev/null +++ b/apps/web/components/content-cards/website.tsx @@ -0,0 +1,103 @@ +"use client" + +import { Card, CardContent } from "@repo/ui/components/card" +import { ExternalLink } from "lucide-react" +import { useState } from "react" +import { cn } from "@lib/utils" +import { getPastelBackgroundColor } from "../memories-utils" + +interface WebsiteCardProps { + title: string + url: string + image?: string + description?: string + className?: string + onClick?: () => void + showExternalLink?: boolean +} + +export const WebsiteCard = ({ + title, + url, + image, + description, + className, + onClick, + showExternalLink = true, +}: WebsiteCardProps) => { + const [imageError, setImageError] = useState(false) + + const handleCardClick = () => { + if (onClick) { + onClick() + } else { + window.open(url, "_blank", "noopener,noreferrer") + } + } + + const handleExternalLinkClick = (e: React.MouseEvent) => { + e.stopPropagation() + window.open(url, "_blank", "noopener,noreferrer") + } + + const hostname = (() => { + try { + return new URL(url).hostname + } catch { + return url + } + })() + + return ( + <Card + className={cn( + "cursor-pointer transition-all hover:shadow-md group overflow-hidden py-0", + className, + )} + onClick={handleCardClick} + style={{ + backgroundColor: getPastelBackgroundColor(url || title || "website"), + }} + > + <CardContent className="p-0"> + {image && !imageError && ( + <div className="relative h-38 bg-gray-100 overflow-hidden"> + <img + src={image} + alt={title || "Website preview"} + className="w-full h-full object-cover transition-transform group-hover:scale-105" + onError={() => setImageError(true)} + loading="lazy" + /> + </div> + )} + + <div className="px-4 py-2 space-y-2"> + <div className="font-semibold text-sm line-clamp-2 leading-tight flex items-center justify-between"> + {title} + {showExternalLink && ( + <button + onClick={handleExternalLinkClick} + className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-100 flex-shrink-0" + type="button" + aria-label="Open in new tab" + > + <ExternalLink className="w-3 h-3" /> + </button> + )} + </div> + + {description && ( + <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed"> + {description} + </p> + )} + + <p className="text-xs text-muted-foreground truncate">{hostname}</p> + </div> + </CardContent> + </Card> + ) +} + +WebsiteCard.displayName = "WebsiteCard" diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx new file mode 100644 index 00000000..b51b4b84 --- /dev/null +++ b/apps/web/components/header.tsx @@ -0,0 +1,175 @@ +import { Button } from "@ui/components/button" +import { Logo, LogoFull } from "@ui/assets/Logo" +import Link from "next/link" +import { MoonIcon, Plus, SunIcon, MonitorIcon, Network } from "lucide-react" +import { + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from "@ui/components/dropdown-menu" +import { DropdownMenuItem } from "@ui/components/dropdown-menu" +import { DropdownMenu } from "@ui/components/dropdown-menu" +import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar" +import { useAuth } from "@lib/auth-context" +import { ConnectAIModal } from "./connect-ai-modal" +import { useTheme } from "next-themes" +import { cn } from "@lib/utils" +import { useRouter } from "next/navigation" +import { MCPIcon } from "./menu" +import { authClient } from "@lib/auth" +import { analytics } from "@/lib/analytics" +import { useGraphModal } from "@/stores" + +export function Header({ onAddMemory }: { onAddMemory?: () => void }) { + const { user } = useAuth() + const { theme, setTheme } = useTheme() + const router = useRouter() + const { setIsOpen: setGraphModalOpen } = useGraphModal() + + const handleSignOut = () => { + analytics.userSignedOut() + authClient.signOut() + router.push("/login") + } + + return ( + <div className="flex items-center justify-between w-full p-3 md:p-4"> + <div className="flex items-center gap-2 md:gap-3 justify-between w-full"> + <Link + className="pointer-events-auto" + href={ + process.env.NODE_ENV === "development" + ? "http://localhost:3000" + : "https://app.supermemory.ai" + } + rel="noopener noreferrer" + > + <LogoFull className="h-8 hidden md:block" /> + <Logo className="h-8 md:hidden" /> + </Link> + + <div className="flex items-center gap-1.5 md:gap-3"> + <Button + variant="secondary" + size="sm" + onClick={onAddMemory} + className="gap-1.5" + > + <Plus className="h-4 w-4" /> + <span className="hidden sm:inline">Add Memory</span> + <span className="hidden md:inline bg-secondary-foreground/10 rounded-md px-2 py-[2px] text-xs"> + c + </span> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => setGraphModalOpen(true)} + className="gap-1.5" + > + <Network className="h-4 w-4" /> + <span className="hidden sm:inline">Graph View</span> + </Button> + <ConnectAIModal> + <Button variant="ghost" size="sm" className="gap-1.5"> + <MCPIcon className="h-4 w-4" /> + <span className="hidden lg:inline">Connect to AI (MCP)</span> + </Button> + </ConnectAIModal> + <DropdownMenu> + <DropdownMenuTrigger> + <Avatar className="border border-border h-8 w-8 md:h-10 md:w-10"> + <AvatarImage src={user?.image ?? ""} /> + <AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback> + </Avatar> + </DropdownMenuTrigger> + <DropdownMenuContent className="mr-2 md:mr-4 px-2 w-56"> + <DropdownMenuLabel> + <div> + <p className="text-sm font-medium">{user?.name}</p> + <p className="text-xs text-muted-foreground">{user?.email}</p> + </div> + </DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => router.push("/settings")}> + Profile + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => router.push("/settings/billing")} + > + Billing + </DropdownMenuItem> + <DropdownMenuItem + className="flex items-center justify-between p-2 cursor-default hover:bg-transparent focus:bg-transparent data-[highlighted]:bg-transparent" + onSelect={(e) => e.preventDefault()} + > + <span className="text-sm font-medium">Theme</span> + <div className="flex items-center gap-1 bg-accent rounded-full"> + <Button + variant={theme === "system" ? "default" : "ghost"} + size="sm" + className={cn( + "h-6 w-6 rounded-full group hover:cursor-pointer", + )} + onClick={() => setTheme("system")} + title="System" + > + <MonitorIcon + className={cn( + theme === "system" + ? "text-primay-foreground" + : "text-muted-foreground", + "h-3 w-3 group-hover:text-foreground", + )} + /> + </Button> + <Button + variant={theme === "light" ? "default" : "ghost"} + size="sm" + className={cn( + "h-6 w-6 rounded-full group hover:cursor-pointer", + )} + onClick={() => setTheme("light")} + title="Light" + > + <SunIcon + className={cn( + theme === "light" + ? "text-primay-foreground" + : "text-muted-foreground", + "h-3 w-3 group-hover:text-foreground", + )} + /> + </Button> + <Button + variant={theme === "dark" ? "default" : "ghost"} + size="sm" + className={cn( + "h-6 w-6 rounded-full group hover:cursor-pointer", + )} + onClick={() => setTheme("dark")} + title="Dark" + > + <MoonIcon + className={cn( + theme === "dark" + ? "text-primay-foreground" + : "text-muted-foreground", + "h-3 w-3 group-hover:text-foreground", + )} + /> + </Button> + </div> + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => handleSignOut()}> + Logout + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </div> + ) +} diff --git a/apps/web/components/masonry-memory-list.tsx b/apps/web/components/masonry-memory-list.tsx new file mode 100644 index 00000000..2f634f74 --- /dev/null +++ b/apps/web/components/masonry-memory-list.tsx @@ -0,0 +1,269 @@ +"use client" + +import { useIsMobile } from "@hooks/use-mobile" +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" +import { colors } from "@repo/ui/memory-graph/constants" +import { Sparkles } from "lucide-react" +import { Masonry, useInfiniteLoader } from "masonic" +import { memo, useCallback, useMemo, useState } from "react" +import type { z } from "zod" +import { analytics } from "@/lib/analytics" +import { useDeleteDocument } from "@lib/queries" +import { useProject } from "@/stores" + +import { MemoryDetail } from "./memories-utils/memory-detail" +import { TweetCard } from "./content-cards/tweet" +import { WebsiteCard } from "./content-cards/website" +import { NoteCard } from "./content-cards/note" +import { GoogleDocsCard } from "./content-cards/google-docs" +import type { Tweet } from "react-tweet/api" + +type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema> +type DocumentWithMemories = DocumentsResponse["documents"][0] + +interface MasonryMemoryListProps { + children?: React.ReactNode + documents: DocumentWithMemories[] + isLoading: boolean + isLoadingMore: boolean + error: Error | null + totalLoaded: number + hasMore: boolean + loadMoreDocuments: () => Promise<void> +} + +const DocumentCard = memo( + ({ + index: _index, + data: document, + width, + onOpenDetails, + onDelete, + }: { + index: number + data: DocumentWithMemories & { ogImage?: string } + width: number + onOpenDetails: (document: DocumentWithMemories) => void + onDelete: (document: DocumentWithMemories) => void + }) => { + const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten) + const forgottenMemories = document.memoryEntries.filter( + (m) => m.isForgotten, + ) + + if ( + document.url?.includes("https://docs.googleapis.com/v1/documents") || + document.url?.includes("docs.google.com/document") || + document.type === "google_doc" + ) { + return ( + <GoogleDocsCard + url={document.url} + title={document.title || "Untitled Document"} + description={document.content} + activeMemories={activeMemories} + lastModified={document.updatedAt || document.createdAt} + /> + ) + } + + if ( + document.url?.includes("x.com/") && + document.metadata?.sm_internal_twitter_metadata + ) { + return ( + <TweetCard + data={ + document.metadata?.sm_internal_twitter_metadata as unknown as Tweet + } + activeMemories={activeMemories} + /> + ) + } + + if (document.url?.includes("https://")) { + return ( + <WebsiteCard + url={document.url} + title={document.title || "Untitled Document"} + image={document.ogImage} + /> + ) + } + + return ( + <NoteCard + document={document} + width={width} + activeMemories={activeMemories} + forgottenMemories={forgottenMemories} + onOpenDetails={onOpenDetails} + onDelete={onDelete} + /> + ) + }, +) + +DocumentCard.displayName = "DocumentCard" + +export const MasonryMemoryList = ({ + children, + documents, + isLoading, + isLoadingMore, + error, + hasMore, + loadMoreDocuments, +}: MasonryMemoryListProps) => { + const [selectedSpace, _] = useState<string>("all") + const [selectedDocument, setSelectedDocument] = + useState<DocumentWithMemories | null>(null) + const [isDetailOpen, setIsDetailOpen] = useState(false) + const isMobile = useIsMobile() + const { selectedProject } = useProject() + const deleteDocumentMutation = useDeleteDocument(selectedProject) + + const handleDeleteDocument = useCallback( + (document: DocumentWithMemories) => { + deleteDocumentMutation.mutate(document.id) + }, + [deleteDocumentMutation], + ) + + // Filter documents based on selected space + const filteredDocuments = useMemo(() => { + if (!documents) return [] + + if (selectedSpace === "all") { + return documents + } + + return documents + .map((doc) => ({ + ...doc, + memoryEntries: doc.memoryEntries.filter( + (memory) => + (memory.spaceContainerTag ?? memory.spaceId) === selectedSpace, + ), + })) + .filter((doc) => doc.memoryEntries.length > 0) + }, [documents, selectedSpace]) + + const handleOpenDetails = useCallback((document: DocumentWithMemories) => { + analytics.memoryDetailOpened() + setSelectedDocument(document) + setIsDetailOpen(true) + }, []) + + const handleCloseDetails = useCallback(() => { + setIsDetailOpen(false) + setTimeout(() => setSelectedDocument(null), 300) + }, []) + + // Infinite loading with Masonic + const maybeLoadMore = useInfiniteLoader( + async (_startIndex, _stopIndex, _currentItems) => { + if (hasMore && !isLoadingMore) { + await loadMoreDocuments() + } + }, + { + isItemLoaded: (index, items) => !!items[index], + minimumBatchSize: 10, + threshold: 5, + }, + ) + + const renderDocumentCard = useCallback( + ({ + index, + data, + width, + }: { + index: number + data: DocumentWithMemories + width: number + }) => ( + <DocumentCard + index={index} + data={data} + width={width} + onOpenDetails={handleOpenDetails} + onDelete={handleDeleteDocument} + /> + ), + [handleOpenDetails, handleDeleteDocument], + ) + + return ( + <> + <div className="h-full relative pt-10"> + {error ? ( + <div className="h-full flex items-center justify-center p-4"> + <div className="rounded-xl overflow-hidden"> + <div + className="relative z-10 px-6 py-4" + style={{ color: colors.text.primary }} + > + Error loading documents: {error.message} + </div> + </div> + </div> + ) : isLoading ? ( + <div className="h-full flex items-center justify-center p-4"> + <div className="rounded-xl overflow-hidden"> + <div + className="relative z-10 px-6 py-4" + > + <div className="flex items-center gap-2"> + <Sparkles className="w-4 h-4 animate-spin text-blue-400" /> + <span>Loading memory list...</span> + </div> + </div> + </div> + </div> + ) : filteredDocuments.length === 0 && !isLoading ? ( + <div className="h-full flex items-center justify-center p-4"> + {children} + </div> + ) : ( + <div + className="h-full overflow-auto custom-scrollbar sm-tweet-theme" + data-theme="light" + > + <Masonry + items={filteredDocuments} + render={renderDocumentCard} + columnGutter={16} + rowGutter={16} + columnWidth={280} + maxColumnCount={isMobile ? 1 : undefined} + itemHeightEstimate={200} + overscanBy={3} + onRender={maybeLoadMore} + className="px-4" + /> + + {isLoadingMore && ( + <div className="py-8 flex items-center justify-center"> + <div className="flex items-center gap-2"> + <Sparkles className="w-4 h-4 animate-spin text-blue-400" /> + <span style={{ color: colors.text.primary }}> + Loading more memories... + </span> + </div> + </div> + )} + </div> + )} + </div> + + <MemoryDetail + document={selectedDocument} + isOpen={isDetailOpen} + onClose={handleCloseDetails} + isMobile={isMobile} + /> + </> + ) +} diff --git a/apps/web/components/memories-utils/html-content-renderer.tsx b/apps/web/components/memories-utils/html-content-renderer.tsx new file mode 100644 index 00000000..6231f5eb --- /dev/null +++ b/apps/web/components/memories-utils/html-content-renderer.tsx @@ -0,0 +1,62 @@ +import { memo, useMemo } from "react" +import DOMPurify from "dompurify" + +interface HTMLContentRendererProps { + content: string + className?: string +} + +/** + * Detects if content is likely HTML based on common HTML patterns + */ +const isHTMLContent = (content: string): boolean => { + // Check for HTML tags, entities, and DOCTYPE + const htmlPatterns = [ + /<[a-z][\s\S]*>/i, // HTML tags + /&[a-z]+;/i, // HTML entities + /<!doctype\s+html/i, // DOCTYPE declaration + /<\/[a-z]+>/i, // Closing tags + ] + + return htmlPatterns.some((pattern) => pattern.test(content)) +} + +export const HTMLContentRenderer = memo( + ({ content, className = "" }: HTMLContentRendererProps) => { + const { isHTML, processedContent } = useMemo(() => { + const contentIsHTML = isHTMLContent(content) + + if (contentIsHTML) { + return { + isHTML: true, + processedContent: DOMPurify.sanitize(content), + } + } + + return { + isHTML: false, + processedContent: content, + } + }, [content]) + + if (isHTML) { + return ( + <div + className={`${className} bg-background`} + // biome-ignore lint/security/noDangerouslySetInnerHtml: Content is sanitized with DOMPurify + dangerouslySetInnerHTML={{ __html: processedContent }} + /> + ) + } + + return ( + <p + className={`text-sm leading-relaxed whitespace-pre-wrap text-foreground ${className}`} + > + {processedContent} + </p> + ) + }, +) + +HTMLContentRenderer.displayName = "HTMLContentRenderer" diff --git a/apps/web/components/memories-utils/index.tsx b/apps/web/components/memories-utils/index.tsx new file mode 100644 index 00000000..3052f3c7 --- /dev/null +++ b/apps/web/components/memories-utils/index.tsx @@ -0,0 +1,109 @@ +import type { DocumentWithMemories } from "@ui/memory-graph/types" + +export 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 getOrdinalSuffix = (n: number) => { + const s = ["th", "st", "nd", "rd"] + const v = n % 100 + return n + (s[(v - 20) % 10] || s[v] || s[0] || "th") + } + + const formattedDay = getOrdinalSuffix(day) + + if (dateYear !== currentYear) { + return `${month} ${formattedDay}, ${dateYear}` + } + + return `${month} ${formattedDay}` +} + +export const getSourceUrl = (document: DocumentWithMemories) => { + if (document.type === "google_doc" && 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}` + } + if (document.type === "google_slide" && document.customId) { + return `https://docs.google.com/presentation/d/${document.customId}` + } + // Fallback to existing URL for all other document types + return document.url +} + +// Simple hash function for consistent color generation +const hashString = (str: string): number => { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32-bit integer + } + return Math.abs(hash) +} + +// Generate consistent pastel background color based on document ID +export const getPastelBackgroundColor = ( + documentId: string | undefined | null, +): string => { + // Handle null/undefined cases + if (!documentId) { + return "rgba(255, 255, 255, 0.06)" // Default fallback color + } + + const hash = hashString(documentId) + + // Define pastel color palette with good contrast against dark backgrounds + const pastelColors = [ + // Soft pinks and roses + "rgba(255, 182, 193, 0.08)", // Light pink + "rgba(255, 218, 221, 0.08)", // Misty rose + "rgba(255, 192, 203, 0.08)", // Pink + + // Soft blues and purples + "rgba(173, 216, 230, 0.08)", // Light blue + "rgba(221, 160, 221, 0.08)", // Plum + "rgba(218, 112, 214, 0.08)", // Orchid + "rgba(147, 197, 253, 0.08)", // Sky blue + + // Soft greens + "rgba(152, 251, 152, 0.08)", // Pale green + "rgba(175, 238, 238, 0.08)", // Pale turquoise + "rgba(144, 238, 144, 0.08)", // Light green + + // Soft oranges and yellows + "rgba(255, 218, 185, 0.08)", // Peach puff + "rgba(255, 239, 213, 0.08)", // Papaya whip + "rgba(255, 228, 196, 0.08)", // Bisque + + // Soft corals and salmons + "rgba(250, 128, 114, 0.08)", // Salmon + "rgba(255, 127, 80, 0.08)", // Coral + "rgba(255, 160, 122, 0.08)", // Light salmon + ] + + // Use hash to consistently pick a color + const colorIndex = hash % pastelColors.length + return pastelColors[colorIndex] || "rgba(255, 255, 255, 0.06)" +} diff --git a/apps/web/components/memories-utils/memory-detail.tsx b/apps/web/components/memories-utils/memory-detail.tsx new file mode 100644 index 00000000..8f238731 --- /dev/null +++ b/apps/web/components/memories-utils/memory-detail.tsx @@ -0,0 +1,385 @@ +import { getDocumentIcon } from "@/lib/document-icon" +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@repo/ui/components/drawer" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog" +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" +import { Badge } from "@ui/components/badge" +import { + Brain, + Calendar, + ChevronDown, + ChevronUp, + CircleUserRound, + ExternalLink, + List, + Sparkles, +} from "lucide-react" +import { memo, useState } from "react" +import type { z } from "zod" +import { formatDate, getSourceUrl } from "." +import { Label1Regular } from "@ui/text/label/label-1-regular" +import { HTMLContentRenderer } from "./html-content-renderer" + +type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema> +type DocumentWithMemories = DocumentsResponse["documents"][0] +type MemoryEntry = DocumentWithMemories["memoryEntries"][0] + +const formatDocumentType = (type: string) => { + if (type.toLowerCase() === "pdf") return "PDF" + + return type + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" ") +} + +const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => { + return ( + <div + className={`p-2.5 md:p-4 rounded-lg md:rounded-xl border transition-all relative overflow-hidden group ${ + memory.isLatest + ? "bg-card shadow-sm hover:shadow-md border-primary/30" + : "bg-card/50 shadow-xs hover:shadow-sm border-border/60 hover:border-border" + }`} + > + <div className="flex items-start gap-2 md:gap-3 relative z-10"> + <div className="flex-1 space-y-1.5 md:space-y-3"> + <Label1Regular className="text-xs md:text-sm leading-relaxed text-left text-card-foreground"> + {memory.memory} + </Label1Regular> + <div className="flex gap-1 md:gap-2 justify-between items-center flex-wrap"> + <div className="flex items-center gap-2 md:gap-3 text-[10px] md:text-xs text-muted-foreground"> + <span className="flex items-center gap-1"> + <Calendar className="w-3 h-3 md:w-3.5 md:h-3.5" /> + {formatDate(memory.createdAt)} + </span> + <span className="font-mono bg-muted/30 px-1 md:px-1.5 py-0.5 rounded text-[9px] md:text-[10px]"> + v{memory.version} + </span> + {memory.sourceRelevanceScore && ( + <span + className={`flex items-center gap-1 font-medium ${ + memory.sourceRelevanceScore > 70 + ? "text-emerald-600 dark:text-emerald-400" + : "text-muted-foreground" + }`} + > + <Sparkles className="w-3.5 h-3.5" /> + {memory.sourceRelevanceScore}% + </span> + )} + </div> + <div className="flex items-center gap-1 md:gap-1.5 flex-wrap"> + {memory.isForgotten && ( + <Badge + className="text-[9px] md:text-[10px] h-4 md:h-5" + variant="destructive" + > + Forgotten + </Badge> + )} + {memory.isLatest && ( + <Badge + className="text-[9px] md:text-[10px] h-4 md:h-5 bg-primary/15 text-primary border-primary/30" + variant="outline" + > + Latest + </Badge> + )} + {memory.forgetAfter && ( + <Badge + className="text-[9px] md:text-[10px] h-4 md:h-5 text-amber-600 dark:text-amber-500 bg-amber-500/10 border-amber-500/30" + variant="outline" + > + <span className="hidden sm:inline"> + Expires {formatDate(memory.forgetAfter)} + </span> + <span className="sm:hidden">Expires</span> + </Badge> + )} + </div> + </div> + </div> + </div> + </div> + ) +}) + +export const MemoryDetail = memo( + ({ + document, + isOpen, + onClose, + isMobile, + }: { + document: DocumentWithMemories | null + isOpen: boolean + onClose: () => void + isMobile: boolean + }) => { + if (!document) return null + + const [isSummaryOpen, setIsSummaryOpen] = useState(false) + + const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten) + const forgottenMemories = document.memoryEntries.filter( + (m) => m.isForgotten, + ) + + const HeaderContent = ({ + TitleComponent, + }: { + TitleComponent: typeof DialogTitle | typeof DrawerTitle + }) => ( + <div className="flex items-start justify-between gap-2"> + <div className="flex items-start gap-2 md:gap-3 flex-1 min-w-0"> + <div className="p-1.5 md:p-2 rounded-lg bg-muted/10 flex-shrink-0"> + {getDocumentIcon( + document.type, + "w-4 h-4 md:w-5 md:h-5 text-foreground", + )} + </div> + <div className="flex-1 min-w-0"> + <TitleComponent className="text-foreground text-sm md:text-base truncate text-left"> + {document.title || "Untitled Document"} + </TitleComponent> + <div className="flex items-center gap-1.5 md:gap-2 mt-1 text-[10px] md:text-xs text-muted-foreground flex-wrap"> + <span>{formatDocumentType(document.type)}</span> + <span>•</span> + <span>{formatDate(document.createdAt)}</span> + {document.url && ( + <> + <span>•</span> + <button + className="flex items-center gap-0.5 md:gap-1 transition-all hover:gap-1 md:hover:gap-2 text-primary hover:text-primary/80 whitespace-nowrap" + onClick={() => { + const sourceUrl = getSourceUrl(document) + window.open(sourceUrl ?? undefined, "_blank") + }} + type="button" + > + <span className="hidden sm:inline">View source</span> + <span className="sm:hidden">Source</span> + <ExternalLink className="w-2.5 h-2.5 md:w-3 md:h-3" /> + </button> + </> + )} + </div> + </div> + </div> + </div> + ) + + const ContentDisplaySection = () => { + const hasContent = document.content && document.content.trim().length > 0 + + if (!hasContent) { + return ( + <div className="text-center py-12 rounded-lg bg-muted/5"> + <CircleUserRound className="w-12 h-12 mx-auto mb-4 opacity-30 text-muted-foreground" /> + <p className="text-muted-foreground"> + No content available for this document + </p> + </div> + ) + } + + return ( + <div className="p-3 md:p-4 rounded-lg bg-muted/5 border border-border h-full overflow-y-auto max-w-3xl"> + <HTMLContentRenderer content={document.content || ""} /> + </div> + ) + } + + const SummaryDisplaySection = () => { + const hasSummary = document.summary && document.summary.trim().length > 0 + + if (!hasSummary) { + return ( + <div className="text-center py-6 rounded-lg bg-muted/5"> + <List className="w-6 h-6 mx-auto mb-2 opacity-30 text-muted-foreground" /> + <p className="text-muted-foreground text-xs"> + No summary available + </p> + </div> + ) + } + + return ( + <div className="p-2.5 md:p-3 px-3 md:px-4 rounded-lg bg-primary/5 border border-primary/15"> + <p className="text-xs md:text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground"> + {document.summary} + </p> + </div> + ) + } + + const MemoryContent = () => ( + <div className="space-y-6"> + {activeMemories.length > 0 && ( + <div> + <div className="text-sm font-medium mb-2 flex items-start gap-2 py-2 text-muted-foreground"> + Active Memories ({activeMemories.length}) + </div> + <div className="space-y-3"> + {activeMemories.map((memory) => ( + <div key={memory.id}> + <MemoryDetailItem memory={memory} /> + </div> + ))} + </div> + </div> + )} + + {forgottenMemories.length > 0 && ( + <div> + <div className="text-sm font-medium mb-4 px-3 py-2 rounded-lg opacity-60 text-muted-foreground bg-muted/5"> + Forgotten Memories ({forgottenMemories.length}) + </div> + <div className="space-y-3 opacity-40"> + {forgottenMemories.map((memory) => ( + <MemoryDetailItem key={memory.id} memory={memory} /> + ))} + </div> + </div> + )} + + {activeMemories.length === 0 && forgottenMemories.length === 0 && ( + <div className="text-center py-12 rounded-lg bg-muted/5"> + <Brain className="w-12 h-12 mx-auto mb-4 opacity-30 text-muted-foreground" /> + <p className="text-muted-foreground"> + No memories found for this document + </p> + </div> + )} + </div> + ) + + if (isMobile) { + return ( + <Drawer onOpenChange={onClose} open={isOpen}> + <DrawerContent className="border-0 p-0 overflow-hidden max-h-[95vh] bg-background border-t border-border backdrop-blur-xl flex flex-col"> + <div className="p-3 md:p-4 relative bg-muted/5 flex-shrink-0"> + <DrawerHeader className="p-0 text-left"> + <HeaderContent TitleComponent={DrawerTitle} /> + </DrawerHeader> + </div> + + <div className="flex-1 overflow-y-auto"> + <div className="border-b border-border"> + <div className="p-2.5 md:p-3 bg-muted/5"> + <h4 className="text-sm md:text-base font-medium text-foreground"> + Content + </h4> + </div> + <div className="p-3 md:p-4"> + <ContentDisplaySection /> + </div> + </div> + + <div className="border-b border-border"> + <div className="p-2.5 md:p-3 bg-muted/5"> + <h4 className="text-sm md:text-base font-medium text-foreground"> + Summary + </h4> + </div> + <div className="p-3 md:p-4"> + <SummaryDisplaySection /> + </div> + </div> + + <div> + <div className="px-2.5 pt-2.5 md:p-3 bg-muted/5"> + <h4 className="text-sm md:text-base font-medium text-foreground"> + Memories + </h4> + </div> + <div className="p-3 md:p-4"> + <MemoryContent /> + </div> + </div> + </div> + </DrawerContent> + </Drawer> + ) + } + + return ( + <Dialog onOpenChange={onClose} open={isOpen}> + <DialogContent className="w-[95vw] md:w-[90vw] lg:w-[85vw] h-[90vh] border-0 p-0 overflow-hidden flex flex-col bg-background !max-w-7xl gap-0"> + <div className="p-4 md:p-6 relative flex-shrink-0 bg-muted/5"> + <DialogHeader className="pb-0"> + <HeaderContent TitleComponent={DialogTitle} /> + </DialogHeader> + </div> + + <div className="flex-1 flex flex-col lg:flex-row overflow-hidden"> + <div className="flex-1 flex flex-col h-full justify-between min-w-0"> + <div className="p-2 px-3 md:px-4 overflow-y-auto custom-scrollbar transition-all duration-300"> + <h3 className="font-medium text-[10px] md:text-xs text-muted-foreground uppercase pb-1 px-1"> + Content + </h3> + <ContentDisplaySection /> + </div> + + <div className="transition-all duration-300 mx-2 mb-3 md:mb-4 flex-shrink-0"> + <div className="bg-card border border-border rounded-xl shadow-lg backdrop-blur-sm h-full flex flex-col"> + <button + onClick={() => setIsSummaryOpen(!isSummaryOpen)} + className="flex-shrink-0 w-full flex items-center justify-between p-3 md:p-4 hover:bg-muted/5 transition-colors rounded-t-xl" + type="button" + > + <div className="flex items-center gap-1.5 md:gap-2"> + <h3 className="font-semibold text-xs md:text-sm text-foreground"> + Summary + </h3> + {document.summary && + document.summary.trim().length > 0 && ( + <Badge + className="text-[10px] h-5" + variant="secondary" + > + Available + </Badge> + )} + </div> + {isSummaryOpen ? ( + <ChevronDown className="w-4 h-4 text-muted-foreground" /> + ) : ( + <ChevronUp className="w-4 h-4 text-muted-foreground" /> + )} + </button> + + {isSummaryOpen && ( + <div className="flex-1 px-3 md:px-4 pb-3 md:pb-4 overflow-hidden min-h-0"> + <div className="h-full overflow-y-auto custom-scrollbar"> + <SummaryDisplaySection /> + </div> + </div> + )} + </div> + </div> + </div> + + <div className="w-full lg:w-96 flex flex-col border-t lg:border-t-0 lg:border-l border-border"> + <div className="flex-1 flex flex-col"> + <div className="flex-1 memory-dialog-scroll overflow-y-auto p-3 md:p-4"> + <MemoryContent /> + </div> + </div> + </div> + </div> + </DialogContent> + </Dialog> + ) + }, +) diff --git a/apps/web/components/memories.tsx b/apps/web/components/memories.tsx new file mode 100644 index 00000000..568e9eb5 --- /dev/null +++ b/apps/web/components/memories.tsx @@ -0,0 +1,276 @@ +"use client" + +import { useAuth } from "@lib/auth-context" +import { $fetch } from "@repo/lib/api" +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" +import { useInfiniteQuery } from "@tanstack/react-query" +import { useCallback, useEffect, useMemo, useState } from "react" +import type { z } from "zod" +import { MemoryGraph } from "@repo/ui/memory-graph" +import { Dialog, DialogContent } from "@repo/ui/components/dialog" +import { ConnectAIModal } from "@/components/connect-ai-modal" +import { MasonryMemoryList } from "@/components/masonry-memory-list" +import { AddMemoryView } from "@/components/views/add-memory" +import { useChatOpen, useProject, useGraphModal } from "@/stores" +import { useGraphHighlights } from "@/stores/highlights" + +type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema> +type DocumentWithMemories = DocumentsResponse["documents"][0] + +export function Memories() { + const { user } = useAuth() + const { documentIds: allHighlightDocumentIds } = useGraphHighlights() + const { selectedProject } = useProject() + const { isOpen } = useChatOpen() + const { isOpen: showGraphModal, setIsOpen: setShowGraphModal } = + useGraphModal() + const [injectedDocs, setInjectedDocs] = useState<DocumentWithMemories[]>([]) + const [showAddMemoryView, setShowAddMemoryView] = useState(false) + const [showConnectAIModal, setShowConnectAIModal] = useState(false) + + const IS_DEV = process.env.NODE_ENV === "development" + const PAGE_SIZE = IS_DEV ? 100 : 100 + const MAX_TOTAL = 1000 + + const { + data, + error, + isPending, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useInfiniteQuery<DocumentsResponse, Error>({ + queryKey: ["documents-with-memories", selectedProject], + initialPageParam: 1, + queryFn: async ({ pageParam }) => { + const response = await $fetch("@post/documents/documents", { + body: { + page: pageParam as number, + limit: (pageParam as number) === 1 ? (IS_DEV ? 500 : 500) : PAGE_SIZE, + sort: "createdAt", + order: "desc", + containerTags: selectedProject ? [selectedProject] : undefined, + }, + disableValidation: true, + }) + + if (response.error) { + throw new Error(response.error?.message || "Failed to fetch documents") + } + + return response.data + }, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce( + (acc, p) => acc + (p.documents?.length ?? 0), + 0, + ) + if (loaded >= MAX_TOTAL) return undefined + + const { currentPage, totalPages } = lastPage.pagination + if (currentPage < totalPages) { + return currentPage + 1 + } + return undefined + }, + staleTime: 5 * 60 * 1000, + enabled: !!user, // Only run query if user is authenticated + }) + + const baseDocuments = useMemo(() => { + 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]) + + const totalLoaded = allDocuments.length + const hasMore = hasNextPage + const isLoadingMore = isFetchingNextPage + + const loadMoreDocuments = useCallback(async (): Promise<void> => { + if (hasNextPage && !isFetchingNextPage) { + await fetchNextPage() + return + } + return + }, [hasNextPage, isFetchingNextPage, fetchNextPage]) + + // Handle highlighted documents injection for chat + useEffect(() => { + 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.customId) present.add(d.customId as string) + } + 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/documents/documents/by-ids", { + body: { + ids: missing, + by: "customId", + containerTags: selectedProject ? [selectedProject] : undefined, + }, + disableValidation: true, + }) + if (cancelled || resp?.error) return + const extraDocs = resp?.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] + for (const doc of extraDocs) { + if (!seen.has(doc.id)) { + merged.push(doc) + seen.add(doc.id) + } + } + return merged + }) + } catch {} + } + void run() + return () => { + cancelled = true + } + }, [ + isOpen, + allHighlightDocumentIds, + baseDocuments, + injectedDocs, + selectedProject, + ]) + + // Show connect AI modal if no documents + useEffect(() => { + if (allDocuments.length === 0) { + setShowConnectAIModal(true) + } + }, [allDocuments.length]) + + if (!user) { + return ( + <div className="flex items-center justify-center h-full"> + <div className="text-center text-muted-foreground"> + <p>Please log in to view your memories</p> + </div> + </div> + ) + } + + return ( + <> + <div className="relative h-full mx-4 md:mx-24"> + <MasonryMemoryList + documents={allDocuments} + error={error} + hasMore={hasMore} + isLoading={isPending} + isLoadingMore={isLoadingMore} + loadMoreDocuments={loadMoreDocuments} + totalLoaded={totalLoaded} + > + <div className="absolute inset-0 flex items-center justify-center"> + <ConnectAIModal + onOpenChange={setShowConnectAIModal} + open={showConnectAIModal} + > + <div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6"> + <div className="relative z-10 text-slate-200 text-center"> + <div className="flex flex-col gap-3"> + <button + className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline" + onClick={(e) => { + e.stopPropagation() + setShowAddMemoryView(true) + setShowConnectAIModal(false) + }} + type="button" + > + Add your first memory + </button> + </div> + </div> + </div> + </ConnectAIModal> + </div> + </MasonryMemoryList> + + {showAddMemoryView && ( + <AddMemoryView + initialTab="note" + onClose={() => setShowAddMemoryView(false)} + /> + )} + </div> + + {/* Memory Graph Modal */} + <Dialog open={showGraphModal} onOpenChange={setShowGraphModal}> + <DialogContent + className="w-[95vw] h-[95vh] p-0 max-w-6xl sm:max-w-6xl" + showCloseButton={true} + > + <div className="w-full h-full"> + <MemoryGraph + documents={allDocuments} + error={error} + hasMore={hasMore} + isLoading={isPending} + isLoadingMore={isLoadingMore} + loadMoreDocuments={loadMoreDocuments} + totalLoaded={totalLoaded} + variant="console" + showSpacesSelector={true} + highlightDocumentIds={allHighlightDocumentIds} + highlightsVisible={isOpen} + > + <div className="absolute inset-0 flex items-center justify-center"> + <ConnectAIModal + onOpenChange={setShowConnectAIModal} + open={showConnectAIModal} + > + <div className="rounded-xl overflow-hidden cursor-pointer hover:bg-white/5 transition-colors p-6"> + <div className="relative z-10 text-slate-200 text-center"> + <div className="flex flex-col gap-3"> + <button + className="text-sm text-blue-400 hover:text-blue-300 transition-colors underline" + onClick={(e) => { + e.stopPropagation() + setShowAddMemoryView(true) + setShowConnectAIModal(false) + }} + type="button" + > + Add your first memory + </button> + </div> + </div> + </div> + </ConnectAIModal> + </div> + </MemoryGraph> + </div> + </DialogContent> + </Dialog> + </> + ) +} diff --git a/apps/web/components/memories/index.tsx b/apps/web/components/memories/index.tsx deleted file mode 100644 index 97ef57bd..00000000 --- a/apps/web/components/memories/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { DocumentWithMemories } from "@ui/memory-graph/types"; - -export 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 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); - - if (dateYear !== currentYear) { - return `${month} ${formattedDay}, ${dateYear}`; - } - - return `${month} ${formattedDay}`; -}; - -export const getSourceUrl = (document: DocumentWithMemories) => { - if (document.type === "google_doc" && 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}`; - } - if (document.type === "google_slide" && document.customId) { - return `https://docs.google.com/presentation/d/${document.customId}`; - } - // Fallback to existing URL for all other document types - return document.url; -};
\ No newline at end of file diff --git a/apps/web/components/memories/memory-detail.tsx b/apps/web/components/memories/memory-detail.tsx deleted file mode 100644 index dad2a8a3..00000000 --- a/apps/web/components/memories/memory-detail.tsx +++ /dev/null @@ -1,415 +0,0 @@ -import { getDocumentIcon } from '@/lib/document-icon'; -import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle, -} from '@repo/ui/components/drawer'; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from '@repo/ui/components/sheet'; -import { - Tabs, - TabsList, - TabsTrigger, - TabsContent, -} from '@repo/ui/components/tabs'; -import { colors } from '@repo/ui/memory-graph/constants'; -import type { DocumentsWithMemoriesResponseSchema } from '@repo/validation/api'; -import { Badge } from '@ui/components/badge'; -import { Brain, Calendar, CircleUserRound, ExternalLink, List, Sparkles } from 'lucide-react'; -import { memo } from 'react'; -import type { z } from 'zod'; -import { formatDate, getSourceUrl } from '.'; -import { Label1Regular } from '@ui/text/label/label-1-regular'; - -type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>; -type DocumentWithMemories = DocumentsResponse['documents'][0]; -type MemoryEntry = DocumentWithMemories['memoryEntries'][0]; - -const formatDocumentType = (type: string) => { - // Special case for 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(' '); -}; - -const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => { - return ( - <button - className="p-4 rounded-lg transition-all relative overflow-hidden cursor-pointer" - style={{ - backgroundColor: memory.isLatest - ? colors.memory.primary - : 'rgba(255, 255, 255, 0.02)', - }} - tabIndex={0} - type="button" - > - <div className="flex items-start gap-2 relative z-10"> - <div - className="p-1 rounded" - style={{ - backgroundColor: memory.isLatest - ? colors.memory.secondary - : 'transparent', - }} - > - <Brain - 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"> - <Label1Regular - className="text-sm leading-relaxed text-left" - style={{ color: colors.text.primary }} - > - {memory.memory} - </Label1Regular> - <div className="flex gap-2 justify-between"> - <div - className="flex items-center gap-4 text-xs" - style={{ color: colors.text.muted }} - > - <span className="flex items-center gap-1"> - <Calendar className="w-3 h-3" /> - {formatDate(memory.createdAt)} - </span> - <span className="font-mono">v{memory.version}</span> - {memory.sourceRelevanceScore && ( - <span - className="flex items-center gap-1" - style={{ - color: - memory.sourceRelevanceScore > 70 - ? colors.accent.emerald - : colors.text.muted, - }} - > - <Sparkles className="w-3 h-3" /> - {memory.sourceRelevanceScore}% - </span> - )} - </div> - <div className="flex items-center gap-2 flex-wrap"> - {memory.isForgotten && ( - <Badge - className="text-xs border-red-500/30 backdrop-blur-sm" - style={{ - backgroundColor: colors.status.forgotten, - color: '#dc2626', - backdropFilter: 'blur(4px)', - WebkitBackdropFilter: 'blur(4px)', - }} - variant="destructive" - > - Forgotten - </Badge> - )} - {memory.isLatest && ( - <Badge - className="text-xs" - style={{ - backgroundColor: colors.memory.secondary, - color: colors.text.primary, - backdropFilter: 'blur(4px)', - WebkitBackdropFilter: 'blur(4px)', - }} - variant="default" - > - Latest - </Badge> - )} - {memory.forgetAfter && ( - <Badge - className="text-xs backdrop-blur-sm" - style={{ - color: colors.status.expiring, - backgroundColor: 'rgba(251, 165, 36, 0.1)', - backdropFilter: 'blur(4px)', - WebkitBackdropFilter: 'blur(4px)', - }} - variant="outline" - > - Expires: {formatDate(memory.forgetAfter)} - </Badge> - )} - </div> - </div> - </div> - </div> - </button> - ); -}); - -export const MemoryDetail = memo( - ({ - document, - isOpen, - onClose, - isMobile, - }: { - document: DocumentWithMemories | null; - isOpen: boolean; - onClose: () => void; - isMobile: boolean; - }) => { - if (!document) return null; - - const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten); - const forgottenMemories = document.memoryEntries.filter( - (m) => m.isForgotten - ); - - 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 - className="p-2 rounded-lg" - style={{ - backgroundColor: colors.background.secondary, - }} - > - {getDocumentIcon(document.type, 'w-5 h-5')} - </div> - <div className="flex-1"> - <TitleComponent style={{ color: colors.text.primary }}> - {document.title || 'Untitled Document'} - </TitleComponent> - <div - className="flex items-center gap-2 mt-1 text-xs" - style={{ color: colors.text.muted }} - > - <span>{formatDocumentType(document.type)}</span> - <span>•</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'); - }} - style={{ color: colors.accent.primary }} - type="button" - > - View source - <ExternalLink className="w-3 h-3" /> - </button> - </> - )} - </div> - </div> - </div> - </div> - ); - - const ContentAndSummarySection = () => { - const hasContent = document.content && document.content.trim().length > 0; - const hasSummary = document.summary && document.summary.trim().length > 0; - - if (!hasContent && !hasSummary) return null; - - const defaultTab = hasContent ? 'content' : 'summary'; - - return ( - <div className="mt-4"> - <Tabs defaultValue={defaultTab} className="w-full"> - <TabsList - className={`grid w-full bg-white/5 border border-white/10 h-11 ${ - hasContent && hasSummary ? 'grid-cols-2' : 'grid-cols-1' - }`} - > - {hasContent && ( - <TabsTrigger - value="content" - className="text-xs bg-transparent h-8" - style={{ color: colors.text.secondary }} - > - <CircleUserRound className="w-3 h-3" /> - Original Content - </TabsTrigger> - )} - {hasSummary && ( - <TabsTrigger - value="summary" - className="text-xs flex items-center gap-1 bg-transparent h-8" - style={{ color: colors.text.secondary }} - > - <List className="w-3 h-3" /> - Summary - </TabsTrigger> - )} - </TabsList> - - {hasContent && ( - <TabsContent value="content" className="mt-3"> - <div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-white/[0.03] border border-white/[0.08]"> - <p - className="text-sm leading-relaxed whitespace-pre-wrap" - style={{ color: colors.text.primary }} - > - {document.content} - </p> - </div> - </TabsContent> - )} - - {hasSummary && ( - <TabsContent value="summary" className="mt-3"> - <div className="p-3 rounded-lg max-h-48 overflow-y-auto custom-scrollbar bg-indigo-500/5 border border-indigo-500/15"> - <p - className="text-sm leading-relaxed whitespace-pre-wrap" - style={{ color: colors.text.muted }} - > - {document.summary} - </p> - </div> - </TabsContent> - )} - </Tabs> - </div> - ); - }; - - const MemoryContent = () => ( - <div className="space-y-6 px-6"> - {activeMemories.length > 0 && ( - <div> - <div - className="text-sm font-medium mb-2 flex items-start gap-2 py-2" - style={{ - color: colors.text.secondary, - }} - > - Active Memories ({activeMemories.length}) - </div> - <div className="space-y-3"> - {activeMemories.map((memory) => ( - <div - key={memory.id} - > - <MemoryDetailItem memory={memory} /> - </div> - ))} - </div> - </div> - )} - - {forgottenMemories.length > 0 && ( - <div> - <div - className="text-sm font-medium mb-4 px-3 py-2 rounded-lg opacity-60" - style={{ - color: colors.text.muted, - backgroundColor: 'rgba(255, 255, 255, 0.02)', - }} - > - Forgotten Memories ({forgottenMemories.length}) - </div> - <div className="space-y-3 opacity-40"> - {forgottenMemories.map((memory) => ( - <MemoryDetailItem key={memory.id} memory={memory} /> - ))} - </div> - </div> - )} - - {activeMemories.length === 0 && forgottenMemories.length === 0 && ( - <div - className="text-center py-12 rounded-lg" - style={{ - backgroundColor: 'rgba(255, 255, 255, 0.02)', - }} - > - <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 ( - <Drawer onOpenChange={onClose} open={isOpen}> - <DrawerContent - className="border-0 p-0 overflow-hidden max-h-[90vh]" - style={{ - backgroundColor: colors.background.secondary, - borderTop: `1px solid ${colors.document.border}`, - backdropFilter: 'blur(20px)', - WebkitBackdropFilter: 'blur(20px)', - }} - > - {/* Header section with glass effect */} - <div - className="p-4 relative border-b" - style={{ - backgroundColor: 'rgba(255, 255, 255, 0.02)', - borderBottom: `1px solid ${colors.document.border}`, - }} - > - <DrawerHeader className="pb-0 px-0 text-left"> - <HeaderContent TitleComponent={DrawerTitle} /> - </DrawerHeader> - - <ContentAndSummarySection /> - </div> - - <div className="flex-1 memory-drawer-scroll overflow-y-auto"> - <MemoryContent /> - </div> - </DrawerContent> - </Drawer> - ); - } - - return ( - <Sheet onOpenChange={onClose} open={isOpen}> - <SheetContent - className="w-full sm:max-w-2xl border-0 p-0 overflow-hidden" - style={{ - backgroundColor: colors.background.secondary, - }} - > - <div - className="p-6 relative" - style={{ - backgroundColor: 'rgba(255, 255, 255, 0.02)', - }} - > - <SheetHeader className="pb-0"> - <HeaderContent TitleComponent={SheetTitle} /> - </SheetHeader> - - <ContentAndSummarySection /> - </div> - - <div className="h-[calc(100vh-200px)] memory-sheet-scroll overflow-y-auto"> - <MemoryContent /> - </div> - </SheetContent> - </Sheet> - ); - } -); diff --git a/apps/web/components/memory-list-view.tsx b/apps/web/components/memory-list-view.tsx index b91e2562..c2b4b0c8 100644 --- a/apps/web/components/memory-list-view.tsx +++ b/apps/web/components/memory-list-view.tsx @@ -26,9 +26,9 @@ import { analytics } from "@/lib/analytics" import { useDeleteDocument } from "@lib/queries" import { useProject } from "@/stores" -import { MemoryDetail } from "./memories/memory-detail" +import { MemoryDetail } from "./memories-utils/memory-detail" import { getDocumentIcon } from "@/lib/document-icon" -import { formatDate, getSourceUrl } from "./memories" +import { formatDate, getSourceUrl } from "./memories-utils" type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema> type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -44,31 +44,6 @@ interface MemoryListViewProps { 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" - } - - return ( - <div className="flex items-center gap-3 mb-3 px-4 md:mb-6 md:mt-3"> - <div> - <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 }}> - Welcome back to your memory collection - </p> - </div> - </div> - ) -}) - const DocumentCard = memo( ({ document, @@ -86,7 +61,7 @@ const DocumentCard = memo( return ( <Card - className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden border-0 gap-2 md:w-full" + className="h-full mx-4 p-4 transition-all cursor-pointer group relative overflow-hidden gap-2 md:w-full shadow-xs" onClick={() => { analytics.documentCardClicked() onOpenDetails(document) @@ -312,7 +287,7 @@ export const MemoryListView = ({ hasMore, isLoadingMore, loadMoreDocuments, - virtualizer.getVirtualItems(), + virtualizer.getVirtualItems, virtualItems.length, ]) @@ -322,7 +297,6 @@ export const MemoryListView = ({ <div className="h-full overflow-hidden relative pb-20" ref={containerRef} - style={{ backgroundColor: colors.background.primary }} > {error ? ( <div className="h-full flex items-center justify-center p-4"> @@ -358,8 +332,6 @@ export const MemoryListView = ({ ref={parentRef} className="h-full overflow-auto mt-20 custom-scrollbar" > - <GreetingMessage /> - <div className="w-full relative" style={{ @@ -375,13 +347,13 @@ export const MemoryListView = ({ key={virtualRow.key} data-index={virtualRow.index} ref={virtualizer.measureElement} - className="absolute top-0 left-0 w-full" + className="absolute top-0 left-0 w-full sm-tweet-theme" style={{ transform: `translateY(${virtualRow.start + virtualRow.index * gap}px)`, }} > <div - className="grid justify-start" + className="grid justify-center" style={{ gridTemplateColumns: `repeat(${columns}, ${columnWidth}px)`, gap: `${gap}px`, diff --git a/apps/web/components/menu.tsx b/apps/web/components/menu.tsx index db012ab7..e59f3839 100644 --- a/apps/web/components/menu.tsx +++ b/apps/web/components/menu.tsx @@ -10,21 +10,19 @@ import { ConnectAIModal } from "./connect-ai-modal" 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, Puzzle, User, X } from "lucide-react" +import { Plus, Puzzle, User, X } from "lucide-react" import { AnimatePresence, LayoutGroup, motion } from "motion/react" import { useRouter, useSearchParams } from "next/navigation" import { useCallback, 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 { IntegrationsView } from "./views/integrations" import { ProfileView } from "./views/profile" -const MCPIcon = ({ className }: { className?: string }) => { +export const MCPIcon = ({ className }: { className?: string }) => { return ( <svg className={className} @@ -65,11 +63,10 @@ function Menu({ id }: { id?: string }) { const [showConnectAIModal, setShowConnectAIModal] = useState(false) const isMobile = useIsMobile() const { activePanel, setActivePanel } = useMobilePanel() - const { setMenuExpanded } = useTour() const autumn = useCustomer() const { setIsOpen } = useChatOpen() - const { data: memoriesCheck } = fetchMemoriesFeature(autumn) + const { data: memoriesCheck } = fetchMemoriesFeature(autumn, !autumn.isLoading) const memoriesUsed = memoriesCheck?.usage ?? 0 const memoriesLimit = memoriesCheck?.included_usage ?? 0 @@ -99,14 +96,6 @@ function Menu({ id }: { id?: string }) { const shouldShowLimitWarning = !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, - integrations: "", // No tour ID for integrations yet - } - const menuItems = [ { icon: Plus, @@ -115,12 +104,6 @@ function Menu({ id }: { id?: string }) { disabled: false, }, { - icon: MessageSquareMore, - text: "Chat", - key: "chat" as const, - disabled: false, - }, - { icon: Puzzle, text: "Integrations", key: "integrations" as const, @@ -221,14 +204,6 @@ function Menu({ id }: { id?: string }) { } }, [isMobile, activePanel]) - // Notify tour provider about expansion state changes - useEffect(() => { - const isExpanded = isMobile - ? isMobileMenuOpen || !!expandedView - : isHovered || !!expandedView - setMenuExpanded(isExpanded) - }, [isMobile, isMobileMenuOpen, isHovered, expandedView, setMenuExpanded]) - // Calculate width based on state const menuWidth = expandedView || isCollapsing ? 600 : isHovered ? 160 : 56 @@ -267,14 +242,9 @@ function Menu({ id }: { id?: string }) { }, }} > - {/* Glass effect background */} - <motion.div className="absolute inset-0" layout> - <GlassMenuEffect /> - </motion.div> - {/* Menu content */} <motion.div - className="relative z-20 flex flex-col gap-6 w-full" + className="relative z-20 flex flex-col gap-6 w-full bg-white" layout > <AnimatePresence @@ -323,8 +293,7 @@ function Menu({ id }: { id?: string }) { duration: 0.1, }, }} - className={`flex w-full items-center text-white/80 transition-colors duration-100 hover:text-white cursor-pointer relative ${isHovered || expandedView ? "px-1" : ""}`} - id={menuItemTourIds[item.key]} + className={`flex w-full items-center transition-colors duration-100 cursor-pointer relative ${isHovered || expandedView ? "px-1" : ""}`} initial={{ opacity: 0, y: 20, scale: 0.95 }} layout onClick={() => handleMenuItemClick(item.key)} @@ -346,14 +315,14 @@ function Menu({ id }: { id?: string }) { initial={{ scale: 0.8 }} layout="position" > - <item.icon className="duration-200 h-6 w-6 drop-shadow-lg flex-shrink-0" /> + <item.icon className="duration-200 h-6 w-6 flex-shrink-0" /> </motion.div> <motion.p animate={{ opacity: isHovered ? 1 : 0, x: isHovered ? 0 : -10, }} - className="drop-shadow-lg pl-3 whitespace-nowrap" + className="pl-3 whitespace-nowrap" initial={{ opacity: 0, x: -10 }} style={{ transform: "translateZ(0)", @@ -373,7 +342,7 @@ function Menu({ id }: { id?: string }) { opacity: 1, scaleX: 1, }} - className="w-full h-px bg-white/20 mt-3 origin-left" + className="w-full h-px bg-black/20 mt-3 origin-left" initial={{ opacity: 0, scaleX: 0 }} transition={{ duration: 0.3, @@ -590,7 +559,6 @@ function Menu({ id }: { id?: string }) { }, }} 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={() => { diff --git a/apps/web/components/project-selector.tsx b/apps/web/components/project-selector.tsx index a592c474..0682a641 100644 --- a/apps/web/components/project-selector.tsx +++ b/apps/web/components/project-selector.tsx @@ -1,8 +1,8 @@ -"use client"; +"use client" -import { $fetch } from "@repo/lib/api"; -import { DEFAULT_PROJECT_ID } from "@repo/lib/constants"; -import { Button } from "@repo/ui/components/button"; +import { $fetch } from "@repo/lib/api" +import { DEFAULT_PROJECT_ID } from "@repo/lib/constants" +import { Button } from "@repo/ui/components/button" import { Dialog, DialogContent, @@ -10,141 +10,139 @@ 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 { Label } from "@repo/ui/components/label"; +} 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +} 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"; +} 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`, - ); + ) if (response.error) { 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"> - <motion.button - className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-white/5 hover:bg-white/10 transition-colors" + <Button + variant="ghost" + className="flex items-center gap-1.5 px-2 py-1.5 rounded-md transition-colors" onClick={() => setIsOpen(!isOpen)} - whileHover={{ scale: 1.01 }} - whileTap={{ scale: 0.99 }} > - <FolderIcon className="h-3.5 w-3.5 text-white/70" /> - <span className="text-xs font-medium text-white/90 max-w-32 truncate"> + <FolderIcon className="h-3.5 w-3.5" /> + <span className="text-xs font-medium max-w-32 truncate"> {isLoading ? "..." : projectName} </span> <motion.div animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: 0.15 }} > - <ChevronDown className="h-3 w-3 text-white/50" /> + <ChevronDown className="h-3 w-3" /> </motion.div> - </motion.button> + </Button> <AnimatePresence> {isOpen && ( @@ -158,29 +156,27 @@ export function ProjectSelector() { /> <motion.div - className="absolute top-full left-0 mt-1 w-56 bg-[#0f1419] backdrop-blur-xl border border-white/10 rounded-md shadow-xl z-50 overflow-hidden" + className="absolute top-full left-0 mt-1 w-56 bg-background/95 backdrop-blur-xl border border-border rounded-md shadow-xl z-50 overflow-hidden" initial={{ opacity: 0, y: -5, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -5, scale: 0.98 }} transition={{ duration: 0.15 }} > <div className="p-1.5 max-h-64 overflow-y-auto"> - {/* Default Project */} - <motion.div + <Button + variant="ghost" className={`flex items-center justify-between p-2 rounded-md transition-colors cursor-pointer ${ selectedProject === DEFAULT_PROJECT_ID - ? "bg-white/15" - : "hover:bg-white/8" + ? "bg-accent" + : "hover:bg-accent/50" }`} onClick={() => handleProjectSelect(DEFAULT_PROJECT_ID)} > <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> + <FolderIcon className="h-3.5 w-3.5" /> + <span className="text-xs font-medium">Default</span> </div> - </motion.div> + </Button> {/* User Projects */} {projects @@ -190,71 +186,69 @@ export function ProjectSelector() { key={project.id} className={`flex items-center justify-between p-2 rounded-md transition-colors group ${ selectedProject === project.containerTag - ? "bg-white/15" - : "hover:bg-white/8" + ? "bg-accent" + : "hover:bg-accent/50" }`} initial={{ opacity: 0, x: -5 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: index * 0.03 }} > - <div + <button className="flex items-center gap-2 flex-1 cursor-pointer" + type="button" 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"> + <FolderIcon className="h-3.5 w-3.5 opacity-70" /> + <span className="text-xs font-medium truncate max-w-32"> {project.name} </span> - </div> + </button> <div className="flex items-center gap-1"> <DropdownMenu> <DropdownMenuTrigger asChild> <motion.button - className="p-1 hover:bg-white/10 rounded transition-all" + className="p-1 hover:bg-accent rounded transition-all" onClick={(e) => e.stopPropagation()} whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} > - <MoreHorizontal className="h-3 w-3 text-white/50" /> + <MoreHorizontal className="h-3 w-3" /> </motion.button> </DropdownMenuTrigger> - <DropdownMenuContent - align="end" - className="bg-black/90 border-white/10" - > + <DropdownMenuContent align="end"> {/* Show experimental toggle only if NOT experimental and NOT default project */} {!project.isExperimental && project.containerTag !== DEFAULT_PROJECT_ID && ( <DropdownMenuItem - className="text-blue-400 hover:text-blue-300 cursor-pointer text-xs" + className="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark: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" /> + <div className="h-3 w-3 mr-2 rounded border border-blue-600 dark:border-blue-400" /> Enable Experimental Mode </DropdownMenuItem> )} {project.isExperimental && ( <DropdownMenuItem - className="text-blue-300/50 text-xs" + className="text-blue-600/50 dark:text-blue-300/50 text-xs" disabled > - <div className="h-3 w-3 mr-2 rounded bg-blue-400" /> + <div className="h-3 w-3 mr-2 rounded bg-blue-600 dark:bg-blue-400" /> Experimental Mode Active </DropdownMenuItem> )} <DropdownMenuItem - className="text-red-400 hover:text-red-300 cursor-pointer text-xs" + className="text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300 cursor-pointer text-xs" onClick={(e) => { - e.stopPropagation(); + e.stopPropagation() setDeleteDialog({ open: true, project: { @@ -264,8 +258,8 @@ export function ProjectSelector() { }, action: "move", targetProjectId: "", - }); - setIsOpen(false); + }) + setIsOpen(false) }} > <Trash2 className="h-3 w-3 mr-2" /> @@ -278,15 +272,15 @@ export function ProjectSelector() { ))} <motion.div - className="flex items-center gap-2 p-2 rounded-md hover:bg-white/8 transition-colors cursor-pointer border-t border-white/10 mt-1" + className="flex items-center gap-2 p-2 rounded-md hover:bg-accent/50 transition-colors cursor-pointer border-t border-border mt-1" onClick={handleCreateNewProject} whileHover={{ x: 1 }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} 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"> + <Plus className="h-3.5 w-3.5 text-foreground/70" /> + <span className="text-xs font-medium text-foreground/80"> New Project </span> </motion.div> @@ -310,7 +304,7 @@ export function ProjectSelector() { } open={deleteDialog.open} > - <DialogContent className="sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white"> + <DialogContent className="sm:max-w-2xl"> <motion.div animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} @@ -318,7 +312,7 @@ export function ProjectSelector() { > <DialogHeader> <DialogTitle>Delete Project</DialogTitle> - <DialogDescription className="text-white/60"> + <DialogDescription> Are you sure you want to delete "{deleteDialog.project.name} "? Choose what to do with the documents in this project. </DialogDescription> @@ -339,10 +333,7 @@ export function ProjectSelector() { } type="radio" /> - <Label - className="text-white cursor-pointer text-sm" - htmlFor="move" - > + <Label className="cursor-pointer text-sm" htmlFor="move"> Move documents to another project </Label> </div> @@ -362,14 +353,11 @@ export function ProjectSelector() { } value={deleteDialog.targetProjectId} > - <SelectTrigger className="w-full bg-white/5 border-white/10 text-white"> + <SelectTrigger className="w-full"> <SelectValue placeholder="Select target project..." /> </SelectTrigger> - <SelectContent className="bg-black/90 backdrop-blur-xl border-white/10"> - <SelectItem - className="text-white hover:bg-white/10" - value={DEFAULT_PROJECT_ID} - > + <SelectContent> + <SelectItem value={DEFAULT_PROJECT_ID}> Default Project </SelectItem> {projects @@ -379,11 +367,7 @@ export function ProjectSelector() { p.containerTag !== DEFAULT_PROJECT_ID, ) .map((project: Project) => ( - <SelectItem - className="text-white hover:bg-white/10" - key={project.id} - value={project.id} - > + <SelectItem key={project.id} value={project.id}> {project.name} </SelectItem> ))} @@ -406,7 +390,7 @@ export function ProjectSelector() { type="radio" /> <Label - className="text-white cursor-pointer text-sm" + className="cursor-pointer text-sm" htmlFor="delete" > Delete all documents in this project @@ -415,7 +399,7 @@ export function ProjectSelector() { {deleteDialog.action === "delete" && ( <motion.p animate={{ opacity: 1 }} - className="text-sm text-red-400 ml-6" + className="text-sm text-red-600 dark:text-red-400 ml-6" initial={{ opacity: 0 }} > ⚠️ This action cannot be undone. All documents will be @@ -430,7 +414,6 @@ export function ProjectSelector() { whileTap={{ scale: 0.95 }} > <Button - className="bg-white/5 hover:bg-white/10 border-white/10 text-white" onClick={() => setDeleteDialog({ open: false, @@ -450,11 +433,11 @@ export function ProjectSelector() { whileTap={{ scale: 0.95 }} > <Button - className={`${ + className={ deleteDialog.action === "delete" - ? "bg-red-600 hover:bg-red-700" - : "bg-white/10 hover:bg-white/20" - } text-white border-white/20`} + ? "bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white" + : "" + } disabled={ deleteProjectMutation.isPending || (deleteDialog.action === "move" && @@ -478,10 +461,10 @@ export function ProjectSelector() { project: null, action: "move", targetProjectId: "", - }); + }) }, }, - ); + ) } }} type="button" @@ -514,7 +497,7 @@ export function ProjectSelector() { onOpenChange={(open) => setExpDialog({ ...expDialog, open })} open={expDialog.open} > - <DialogContent className="sm:max-w-lg bg-black/90 backdrop-blur-xl border-white/10 text-white"> + <DialogContent className="sm:max-w-lg"> <motion.div animate={{ opacity: 1, scale: 1 }} className="flex flex-col gap-4" @@ -522,19 +505,19 @@ export function ProjectSelector() { initial={{ opacity: 0, scale: 0.95 }} > <DialogHeader> - <DialogTitle className="text-white"> - Enable Experimental Mode? - </DialogTitle> - <DialogDescription className="text-white/60"> + <DialogTitle>Enable Experimental Mode?</DialogTitle> + <DialogDescription> Experimental mode enables beta features and advanced memory relationships for this project. <br /> <br /> - <span className="text-yellow-400 font-medium"> + <span className="text-yellow-600 dark:text-yellow-400 font-medium"> Warning: </span>{" "} This action is{" "} - <span className="text-red-400 font-bold">irreversible</span> + <span className="text-red-600 dark:text-red-400 font-bold"> + irreversible + </span> . Once enabled, you cannot return to regular mode for this project. </DialogDescription> @@ -545,7 +528,6 @@ export function ProjectSelector() { whileTap={{ scale: 0.95 }} > <Button - className="bg-white/5 hover:bg-white/10 border-white/10 text-white" onClick={() => setExpDialog({ open: false, projectId: "" }) } @@ -560,7 +542,7 @@ export function ProjectSelector() { whileTap={{ scale: 0.95 }} > <Button - className="bg-blue-600 hover:bg-blue-700 text-white" + className="bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white" disabled={enableExperimentalMutation.isPending} onClick={() => enableExperimentalMutation.mutate(expDialog.projectId) @@ -584,5 +566,5 @@ export function ProjectSelector() { )} </AnimatePresence> </div> - ); + ) } diff --git a/apps/web/components/referral-upgrade-modal.tsx b/apps/web/components/referral-upgrade-modal.tsx deleted file mode 100644 index 029bd2ae..00000000 --- a/apps/web/components/referral-upgrade-modal.tsx +++ /dev/null @@ -1,304 +0,0 @@ -"use client"; - -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"; -import { - CheckCircle, - Copy, - CreditCard, - Gift, - LoaderIcon, - Share2, - Users, -} from "lucide-react"; -import { motion } from "motion/react"; -import Link from "next/link"; -import { useState } from "react"; - -interface ReferralUpgradeModalProps { - 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); - - const { data: memoriesCheck } = fetchMemoriesFeature(autumn as any); - const memoriesUsed = memoriesCheck?.usage ?? 0; - const memoriesLimit = memoriesCheck?.included_usage ?? 0; - - // Fetch subscription status - const { - data: status = { - consumer_pro: null, - }, - isLoading: isCheckingStatus, - } = fetchSubscriptionStatus(autumn as any); - - const isPro = status.consumer_pro; - - // Handle upgrade - const handleUpgrade = async () => { - setIsLoading(true); - try { - await autumn.attach({ - productId: "consumer_pro", - successUrl: "https://app.supermemory.ai/", - }); - window.location.reload(); - } catch (error) { - 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 handleCopyReferralLink = async () => { - try { - await navigator.clipboard.writeText(referralLink); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (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!", - url: referralLink, - }); - } catch (error) { - console.error("Error sharing:", error); - } - } else { - handleCopyReferralLink(); - } - }; - - if (user?.isAnonymous) { - return ( - <Dialog open={isOpen} onOpenChange={onClose}> - <DialogContent className="sm:max-w-md bg-[#0f1419] backdrop-blur-xl border-white/10 text-white"> - <motion.div - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.95 }} - initial={{ opacity: 0, scale: 0.95 }} - > - <DialogHeader> - <DialogTitle>Get More Memories</DialogTitle> - <DialogDescription className="text-white/60"> - Sign in to access referrals and upgrade options - </DialogDescription> - </DialogHeader> - - <div className="text-center py-6"> - <Button - asChild - className="bg-white/10 hover:bg-white/20 text-white border-white/20" - > - <Link href="/login">Sign in</Link> - </Button> - </div> - </motion.div> - </DialogContent> - </Dialog> - ); - } - - return ( - <Dialog open={isOpen} onOpenChange={onClose}> - <DialogContent className="sm:max-w-lg bg-[#0f1419] backdrop-blur-xl border-white/10 text-white"> - <motion.div - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.95 }} - initial={{ opacity: 0, scale: 0.95 }} - > - <DialogHeader className="mb-4"> - <DialogTitle>Get More Memories</DialogTitle> - <DialogDescription className="text-white/60"> - Expand your memory capacity through referrals or upgrades - </DialogDescription> - </DialogHeader> - - {/* Current Usage */} - <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"}`} - > - {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" - }`} - style={{ - width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`, - }} - /> - </div> - </div> - - {/* Tabs */} - <Tabs defaultValue="refer" className="w-full"> - <TabsList className="grid w-full grid-cols-2 bg-white/5"> - <TabsTrigger value="refer" className="flex items-center gap-2"> - <Users className="w-4 h-4" /> - Refer Friends - </TabsTrigger> - {!isPro && ( - <TabsTrigger - value="upgrade" - className="flex items-center gap-2" - > - <CreditCard className="w-4 h-4" /> - Upgrade Plan - </TabsTrigger> - )} - </TabsList> - - <TabsContent value="refer" className="space-y-4 mt-6"> - <div className="text-center"> - <Gift className="w-12 h-12 text-blue-400 mx-auto mb-3" /> - <HeadingH3Bold className="text-white mb-2"> - Invite Friends, Get More Memories - </HeadingH3Bold> - <p className="text-white/70 text-sm"> - For every friend who joins, you both get +5 extra memories! - </p> - </div> - - <div className="bg-white/5 rounded-lg p-4"> - <Label className="text-sm text-white/70 mb-2 block"> - Your Referral Link - </Label> - <div className="flex gap-2"> - <Input - className="flex-1 bg-white/10 border-white/20 text-white" - readOnly - value={referralLink} - /> - <Button - className="bg-white/5 hover:bg-white/10 text-white border-white/20" - onClick={handleCopyReferralLink} - size="sm" - variant="outline" - > - {copied ? ( - <CheckCircle className="w-4 h-4" /> - ) : ( - <Copy className="w-4 h-4" /> - )} - </Button> - </div> - </div> - - <Button - className="w-full bg-white/5 hover:bg-white/10 text-white border-white/20" - onClick={handleShare} - variant="outline" - > - <Share2 className="w-4 h-4 mr-2" /> - Share Link - </Button> - </TabsContent> - - {!isPro && ( - <TabsContent value="upgrade" className="space-y-4 mt-6"> - <div className="text-center"> - <CreditCard className="w-12 h-12 text-purple-400 mx-auto mb-3" /> - <HeadingH3Bold className="text-white mb-2"> - Upgrade to Pro - </HeadingH3Bold> - <p className="text-white/70 text-sm"> - Get unlimited memories and advanced features - </p> - </div> - - <div className="bg-white/5 rounded-lg border border-white/10 p-4"> - <h4 className="font-medium text-white mb-3"> - Pro Plan Benefits - </h4> - <ul className="space-y-2"> - <li className="flex items-center gap-2 text-sm text-white/90"> - <CheckCircle className="h-4 w-4 text-green-400" /> - 500 memories (vs {memoriesLimit} free) - </li> - <li className="flex items-center gap-2 text-sm text-white/90"> - <CheckCircle className="h-4 w-4 text-green-400" /> - 10 connections - </li> - <li className="flex items-center gap-2 text-sm text-white/90"> - <CheckCircle className="h-4 w-4 text-green-400" /> - Advanced search - </li> - <li className="flex items-center gap-2 text-sm text-white/90"> - <CheckCircle className="h-4 w-4 text-green-400" /> - Priority support - </li> - </ul> - </div> - - <Button - className="w-full bg-blue-600 hover:bg-blue-700 text-white" - disabled={isLoading || isCheckingStatus} - onClick={handleUpgrade} - > - {isLoading || isCheckingStatus ? ( - <> - <LoaderIcon className="h-4 w-4 animate-spin mr-2" /> - Upgrading... - </> - ) : ( - "Upgrade to Pro - $15/month" - )} - </Button> - - <p className="text-xs text-white/50 text-center"> - Cancel anytime. No questions asked. - </p> - </TabsContent> - )} - </Tabs> - </motion.div> - </DialogContent> - </Dialog> - ); -} diff --git a/apps/web/components/text-effect.tsx b/apps/web/components/text-effect.tsx new file mode 100644 index 00000000..82c6823c --- /dev/null +++ b/apps/web/components/text-effect.tsx @@ -0,0 +1,294 @@ +'use client'; +import { cn } from '@lib/utils'; +import { + AnimatePresence, + motion +} from 'motion/react'; +import type { + TargetAndTransition, + Transition, + Variant, + Variants, +} from 'motion/react' +import React from 'react'; + +export type PresetType = 'blur' | 'fade-in-blur' | 'scale' | 'fade' | 'slide'; + +export type PerType = 'word' | 'char' | 'line'; + +export type TextEffectProps = { + children: string; + per?: PerType; + as?: keyof React.JSX.IntrinsicElements; + variants?: { + container?: Variants; + item?: Variants; + }; + className?: string; + preset?: PresetType; + delay?: number; + speedReveal?: number; + speedSegment?: number; + trigger?: boolean; + onAnimationComplete?: () => void; + onAnimationStart?: () => void; + segmentWrapperClassName?: string; + containerTransition?: Transition; + segmentTransition?: Transition; + style?: React.CSSProperties; +}; + +const defaultStaggerTimes: Record<PerType, number> = { + char: 0.03, + word: 0.05, + line: 0.1, +}; + +const defaultContainerVariants: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.05, + }, + }, + exit: { + transition: { staggerChildren: 0.05, staggerDirection: -1 }, + }, +}; + +const defaultItemVariants: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + }, + exit: { opacity: 0 }, +}; + +const presetVariants: Record< + PresetType, + { container: Variants; item: Variants } +> = { + blur: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, filter: 'blur(12px)' }, + visible: { opacity: 1, filter: 'blur(0px)' }, + exit: { opacity: 0, filter: 'blur(12px)' }, + }, + }, + 'fade-in-blur': { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, y: 20, filter: 'blur(12px)' }, + visible: { opacity: 1, y: 0, filter: 'blur(0px)' }, + exit: { opacity: 0, y: 20, filter: 'blur(12px)' }, + }, + }, + scale: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, scale: 0 }, + visible: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0 }, + }, + }, + fade: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, + exit: { opacity: 0 }, + }, + }, + slide: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: 20 }, + }, + }, +}; + +const AnimationComponent: React.FC<{ + segment: string; + variants: Variants; + per: 'line' | 'word' | 'char'; + segmentWrapperClassName?: string; +}> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => { + const content = + per === 'line' ? ( + <motion.span variants={variants} className='block'> + {segment} + </motion.span> + ) : per === 'word' ? ( + <motion.span + aria-hidden='true' + variants={variants} + className='inline-block whitespace-pre' + > + {segment} + </motion.span> + ) : ( + <motion.span className='inline-block whitespace-pre'> + {segment.split('').map((char, charIndex) => ( + <motion.span + key={`char-${charIndex}`} + aria-hidden='true' + variants={variants} + className='inline-block whitespace-pre' + > + {char} + </motion.span> + ))} + </motion.span> + ); + + if (!segmentWrapperClassName) { + return content; + } + + const defaultWrapperClassName = per === 'line' ? 'block' : 'inline-block'; + + return ( + <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}> + {content} + </span> + ); +}); + +AnimationComponent.displayName = 'AnimationComponent'; + +const splitText = (text: string, per: PerType) => { + if (per === 'line') return text.split('\n'); + return text.split(/(\s+)/); +}; + +const hasTransition = ( + variant?: Variant +): variant is TargetAndTransition & { transition?: Transition } => { + if (!variant) return false; + return ( + typeof variant === 'object' && 'transition' in variant + ); +}; + +const createVariantsWithTransition = ( + baseVariants: Variants, + transition?: Transition & { exit?: Transition } +): Variants => { + if (!transition) return baseVariants; + + const { exit: _, ...mainTransition } = transition; + + return { + ...baseVariants, + visible: { + ...baseVariants.visible, + transition: { + ...(hasTransition(baseVariants.visible) + ? baseVariants.visible.transition + : {}), + ...mainTransition, + }, + }, + exit: { + ...baseVariants.exit, + transition: { + ...(hasTransition(baseVariants.exit) + ? baseVariants.exit.transition + : {}), + ...mainTransition, + staggerDirection: -1, + }, + }, + }; +}; + +export function TextEffect({ + children, + per = 'word', + as = 'p', + variants, + className, + preset = 'fade', + delay = 0, + speedReveal = 1, + speedSegment = 1, + trigger = true, + onAnimationComplete, + onAnimationStart, + segmentWrapperClassName, + containerTransition, + segmentTransition, + style, +}: TextEffectProps) { + const segments = splitText(children, per); + const MotionTag = motion[as as keyof typeof motion] as typeof motion.div; + + const baseVariants = preset + ? presetVariants[preset] + : { container: defaultContainerVariants, item: defaultItemVariants }; + + const stagger = defaultStaggerTimes[per] / speedReveal; + + const baseDuration = 0.3 / speedSegment; + + const customStagger = hasTransition(variants?.container?.visible ?? {}) + ? (variants?.container?.visible as TargetAndTransition).transition + ?.staggerChildren + : undefined; + + const customDelay = hasTransition(variants?.container?.visible ?? {}) + ? (variants?.container?.visible as TargetAndTransition).transition + ?.delayChildren + : undefined; + + const computedVariants = { + container: createVariantsWithTransition( + variants?.container || baseVariants.container, + { + staggerChildren: customStagger ?? stagger, + delayChildren: customDelay ?? delay, + ...containerTransition, + exit: { + staggerChildren: customStagger ?? stagger, + staggerDirection: -1, + }, + } + ), + item: createVariantsWithTransition(variants?.item || baseVariants.item, { + duration: baseDuration, + ...segmentTransition, + }), + }; + + return ( + <AnimatePresence mode='popLayout'> + {trigger && ( + <MotionTag + initial='hidden' + animate='visible' + exit='exit' + variants={computedVariants.container} + className={className} + onAnimationComplete={onAnimationComplete} + onAnimationStart={onAnimationStart} + style={style} + > + {per !== 'line' ? <span className='sr-only'>{children}</span> : null} + {segments.map((segment, index) => ( + <AnimationComponent + key={`${per}-${index}-${segment}`} + segment={segment} + variants={computedVariants.item} + per={per} + segmentWrapperClassName={segmentWrapperClassName} + /> + ))} + </MotionTag> + )} + </AnimatePresence> + ); +} diff --git a/apps/web/components/text-morph.tsx b/apps/web/components/text-morph.tsx new file mode 100644 index 00000000..467dc999 --- /dev/null +++ b/apps/web/components/text-morph.tsx @@ -0,0 +1,74 @@ +'use client'; +import { cn } from '@lib/utils'; +import { AnimatePresence, motion, type Transition, type Variants } from 'motion/react'; +import { useMemo, useId } from 'react'; + +export type TextMorphProps = { + children: string; + as?: React.ElementType; + className?: string; + style?: React.CSSProperties; + variants?: Variants; + transition?: Transition; +}; + +export function TextMorph({ + children, + as: Component = 'p', + className, + style, + variants, + transition, +}: TextMorphProps) { + const uniqueId = useId(); + + const characters = useMemo(() => { + const charCounts: Record<string, number> = {}; + + return children.split('').map((char) => { + const lowerChar = char.toLowerCase(); + charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1; + + return { + id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`, + label: char === ' ' ? '\u00A0' : char, + }; + }); + }, [children, uniqueId]); + + const defaultVariants: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + }; + + const defaultTransition: Transition = { + type: 'spring', + stiffness: 280, + damping: 18, + mass: 0.3, + }; + + return ( + // @ts-expect-error - style is optional + <Component className={cn(className)} aria-label={children} style={style}> + <AnimatePresence mode='popLayout' initial={false}> + {characters.map((character) => ( + <motion.span + key={character.id} + layoutId={character.id} + className='inline-block' + aria-hidden='true' + initial='initial' + animate='animate' + exit='exit' + variants={variants || defaultVariants} + transition={transition || defaultTransition} + > + {character.label} + </motion.span> + ))} + </AnimatePresence> + </Component> + ); +} diff --git a/apps/web/components/text-shimmer.tsx b/apps/web/components/text-shimmer.tsx index 1825d08c..815200fe 100644 --- a/apps/web/components/text-shimmer.tsx +++ b/apps/web/components/text-shimmer.tsx @@ -38,7 +38,7 @@ function TextShimmerComponent({ initial={{ backgroundPosition: "100% center" }} animate={{ backgroundPosition: "0% center" }} transition={{ - repeat: Infinity, + repeat: Number.POSITIVE_INFINITY, duration, ease: "linear", }} diff --git a/apps/web/components/tour.tsx b/apps/web/components/tour.tsx deleted file mode 100644 index 33919efe..00000000 --- a/apps/web/components/tour.tsx +++ /dev/null @@ -1,414 +0,0 @@ -"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"; - -// Types -export interface TourStep { - 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; - // Expansion state tracking - setMenuExpanded: (expanded: boolean) => void; - setChatExpanded: (expanded: boolean) => void; -} - -// Context -const TourContext = React.createContext<TourContextType | undefined>(undefined); - -export function useTour() { - const context = React.useContext(TourContext); - if (!context) { - throw new Error("useTour must be used within a TourProvider"); - } - return context; -} - -// Provider -interface TourProviderProps { - children: React.ReactNode; - onComplete?: () => void; - className?: string; - isTourCompleted?: boolean; -} - -export function TourProvider({ - children, - onComplete, - 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); - - // Track expansion states - 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]); - - const startTour = React.useCallback(() => { - 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(); - if (onComplete) { - onComplete(); - } - }, [onComplete]); - - const nextStep = React.useCallback(() => { - if (currentStep < steps.length - 1) { - setCurrentStep(currentStep + 1); - } else { - analytics.tourCompleted(); - endTour(); - setIsTourCompleted(true); - } - }, [currentStep, steps.length, endTour]); - - const previousStep = React.useCallback(() => { - if (currentStep > 0) { - setCurrentStep(currentStep - 1); - } - }, [currentStep]); - - const setMenuExpanded = React.useCallback((expanded: boolean) => { - setIsMenuExpanded(expanded); - }, []); - - const setChatExpanded = React.useCallback((expanded: boolean) => { - setIsChatExpanded(expanded); - }, []); - - const value = React.useMemo( - () => ({ - currentStep, - totalSteps: steps.length, - nextStep, - previousStep, - endTour, - isActive, - isPaused, - startTour, - setSteps, - steps, - isTourCompleted, - setIsTourCompleted, - setMenuExpanded, - setChatExpanded, - }), - [ - currentStep, - steps, - nextStep, - previousStep, - endTour, - isActive, - isPaused, - startTour, - isTourCompleted, - setMenuExpanded, - setChatExpanded, - ], - ); - - return ( - <TourContext.Provider value={value}> - {children} - {isActive && !isPaused && ( - <> - {console.log( - "Rendering TourHighlight for step:", - currentStep, - currentStep >= 0 && currentStep < steps.length - ? steps[currentStep] - : "No step", - )} - <TourHighlight - className={className} - currentStepIndex={currentStep} - steps={steps} - /> - </> - )} - </TourContext.Provider> - ); -} - -// Tour Highlight Component -function TourHighlight({ - currentStepIndex, - steps, - className, -}: { - currentStepIndex: number; - steps: TourStep[]; - className?: string; -}) { - 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; - - React.useEffect(() => { - if (!step) return; - - // Use requestAnimationFrame to ensure DOM is ready - const rafId = requestAnimationFrame(() => { - const element = document.getElementById(step.selectorId); - console.debug( - "Looking for element with ID:", - step.selectorId, - "Found:", - !!element, - ); - if (element) { - const rect = element.getBoundingClientRect(); - console.debug("Element rect:", { - id: step.selectorId, - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - bottom: rect.bottom, - right: rect.right, - }); - setElementRect(rect); - } - }); - - // Add click listener for onClickWithinArea - let clickHandler: ((e: MouseEvent) => void) | null = null; - if (step.onClickWithinArea) { - const element = document.getElementById(step.selectorId); - if (element) { - clickHandler = (e: MouseEvent) => { - if (element.contains(e.target as Node)) { - step.onClickWithinArea?.(); - } - }; - document.addEventListener("click", clickHandler); - } - } - - return () => { - cancelAnimationFrame(rafId); - if (clickHandler) { - document.removeEventListener("click", clickHandler); - } - }; - }, [step]); - - if (!step) return null; - - // Keep the wrapper mounted but animate the content - return ( - <AnimatePresence mode="wait"> - {elementRect && ( - <motion.div - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} - initial={{ opacity: 0 }} - key={`tour-step-${currentStepIndex}`} - transition={{ duration: 0.2 }} - > - {/* Highlight Border */} - <motion.div - animate={{ opacity: 1, scale: 1 }} - className={`fixed z-[101] pointer-events-none ${className}`} - exit={{ opacity: 0, scale: 0.95 }} - initial={{ opacity: 0, scale: 0.95 }} - style={{ - top: elementRect.top + window.scrollY, - left: elementRect.left + window.scrollX, - width: elementRect.width, - height: elementRect.height, - }} - > - <div className="absolute inset-0 rounded-lg outline-4 outline-blue-500/50 outline-offset-0" /> - </motion.div> - - {/* Tooltip with Glass Effect */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className="fixed z-[102] w-72 rounded-lg shadow-xl overflow-hidden" - exit={{ opacity: 0, y: step.position === "top" ? 10 : -10 }} - initial={{ opacity: 0, y: step.position === "top" ? 10 : -10 }} - style={{ - top: (() => { - const baseTop = - step.position === "bottom" - ? elementRect.bottom + 8 - : step.position === "top" - ? elementRect.top - 200 - : 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)); - })(), - left: (() => { - const baseLeft = - step.position === "right" - ? elementRect.right + 8 - : step.position === "left" - ? elementRect.left - 300 - : 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)); - })(), - }} - > - {/* Glass effect background */} - <GlassMenuEffect rounded="rounded-lg" /> - - {/* Content */} - <div className="relative z-10 p-4"> - <div className="mb-4 text-white">{step.content}</div> - <div className="flex items-center justify-between"> - <span className="text-sm text-gray-300"> - {currentStepIndex + 1} / {steps.length} - </span> - <div className="flex gap-2"> - <Button - className="border-white/20 text-white hover:bg-white/10" - onClick={endTour} - size="sm" - variant="outline" - > - Skip - </Button> - {currentStepIndex > 0 && ( - <Button - className="border-white/20 text-white hover:bg-white/10" - onClick={previousStep} - size="sm" - variant="outline" - > - Previous - </Button> - )} - <Button - className="bg-white/20 text-white hover:bg-white/30" - onClick={nextStep} - size="sm" - > - {currentStepIndex === steps.length - 1 ? "Finish" : "Next"} - </Button> - </div> - </div> - </div> - </motion.div> - </motion.div> - )} - </AnimatePresence> - ); -} - -// Tour Alert Dialog -interface TourAlertDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function TourAlertDialog({ open, onOpenChange }: TourAlertDialogProps) { - const { startTour, setIsTourCompleted } = useTour(); - - const handleStart = () => { - console.debug("TourAlertDialog: Starting tour"); - onOpenChange(false); - startTour(); - }; - - const handleSkip = () => { - analytics.tourSkipped(); - setIsTourCompleted(true); // Mark tour as completed when skipped - onOpenChange(false); - }; - - if (!open) return null; - - return ( - <AnimatePresence> - <motion.div - animate={{ opacity: 1, scale: 1 }} - className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[102] w-[90vw] max-w-2xl rounded-lg shadow-xl overflow-hidden" - exit={{ opacity: 0, scale: 0.95 }} - initial={{ opacity: 0, scale: 0.95 }} - > - {/* Glass effect background */} - <GlassMenuEffect rounded="rounded-lg" /> - - {/* Content */} - <div className="relative z-10 p-8 md:p-10 lg:p-12"> - <h2 className="text-3xl md:text-4xl font-bold text-white mb-4"> - Welcome to supermemory™ - </h2> - <p className="text-lg md:text-xl text-gray-200 mb-8 leading-relaxed"> - This is your personal knowledge graph where all your memories are - stored and connected. Let's take a quick tour to help you get - familiar with the interface. - </p> - <div className="flex gap-4 justify-end"> - <Button - className="border-white/20 text-white hover:bg-white/10 px-6 py-2 text-base" - onClick={handleSkip} - size="lg" - variant="outline" - > - Skip Tour - </Button> - <Button - className="bg-white/20 text-white hover:bg-white/30 px-6 py-2 text-base" - onClick={handleStart} - size="lg" - > - Start Tour - </Button> - </div> - </div> - </motion.div> - </AnimatePresence> - ); -} diff --git a/apps/web/components/views/add-memory/action-buttons.tsx b/apps/web/components/views/add-memory/action-buttons.tsx index 6dc49304..fc901ba9 100644 --- a/apps/web/components/views/add-memory/action-buttons.tsx +++ b/apps/web/components/views/add-memory/action-buttons.tsx @@ -26,7 +26,7 @@ export function ActionButtons({ 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" + className="hover:bg-foreground/10 border-none flex-1 sm:flex-initial" onClick={onCancel} type="button" variant="ghost" @@ -40,7 +40,7 @@ export function ActionButtons({ className="flex-1 sm:flex-initial" > <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20 w-full" + className="bg-foreground hover:bg-foreground/20 border-foreground/20 w-full" disabled={isSubmitting || isSubmitDisabled} onClick={submitType === 'button' ? onSubmit : undefined} type={submitType} diff --git a/apps/web/components/views/add-memory/index.tsx b/apps/web/components/views/add-memory/index.tsx index 8bd6a0f0..a78e7629 100644 --- a/apps/web/components/views/add-memory/index.tsx +++ b/apps/web/components/views/add-memory/index.tsx @@ -1,8 +1,6 @@ +"use client" import { $fetch } from "@lib/api" -import { - fetchConsumerProProduct, - fetchMemoriesFeature, -} from "@repo/lib/queries" +import { fetchMemoriesFeature } from "@repo/lib/queries" import { Button } from "@repo/ui/components/button" import { Dialog, @@ -12,6 +10,12 @@ import { DialogHeader, DialogTitle, } from "@repo/ui/components/dialog" +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, +} from "@repo/ui/components/tabs" import { Input } from "@repo/ui/components/input" import { Label } from "@repo/ui/components/label" import { Textarea } from "@repo/ui/components/textarea" @@ -43,13 +47,12 @@ import { ConnectionsTabContent } from "../connections-tab-content" import { ActionButtons } from "./action-buttons" import { MemoryUsageRing } from "./memory-usage-ring" import { ProjectSelection } from "./project-selection" -import { TabButton } from "./tab-button" const TextEditor = dynamic( () => import("./text-editor").then((mod) => ({ default: mod.TextEditor })), { loading: () => ( - <div className="bg-white/5 border border-white/10 rounded-md"> + <div className="bg-foreground/5 border border-foreground/10 rounded-md"> <div className="flex-1 min-h-48 max-h-64 overflow-y-auto flex items-center justify-center text-white/70"> Loading editor... </div> @@ -66,28 +69,6 @@ const TextEditor = dynamic( }, ) -// // Processing status component -// function ProcessingStatus({ status }: { status: string }) { -// const statusConfig = { -// queued: { color: "text-yellow-400", label: "Queued", icon: "⏳" }, -// extracting: { color: "text-blue-400", label: "Extracting", icon: "📤" }, -// chunking: { color: "text-indigo-400", label: "Chunking", icon: "✂️" }, -// embedding: { color: "text-purple-400", label: "Embedding", icon: "🧠" }, -// indexing: { color: "text-pink-400", label: "Indexing", icon: "📝" }, -// unknown: { color: "text-gray-400", label: "Processing", icon: "⚙️" }, -// } - -// const config = -// statusConfig[status as keyof typeof statusConfig] || statusConfig.unknown - -// return ( -// <div className={`flex items-center gap-1 text-xs ${config.color}`}> -// <span>{config.icon}</span> -// <span>{config.label}</span> -// </div> -// ) -// } - export function AddMemoryView({ onClose, initialTab = "note", @@ -107,7 +88,7 @@ export function AddMemoryView({ const [newProjectName, setNewProjectName] = useState("") // Check memory limits - const { data: memoriesCheck } = fetchMemoriesFeature(autumn) + const { data: memoriesCheck } = fetchMemoriesFeature(autumn, !autumn.isLoading) const memoriesUsed = memoriesCheck?.usage ?? 0 const memoriesLimit = memoriesCheck?.included_usage ?? 0 @@ -560,460 +541,452 @@ export function AddMemoryView({ open={showAddDialog} > <DialogContent - className="w-[95vw] max-w-3xl sm:max-w-3xl bg-[#0f1419] backdrop-blur-xl border-white/10 text-white z-[80] max-h-[90vh] overflow-y-auto" + className="w-[100vw] max-w-4xl sm:max-w-4xl backdrop-blur-xl border-white/10 z-[80] h-[52vh] overflow-y-auto p-4" showCloseButton={false} > - <motion.div - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.95 }} - initial={{ opacity: 0, scale: 0.95 }} + <Tabs + value={activeTab} + onValueChange={(value) => setActiveTab(value as typeof activeTab)} + className="flex flex-row gap-4" + orientation="vertical" > - <DialogHeader> - <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> - <div className="flex-1"> - <DialogTitle className="text-base"> - Add to Memory - </DialogTitle> - <DialogDescription className="text-white/50"> - Save any webpage, article, or file to your memory - </DialogDescription> + <TabsList className="flex flex-col gap-2 max-w-48 h-fit bg-transparent p-0"> + <TabsTrigger + value="note" + className="flex flex-col gap-1 justify-start items-start h-auto w-full" + > + <div className="flex gap-1 items-center"> + <Brain className="h-4 w-4" /> + Note </div> - <div className="sm:ml-4 order-first sm:order-last"> - <div className="bg-white/5 p-1 h-10 sm:h-8 rounded-md flex overflow-x-auto"> - <TabButton - icon={Brain} - isActive={activeTab === "note"} - label="Note" - onClick={() => setActiveTab("note")} - /> - <TabButton - icon={LinkIcon} - isActive={activeTab === "link"} - label="Link" - onClick={() => setActiveTab("link")} - /> - <TabButton - icon={FileIcon} - isActive={activeTab === "file"} - label="File" - onClick={() => setActiveTab("file")} - /> - <TabButton - icon={PlugIcon} - isActive={activeTab === "connect"} - label="Connect" - onClick={() => setActiveTab("connect")} + <span className="text-xs text-muted-foreground text-wrap text-left"> + Write down your thoughts + </span> + </TabsTrigger> + <TabsTrigger + value="link" + className="flex flex-col gap-1 justify-start items-start h-auto w-full" + > + <div className="flex gap-1 items-center"> + <LinkIcon className="h-4 w-4" /> + Link + </div> + <span className="text-xs text-muted-foreground text-wrap text-left"> + Save any webpage + </span> + </TabsTrigger> + <TabsTrigger + value="file" + className="flex flex-col gap-1 justify-start items-start h-auto w-full" + > + <div className="flex gap-1 items-center"> + <FileIcon className="h-4 w-4" /> + File + </div> + <span className="text-xs text-muted-foreground text-wrap text-left"> + Upload any file + </span> + </TabsTrigger> + <TabsTrigger + value="connect" + className="flex flex-col gap-1 justify-start items-start h-auto w-full" + > + <div className="flex gap-1 items-center"> + <PlugIcon className="h-4 w-4" /> + Connect + </div> + <span className="text-xs text-muted-foreground text-wrap text-left"> + Connect to your favorite apps + </span> + </TabsTrigger> + </TabsList> + + <TabsContent value="note" className="space-y-4"> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + addContentForm.handleSubmit() + }} + className="h-full flex flex-col" + > + <div className="grid gap-4"> + {/* Note Input */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} + > + <addContentForm.Field + name="content" + validators={{ + onChange: ({ value }) => { + if (!value || value.trim() === "") { + return "Note is required" + } + return undefined + }, + }} + > + {({ state, handleChange, handleBlur }) => ( + <> + <div + className={`bg-black/5 border border-black/10 rounded-md ${ + addContentMutation.isPending ? "opacity-50" : "" + }`} + > + <TextEditor + disabled={addContentMutation.isPending} + onBlur={handleBlur} + onChange={handleChange} + placeholder="Write your note here..." + value={state.value} + /> + </div> + {state.meta.errors.length > 0 && ( + <motion.p + animate={{ opacity: 1, height: "auto" }} + className="text-sm text-red-400 mt-1" + exit={{ opacity: 0, height: 0 }} + initial={{ opacity: 0, height: 0 }} + > + {state.meta.errors + .map((error) => + typeof error === "string" + ? error + : (error?.message ?? + `Error: ${JSON.stringify(error)}`), + ) + .join(", ")} + </motion.p> + )} + </> + )} + </addContentForm.Field> + </motion.div> + </div> + <div className="flex flex-col sm:flex-row sm:justify-between sm:items-end w-full gap-4 mt-auto"> + <div className="flex flex-col sm:flex-row sm:items-end gap-4 order-2 sm:order-1"> + {/* Project Selection */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className={`flex flex-col gap-2 flex-1 sm:flex-initial ${ + addContentMutation.isPending ? "opacity-50" : "" + }`} + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <addContentForm.Field name="project"> + {({ state, handleChange }) => ( + <ProjectSelection + disabled={addContentMutation.isPending} + id="note-project" + isLoading={isLoadingProjects} + onCreateProject={() => + setShowCreateProjectDialog(true) + } + onProjectChange={handleChange} + projects={projects} + selectedProject={state.value} + /> + )} + </addContentForm.Field> + </motion.div> + + <MemoryUsageRing + memoriesLimit={memoriesLimit} + memoriesUsed={memoriesUsed} /> </div> - </div> - </div> - </DialogHeader> - <div className="mt-4"> - {activeTab === "note" && ( - <div className="space-y-4"> - <form - onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - addContentForm.handleSubmit() + <ActionButtons + isSubmitDisabled={!addContentForm.state.canSubmit} + isSubmitting={addContentMutation.isPending} + onCancel={() => { + setShowAddDialog(false) + onClose?.() + addContentForm.reset() }} + submitIcon={Plus} + submitText="Add Note" + /> + </div> + </form> + </TabsContent> + + <TabsContent value="link" className="space-y-4"> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + addContentForm.handleSubmit() + }} + className="h-full flex flex-col" + > + <div className="grid gap-4"> + {/* Link Input */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} > - <div className="grid gap-4"> - {/* Note Input */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.1 }} - > - <addContentForm.Field - name="content" - validators={{ - onChange: ({ value }) => { - if (!value || value.trim() === "") { - return "Note is required" - } - return undefined - }, - }} - > - {({ state, handleChange, handleBlur }) => ( - <> - <div - className={`bg-white/5 border border-white/10 rounded-md ${ - addContentMutation.isPending - ? "opacity-50" - : "" - }`} - > - <TextEditor - className="text-white" - disabled={addContentMutation.isPending} - onBlur={handleBlur} - onChange={handleChange} - placeholder="Write your note here..." - value={state.value} - /> - </div> - {state.meta.errors.length > 0 && ( - <motion.p - animate={{ opacity: 1, height: "auto" }} - className="text-sm text-red-400 mt-1" - exit={{ opacity: 0, height: 0 }} - initial={{ opacity: 0, height: 0 }} - > - {state.meta.errors - .map((error) => - typeof error === "string" - ? error - : (error?.message ?? - `Error: ${JSON.stringify(error)}`), - ) - .join(", ")} - </motion.p> - )} - </> + <label + className="text-sm font-medium" + htmlFor="link-content" + > + Link + </label> + <addContentForm.Field + name="content" + validators={{ + onChange: ({ value }) => { + if (!value || value.trim() === "") { + return "Link is required" + } + try { + new URL(value) + return undefined + } catch { + return "Please enter a valid link" + } + }, + }} + > + {({ state, handleChange, handleBlur }) => ( + <> + <Input + className={`bg-black/5 border-black/10 text-black ${ + addContentMutation.isPending ? "opacity-50" : "" + }`} + disabled={addContentMutation.isPending} + id="link-content" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="https://example.com/article" + value={state.value} + /> + {state.meta.errors.length > 0 && ( + <motion.p + animate={{ opacity: 1, height: "auto" }} + className="text-sm text-red-400 mt-1" + exit={{ opacity: 0, height: 0 }} + initial={{ opacity: 0, height: 0 }} + > + {state.meta.errors + .map((error) => + typeof error === "string" + ? error + : (error?.message ?? + `Error: ${JSON.stringify(error)}`), + ) + .join(", ")} + </motion.p> )} - </addContentForm.Field> - </motion.div> - </div> - <div className="mt-6 flex flex-col sm:flex-row sm:justify-between sm:items-end w-full gap-4"> - <div className="flex flex-col sm:flex-row sm:items-end gap-4 order-2 sm:order-1"> - {/* Project Selection */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className={`flex flex-col gap-2 flex-1 sm:flex-initial ${ - addContentMutation.isPending ? "opacity-50" : "" - }`} - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.15 }} - > - <addContentForm.Field name="project"> - {({ state, handleChange }) => ( - <ProjectSelection - disabled={addContentMutation.isPending} - id="note-project" - isLoading={isLoadingProjects} - onCreateProject={() => - setShowCreateProjectDialog(true) - } - onProjectChange={handleChange} - projects={projects} - selectedProject={state.value} - /> - )} - </addContentForm.Field> - </motion.div> - - <MemoryUsageRing - memoriesLimit={memoriesLimit} - memoriesUsed={memoriesUsed} - /> - </div> - - <ActionButtons - isSubmitDisabled={!addContentForm.state.canSubmit} - isSubmitting={addContentMutation.isPending} - onCancel={() => { - setShowAddDialog(false) - onClose?.() - addContentForm.reset() - }} - submitIcon={Plus} - submitText="Add Note" - /> - </div> - </form> + </> + )} + </addContentForm.Field> + </motion.div> </div> - )} - - {activeTab === "link" && ( - <div className="space-y-4"> - <form - onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - addContentForm.handleSubmit() + <div className="mt-auto flex justify-between items-end w-full"> + <div className="flex items-end gap-4"> + {/* Left side - Project Selection */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className={`flex flex-col gap-2 ${ + addContentMutation.isPending ? "opacity-50" : "" + }`} + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <addContentForm.Field name="project"> + {({ state, handleChange }) => ( + <ProjectSelection + disabled={addContentMutation.isPending} + id="link-project-2" + isLoading={isLoadingProjects} + onCreateProject={() => + setShowCreateProjectDialog(true) + } + onProjectChange={handleChange} + projects={projects} + selectedProject={state.value} + /> + )} + </addContentForm.Field> + </motion.div> + + <MemoryUsageRing + memoriesLimit={memoriesLimit} + memoriesUsed={memoriesUsed} + /> + </div> + + <ActionButtons + isSubmitDisabled={!addContentForm.state.canSubmit} + isSubmitting={addContentMutation.isPending} + onCancel={() => { + setShowAddDialog(false) + onClose?.() + addContentForm.reset() }} + submitIcon={Plus} + submitText="Add Link" + /> + </div> + </form> + </TabsContent> + + <TabsContent value="file" className="space-y-4"> + <form + onSubmit={(e) => { + e.preventDefault() + e.stopPropagation() + fileUploadForm.handleSubmit() + }} + > + <div className="grid gap-4"> + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.1 }} > - <div className="grid gap-4"> - {/* Link Input */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.1 }} - > - <label - className="text-sm font-medium" - htmlFor="link-content" - > - Link - </label> - <addContentForm.Field - name="content" - validators={{ - onChange: ({ value }) => { - if (!value || value.trim() === "") { - return "Link is required" - } - try { - new URL(value) - return undefined - } catch { - return "Please enter a valid link" - } - }, - }} - > - {({ state, handleChange, handleBlur }) => ( - <> - <Input - className={`bg-white/5 border-white/10 text-white ${ - addContentMutation.isPending - ? "opacity-50" - : "" - }`} - disabled={addContentMutation.isPending} - id="link-content" - onBlur={handleBlur} - onChange={(e) => handleChange(e.target.value)} - placeholder="https://example.com/article" - value={state.value} - /> - {state.meta.errors.length > 0 && ( - <motion.p - animate={{ opacity: 1, height: "auto" }} - className="text-sm text-red-400 mt-1" - exit={{ opacity: 0, height: 0 }} - initial={{ opacity: 0, height: 0 }} - > - {state.meta.errors - .map((error) => - typeof error === "string" - ? error - : (error?.message ?? - `Error: ${JSON.stringify(error)}`), - ) - .join(", ")} - </motion.p> - )} - </> - )} - </addContentForm.Field> - </motion.div> - </div> - <div className="mt-6 flex justify-between items-end w-full"> - <div className="flex items-end gap-4"> - {/* Left side - Project Selection */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className={`flex flex-col gap-2 ${ - addContentMutation.isPending ? "opacity-50" : "" - }`} - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.15 }} - > - <addContentForm.Field name="project"> - {({ state, handleChange }) => ( - <ProjectSelection - disabled={addContentMutation.isPending} - id="link-project-2" - isLoading={isLoadingProjects} - onCreateProject={() => - setShowCreateProjectDialog(true) - } - onProjectChange={handleChange} - projects={projects} - selectedProject={state.value} - /> - )} - </addContentForm.Field> - </motion.div> - - <MemoryUsageRing - memoriesLimit={memoriesLimit} - memoriesUsed={memoriesUsed} + <label className="text-sm font-medium" htmlFor="file"> + File + </label> + <Dropzone + accept={{ + "application/pdf": [".pdf"], + "application/msword": [".doc"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + [".docx"], + "text/plain": [".txt"], + "text/markdown": [".md"], + "text/csv": [".csv"], + "application/json": [".json"], + "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"], + }} + className="bg-black/5 border-black/10 hover:bg-black/10 min-h-40" + maxFiles={10} + maxSize={10 * 1024 * 1024} // 10MB + onDrop={(acceptedFiles) => + setSelectedFiles(acceptedFiles) + } + src={selectedFiles} + > + <DropzoneEmptyState /> + <DropzoneContent className="overflow-auto" /> + </Dropzone> + </motion.div> + + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.15 }} + > + <label + className="text-sm font-medium" + htmlFor="file-title" + > + Title (optional) + </label> + <fileUploadForm.Field name="title"> + {({ state, handleChange, handleBlur }) => ( + <Input + className="bg-black/5 border-black/10" + id="file-title" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="Give this file a title" + value={state.value} /> - </div> - - <ActionButtons - isSubmitDisabled={!addContentForm.state.canSubmit} - isSubmitting={addContentMutation.isPending} - onCancel={() => { - setShowAddDialog(false) - onClose?.() - addContentForm.reset() - }} - submitIcon={Plus} - submitText="Add Link" - /> - </div> - </form> - </div> - )} - - {activeTab === "file" && ( - <div className="space-y-4"> - <form - onSubmit={(e) => { - e.preventDefault() - e.stopPropagation() - fileUploadForm.handleSubmit() - }} + )} + </fileUploadForm.Field> + </motion.div> + + <motion.div + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-2" + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.2 }} > - <div className="grid gap-4"> - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.1 }} - > - <label className="text-sm font-medium" htmlFor="file"> - File - </label> - <Dropzone - accept={{ - "application/pdf": [".pdf"], - "application/msword": [".doc"], - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - [".docx"], - "text/plain": [".txt"], - "text/markdown": [".md"], - "text/csv": [".csv"], - "application/json": [".json"], - "image/*": [ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ], - }} - className="bg-white/5 border-white/10 hover:bg-white/10 min-h-40" - maxFiles={10} - maxSize={10 * 1024 * 1024} // 10MB - onDrop={(acceptedFiles) => - setSelectedFiles(acceptedFiles) - } - src={selectedFiles} - > - <DropzoneEmptyState /> - <DropzoneContent className="overflow-auto" /> - </Dropzone> - </motion.div> - - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.15 }} - > - <label - className="text-sm font-medium" - htmlFor="file-title" - > - Title (optional) - </label> - <fileUploadForm.Field name="title"> - {({ state, handleChange, handleBlur }) => ( - <Input - className="bg-white/5 border-white/10 text-white" - id="file-title" - onBlur={handleBlur} - onChange={(e) => handleChange(e.target.value)} - placeholder="Give this file a title" - value={state.value} - /> - )} - </fileUploadForm.Field> - </motion.div> - - <motion.div - animate={{ opacity: 1, y: 0 }} - className="flex flex-col gap-2" - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.2 }} - > - <label - className="text-sm font-medium" - htmlFor="file-description" - > - Description (optional) - </label> - <fileUploadForm.Field name="description"> - {({ state, handleChange, handleBlur }) => ( - <Textarea - className="bg-white/5 border-white/10 text-white min-h-20 max-h-40 overflow-y-auto resize-none" - id="file-description" - onBlur={handleBlur} - onChange={(e) => handleChange(e.target.value)} - placeholder="Add notes or context about this file" - value={state.value} - /> - )} - </fileUploadForm.Field> - </motion.div> - </div> - <div className="mt-6 flex flex-col sm:flex-row sm:justify-between sm:items-end w-full gap-4"> - <div className="flex items-end gap-4"> - {/* Left side - Project Selection */} - <motion.div - animate={{ opacity: 1, y: 0 }} - className={`flex flex-col gap-2 flex-1 sm:flex-initial ${ - fileUploadMutation.isPending ? "opacity-50" : "" - }`} - initial={{ opacity: 0, y: 10 }} - transition={{ delay: 0.25 }} - > - <fileUploadForm.Field name="project"> - {({ state, handleChange }) => ( - <ProjectSelection - disabled={fileUploadMutation.isPending} - id="file-project" - isLoading={isLoadingProjects} - onCreateProject={() => - setShowCreateProjectDialog(true) - } - onProjectChange={handleChange} - projects={projects} - selectedProject={state.value} - /> - )} - </fileUploadForm.Field> - </motion.div> - - <MemoryUsageRing - memoriesLimit={memoriesLimit} - memoriesUsed={memoriesUsed} + <label + className="text-sm font-medium" + htmlFor="file-description" + > + Description (optional) + </label> + <fileUploadForm.Field name="description"> + {({ state, handleChange, handleBlur }) => ( + <Textarea + className="bg-black/5 border-black/10 min-h-20 max-h-40 overflow-y-auto resize-none" + id="file-description" + onBlur={handleBlur} + onChange={(e) => handleChange(e.target.value)} + placeholder="Add notes or context about this file" + value={state.value} /> - </div> - - <ActionButtons - isSubmitDisabled={selectedFiles.length === 0} - isSubmitting={fileUploadMutation.isPending} - onCancel={() => { - setShowAddDialog(false) - onClose?.() - fileUploadForm.reset() - setSelectedFiles([]) - }} - submitIcon={UploadIcon} - submitText="Upload File" - /> - </div> - </form> + )} + </fileUploadForm.Field> + </motion.div> </div> - )} + <div className="mt-6 flex flex-col sm:flex-row sm:justify-between sm:items-end w-full gap-4"> + <div className="flex items-end gap-4"> + {/* Left side - Project Selection */} + <motion.div + animate={{ opacity: 1, y: 0 }} + className={`flex flex-col gap-2 flex-1 sm:flex-initial ${ + fileUploadMutation.isPending ? "opacity-50" : "" + }`} + initial={{ opacity: 0, y: 10 }} + transition={{ delay: 0.25 }} + > + <fileUploadForm.Field name="project"> + {({ state, handleChange }) => ( + <ProjectSelection + disabled={fileUploadMutation.isPending} + id="file-project" + isLoading={isLoadingProjects} + onCreateProject={() => + setShowCreateProjectDialog(true) + } + onProjectChange={handleChange} + projects={projects} + selectedProject={state.value} + /> + )} + </fileUploadForm.Field> + </motion.div> + + <MemoryUsageRing + memoriesLimit={memoriesLimit} + memoriesUsed={memoriesUsed} + /> + </div> - {activeTab === "connect" && ( - <div className="space-y-4"> - <ConnectionsTabContent /> + <ActionButtons + isSubmitDisabled={selectedFiles.length === 0} + isSubmitting={fileUploadMutation.isPending} + onCancel={() => { + setShowAddDialog(false) + onClose?.() + fileUploadForm.reset() + setSelectedFiles([]) + }} + submitIcon={UploadIcon} + submitText="Upload File" + /> </div> - )} - </div> - </motion.div> + </form> + </TabsContent> + + <TabsContent value="connect" className="space-y-4"> + <ConnectionsTabContent /> + </TabsContent> + </Tabs> </DialogContent> </Dialog> )} @@ -1025,7 +998,7 @@ export function AddMemoryView({ onOpenChange={setShowCreateProjectDialog} open={showCreateProjectDialog} > - <DialogContent className="w-[95vw] max-w-2xl sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 text-white z-[80] max-h-[90vh] overflow-y-auto"> + <DialogContent className="w-[95vw] max-w-2xl sm:max-w-2xl bg-black/90 backdrop-blur-xl border-white/10 z-[80] max-h-[90vh] overflow-y-auto"> <motion.div animate={{ opacity: 1, scale: 1 }} initial={{ opacity: 0, scale: 0.95 }} @@ -1045,7 +1018,7 @@ export function AddMemoryView({ > <Label htmlFor="projectName">Project Name</Label> <Input - className="bg-white/5 border-white/10 text-white" + className="bg-white/5 border-white/10" id="projectName" onChange={(e) => setNewProjectName(e.target.value)} placeholder="My Awesome Project" @@ -1063,7 +1036,7 @@ export function AddMemoryView({ whileTap={{ scale: 0.95 }} > <Button - className="bg-white/5 hover:bg-white/10 border-white/10 text-white w-full sm:w-auto" + className="bg-white/5 hover:bg-white/10 border-white/10 w-full sm:w-auto" onClick={() => { setShowCreateProjectDialog(false) setNewProjectName("") @@ -1080,7 +1053,7 @@ export function AddMemoryView({ whileTap={{ scale: 0.95 }} > <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20 w-full sm:w-auto" + className="bg-white/10 hover:bg-white/20 border-white/20 w-full sm:w-auto" disabled={ createProjectMutation.isPending || !newProjectName.trim() } @@ -1131,7 +1104,7 @@ export function AddMemoryExpandedView() { <div className="flex flex-wrap gap-2"> <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" + className="bg-white/10 hover:bg-white/20 border-white/20" onClick={() => handleOpenDialog("note")} size="sm" variant="outline" @@ -1143,7 +1116,7 @@ export function AddMemoryExpandedView() { <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" + className="bg-white/10 hover:bg-white/20 border-white/20" onClick={() => handleOpenDialog("link")} size="sm" variant="outline" @@ -1155,7 +1128,7 @@ export function AddMemoryExpandedView() { <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" + className="bg-white/10 hover:bg-white/20 border-white/20" onClick={() => handleOpenDialog("file")} size="sm" variant="outline" @@ -1167,7 +1140,7 @@ export function AddMemoryExpandedView() { <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" + className="bg-white/10 hover:bg-white/20 border-white/20" onClick={() => handleOpenDialog("connect")} size="sm" variant="outline" diff --git a/apps/web/components/views/add-memory/project-selection.tsx b/apps/web/components/views/add-memory/project-selection.tsx index f23768a3..3c166303 100644 --- a/apps/web/components/views/add-memory/project-selection.tsx +++ b/apps/web/components/views/add-memory/project-selection.tsx @@ -50,18 +50,18 @@ export function ProjectSelection({ value={selectedProject} > <SelectTrigger - className={`bg-white/5 border-white/10 text-white ${className}`} + className={`bg-foreground/5 border-foreground/10 ${className}`} id={id} > <SelectValue placeholder="Select a project" /> </SelectTrigger> <SelectContent - className="bg-black/90 backdrop-blur-xl border-white/10 z-[90]" + className="bg-black/90 backdrop-blur-xl border-foreground/10 z-[90]" position="popper" sideOffset={5} > <SelectItem - className="text-white hover:bg-white/10" + className="hover:bg-foreground/10" key="default" value="sm_project_default" > @@ -71,7 +71,7 @@ export function ProjectSelection({ .filter((p) => p.containerTag !== 'sm_project_default' && p.id) .map((project) => ( <SelectItem - className="text-white hover:bg-white/10" + className="hover:bg-foreground/10" key={project.id || project.containerTag} value={project.containerTag} > @@ -79,7 +79,7 @@ export function ProjectSelection({ </SelectItem> ))} <SelectItem - className="text-white hover:bg-white/10 border-t border-white/10 mt-1" + className="hover:bg-foreground/10 border-t border-foreground/10 mt-1" key="create-new" value="create-new-project" > diff --git a/apps/web/components/views/add-memory/text-editor.tsx b/apps/web/components/views/add-memory/text-editor.tsx index 5a07b8f7..f6cf9425 100644 --- a/apps/web/components/views/add-memory/text-editor.tsx +++ b/apps/web/components/views/add-memory/text-editor.tsx @@ -285,7 +285,7 @@ export function TextEditor({ return ( <blockquote {...props.attributes} - className="border-l-4 border-white/20 pl-4 italic text-white/80" + className="border-l-4 border-foreground/20 pl-4 italic text-foreground/80" > {props.children} </blockquote> @@ -312,7 +312,7 @@ export function TextEditor({ if (leaf.code) { children = ( - <code className="bg-white/10 px-1 rounded text-sm">{children}</code> + <code className="bg-foreground/10 px-1 rounded text-sm">{children}</code> ); } @@ -402,10 +402,10 @@ export function TextEditor({ 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", + "h-8 w-8 !p-0 text-foreground/70 transition-all duration-200 rounded-sm", + "hover:bg-foreground/15 hover:text-foreground hover:scale-105", "active:scale-95", - isActive && "bg-white/20 text-white", + isActive && "bg-foreground/20 text-foreground", )} onMouseDown={onMouseDown} title={title} @@ -444,7 +444,7 @@ export function TextEditor({ onBlur={onBlur} readOnly={disabled} className={cn( - "outline-none w-full h-full text-white placeholder:text-white/50", + "outline-none w-full h-full placeholder:text-foreground/50", disabled && "opacity-50 cursor-not-allowed", )} style={{ @@ -457,7 +457,7 @@ export function TextEditor({ </div> {/* Toolbar */} - <div className="p-1 flex items-center gap-2 bg-white/5 backdrop-blur-sm rounded-b-md"> + <div className="p-1 flex items-center gap-2 bg-foreground/5 backdrop-blur-sm rounded-b-md"> <div className="flex items-center gap-1"> {/* Text formatting */} <ToolbarButton @@ -489,7 +489,7 @@ export function TextEditor({ /> </div> - <div className="w-px h-6 bg-white/30 mx-2" /> + <div className="w-px h-6 bg-foreground/30 mx-2" /> <div className="flex items-center gap-1"> {/* Block formatting */} diff --git a/apps/web/components/views/billing.tsx b/apps/web/components/views/billing.tsx index 8b79eb23..679b648e 100644 --- a/apps/web/components/views/billing.tsx +++ b/apps/web/components/views/billing.tsx @@ -1,70 +1,72 @@ -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 { useEffect, useState } 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 memoriesUsed = memoriesCheck?.usage ?? 0; - const memoriesLimit = memoriesCheck?.included_usage ?? 0; - - const { data: connectionsCheck } = fetchConnectionsFeature(autumn as any); - - const connectionsUsed = connectionsCheck?.usage ?? 0; - - // Fetch subscription status with React Query const { data: status = { consumer_pro: null, }, isLoading: isCheckingStatus, - } = fetchSubscriptionStatus(autumn as any); + } = fetchSubscriptionStatus(autumn, !autumn.isLoading) + + const { data: memoriesCheck } = fetchMemoriesFeature( + autumn, + !autumn.isLoading && !isCheckingStatus, + ) + + const memoriesUsed = memoriesCheck?.usage ?? 0 + const memoriesLimit = memoriesCheck?.included_usage ?? 0 + + const { data: connectionsCheck } = fetchConnectionsFeature(autumn, !autumn.isLoading && !isCheckingStatus) + + const connectionsUsed = connectionsCheck?.usage ?? 0 // 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 ( @@ -74,18 +76,20 @@ export function BillingView() { initial={{ opacity: 0, scale: 0.9 }} transition={{ type: "spring", damping: 20 }} > - <p className="text-white/70 mb-4">Sign in to unlock premium features</p> + <p className="text-muted-foreground mb-4"> + Sign in to unlock premium features + </p> <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <Button asChild - className="bg-white/10 hover:bg-white/20 text-white border-white/20" + className="bg-muted hover:bg-muted/80 text-foreground border-border" size="sm" > <Link href="/login">Sign in</Link> </Button> </motion.div> </motion.div> - ); + ) } if (isPro) { @@ -96,28 +100,28 @@ export function BillingView() { initial={{ opacity: 0, y: 10 }} > <div className="space-y-3"> - <HeadingH3Bold className="text-white flex items-center gap-2"> + <HeadingH3Bold className="text-foreground flex items-center gap-2"> Pro Plan - <span className="text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full"> + <span className="text-xs bg-green-500/20 text-green-600 dark:text-green-400 px-2 py-0.5 rounded-full"> Active </span> </HeadingH3Bold> - <p className="text-sm text-white/70"> + <p className="text-sm text-muted-foreground"> You're enjoying expanded memory capacity with supermemory Pro! </p> </div> {/* Current Usage */} <div className="space-y-3"> - <h4 className="text-sm font-medium text-white/90">Current Usage</h4> + <h4 className="text-sm font-medium text-foreground">Current Usage</h4> <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 text-white/90"> + <span className="text-sm text-muted-foreground">Memories</span> + <span className="text-sm text-foreground"> {memoriesUsed} / {memoriesLimit} </span> </div> - <div className="w-full bg-white/10 rounded-full h-2"> + <div className="w-full bg-muted-foreground/50 rounded-full h-2"> <div className="bg-green-500 h-2 rounded-full transition-all" style={{ @@ -128,26 +132,19 @@ export function BillingView() { </div> <div className="space-y-2"> <div className="flex justify-between items-center"> - <span className="text-sm text-white/70">Connections</span> - <span className="text-sm text-white/90"> + <span className="text-sm text-muted-foreground">Connections</span> + <span className="text-sm text-foreground"> {connectionsUsed} / 10 </span> </div> </div> </div> - <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> - <Button - className="bg-white/10 hover:bg-white/20 text-white border-white/20" - onClick={handleManageBilling} - size="sm" - variant="outline" - > - Manage Billing - </Button> - </motion.div> + <Button onClick={handleManageBilling} size="sm" variant="default"> + Manage Billing + </Button> </motion.div> - ); + ) } return ( @@ -158,17 +155,19 @@ export function BillingView() { > {/* Current Usage - Free Plan */} <div className="space-y-3"> - <HeadingH3Bold className="text-white">Current Plan: Free</HeadingH3Bold> + <HeadingH3Bold className="text-foreground"> + Current Plan: Free + </HeadingH3Bold> <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 text-muted-foreground">Memories</span> <span - className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-400" : "text-white/90"}`} + className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-500" : "text-foreground"}`} > {memoriesUsed} / {memoriesLimit} </span> </div> - <div className="w-full bg-white/10 rounded-full h-2"> + <div className="w-full bg-muted-foreground/50 rounded-full h-2"> <div className={`h-2 rounded-full transition-all ${ memoriesUsed >= memoriesLimit ? "bg-red-500" : "bg-blue-500" @@ -183,23 +182,25 @@ export function BillingView() { {/* Comparison */} <div className="space-y-4"> - <HeadingH3Bold className="text-white">Upgrade to Pro</HeadingH3Bold> + <HeadingH3Bold className="text-foreground"> + Upgrade to Pro + </HeadingH3Bold> <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/* Free Plan */} - <div className="p-4 bg-white/5 rounded-lg border border-white/10"> - <h4 className="font-medium text-white/90 mb-3">Free Plan</h4> + <div className="p-4 bg-muted/50 rounded-lg border border-border"> + <h4 className="font-medium text-foreground mb-3">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" /> + <li className="flex items-center gap-2 text-sm text-muted-foreground"> + <CheckCircle className="h-4 w-4 text-green-500" /> 200 memories </li> - <li className="flex items-center gap-2 text-sm text-white/70"> - <X className="h-4 w-4 text-red-400" /> + <li className="flex items-center gap-2 text-sm text-muted-foreground"> + <X className="h-4 w-4 text-red-500" /> No connections </li> - <li className="flex items-center gap-2 text-sm text-white/70"> - <CheckCircle className="h-4 w-4 text-green-400" /> + <li className="flex items-center gap-2 text-sm text-muted-foreground"> + <CheckCircle className="h-4 w-4 text-green-500" /> Basic search </li> </ul> @@ -207,9 +208,9 @@ export function BillingView() { {/* Pro Plan */} <div className="p-4 bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-lg border border-blue-500/20"> - <h4 className="font-medium text-white mb-3 flex items-center gap-2"> + <h4 className="font-medium text-foreground mb-3 flex items-center gap-2"> Pro Plan - <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded-full"> + <span className="text-xs bg-blue-500/20 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded-full"> Recommended </span> </h4> @@ -218,44 +219,41 @@ export function BillingView() { <CheckCircle className="h-4 w-4 text-green-400" /> Unlimited memories </li> - <li className="flex items-center gap-2 text-sm text-white/90"> - <CheckCircle className="h-4 w-4 text-green-400" /> + <li className="flex items-center gap-2 text-sm text-foreground"> + <CheckCircle className="h-4 w-4 text-green-500" /> 10 connections </li> - <li className="flex items-center gap-2 text-sm text-white/90"> - <CheckCircle className="h-4 w-4 text-green-400" /> + <li className="flex items-center gap-2 text-sm text-foreground"> + <CheckCircle className="h-4 w-4 text-green-500" /> Advanced search </li> - <li className="flex items-center gap-2 text-sm text-white/90"> - <CheckCircle className="h-4 w-4 text-green-400" /> + <li className="flex items-center gap-2 text-sm text-foreground"> + <CheckCircle className="h-4 w-4 text-green-500" /> Priority support </li> </ul> </div> </div> - <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> - <Button - className="bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white border-0 w-full" - disabled={isLoading || isCheckingStatus} - onClick={handleUpgrade} - size="sm" - > - {isLoading || isCheckingStatus ? ( - <> - <LoaderIcon className="h-4 w-4 animate-spin mr-2" /> - Upgrading... - </> - ) : ( - <div>Upgrade to Pro - $15/month (only for first 100 users)</div> - )} - </Button> - </motion.div> + <Button + className="bg-blue-600 hover:bg-blue-700 text-white border-0 w-full" + disabled={isLoading || isCheckingStatus} + onClick={handleUpgrade} + > + {isLoading || isCheckingStatus ? ( + <> + <LoaderIcon className="h-4 w-4 animate-spin mr-2" /> + Upgrading... + </> + ) : ( + <div>Upgrade to Pro - $15/month (only for first 100 users)</div> + )} + </Button> - <p className="text-xs text-white/50 text-center"> + <p className="text-xs text-muted-foreground text-center"> Cancel anytime. No questions asked. </p> </div> </motion.div> - ); + ) } diff --git a/apps/web/components/views/chat/chat-messages.tsx b/apps/web/components/views/chat/chat-messages.tsx index 85f91565..db0ae209 100644 --- a/apps/web/components/views/chat/chat-messages.tsx +++ b/apps/web/components/views/chat/chat-messages.tsx @@ -1,49 +1,49 @@ -"use client"; +"use client" -import { useChat, useCompletion } from "@ai-sdk/react"; -import { cn } from "@lib/utils"; -import { Button } from "@ui/components/button"; -import { Input } from "@ui/components/input"; -import { DefaultChatTransport } from "ai"; +import { useChat, useCompletion } from "@ai-sdk/react" +import { cn } from "@lib/utils" +import { Button } from "@ui/components/button" +import { DefaultChatTransport } from "ai" import { ArrowUp, Check, ChevronDown, ChevronRight, Copy, + Plus, RotateCcw, X, -} from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { Streamdown } from "streamdown"; -import { TextShimmer } from "@/components/text-shimmer"; -import { usePersistentChat, useProject } from "@/stores"; -import { useGraphHighlights } from "@/stores/highlights"; -import { Spinner } from "../../spinner"; +} from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { toast } from "sonner" +import { Streamdown } from "streamdown" +import { TextShimmer } from "@/components/text-shimmer" +import { usePersistentChat, useProject } from "@/stores" +import { useGraphHighlights } from "@/stores/highlights" +import { Spinner } from "../../spinner" interface MemoryResult { - documentId?: string; - title?: string; - content?: string; - url?: string; - score?: number; + documentId?: string + title?: string + content?: string + url?: string + score?: number } interface ExpandableMemoriesProps { - foundCount: number; - results: MemoryResult[]; + foundCount: number + results: MemoryResult[] } function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) { - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(false) if (foundCount === 0) { return ( <div className="text-sm flex items-center gap-2 text-muted-foreground"> <Check className="size-4" /> No memories found </div> - ); + ) } return ( @@ -58,17 +58,16 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) { ) : ( <ChevronRight className="size-4" /> )} - <Check className="size-4" /> - Found {foundCount} {foundCount === 1 ? "memory" : "memories"} + Related memories </button> {isExpanded && results.length > 0 && ( - <div className="mt-2 ml-6 space-y-2 max-h-48 overflow-y-auto"> + <div className="mt-2 ml-6 space-y-2 max-h-48 overflow-y-auto grid grid-cols-3 gap-2"> {results.map((result, index) => { const isClickable = result.url && (result.url.startsWith("http://") || - result.url.startsWith("https://")); + result.url.startsWith("https://")) const content = ( <> @@ -83,7 +82,7 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) { </div> )} {result.url && ( - <div className="text-xs text-blue-400 mt-1 truncate"> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1 truncate"> {result.url} </div> )} @@ -93,12 +92,12 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) { </div> )} </> - ); + ) if (isClickable) { return ( <a - className="block p-2 bg-white/5 rounded-md border border-white/10 hover:bg-white/10 hover:border-white/20 transition-colors cursor-pointer" + className="block p-2 bg-accent/50 rounded-md border border-border hover:bg-accent transition-colors cursor-pointer" href={result.url} key={result.documentId || index} rel="noopener noreferrer" @@ -106,93 +105,93 @@ function ExpandableMemories({ foundCount, results }: ExpandableMemoriesProps) { > {content} </a> - ); + ) } return ( <div - className="p-2 bg-white/5 rounded-md border border-white/10" + className="p-2 bg-accent/50 rounded-md border border-border" key={result.documentId || index} > {content} </div> - ); + ) })} </div> )} </div> - ); + ) } 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) - const scrollToBottom = (behavior: ScrollBehavior = "auto") => { - const node = bottomRef.current; - if (node) node.scrollIntoView({ behavior, block: "end" }); - }; + const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => { + const node = bottomRef.current + if (node) node.scrollIntoView({ behavior, block: "end" }) + }, []) useEffect(function observeBottomVisibility() { - const container = scrollContainerRef.current; - const sentinel = bottomRef.current; - if (!container || !sentinel) return; + const 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); + 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(); - }, []); + ) + observer.observe(sentinel) + return () => observer.disconnect() + }, []) useEffect( function observeContentResize() { - const container = scrollContainerRef.current; - if (!container) return; + const container = scrollContainerRef.current + if (!container) return const resizeObserver = new ResizeObserver(() => { - if (isAutoScroll) scrollToBottom("auto"); + if (isAutoScroll) scrollToBottom("auto") const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - setIsFarFromBottom(distanceFromBottom > 100); - }); - resizeObserver.observe(container); - return () => resizeObserver.disconnect(); + container.scrollHeight - container.scrollTop - container.clientHeight + setIsFarFromBottom(distanceFromBottom > 100) + }) + resizeObserver.observe(container) + return () => resizeObserver.disconnect() }, - [isAutoScroll], - ); + [isAutoScroll, scrollToBottom], + ) function enableAutoScroll() { - setIsAutoScroll(true); + setIsAutoScroll(true) } useEffect( function autoScrollOnNewContent() { - if (isAutoScroll) scrollToBottom("auto"); + if (isAutoScroll) scrollToBottom("auto") }, [isAutoScroll, scrollToBottom, ...triggerKeys], - ); + ) - const recomputeDistanceFromBottom = () => { - const container = scrollContainerRef.current; - if (!container) return; + const recomputeDistanceFromBottom = useCallback(() => { + const container = scrollContainerRef.current + if (!container) return const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - setIsFarFromBottom(distanceFromBottom > 100); - }; + container.scrollHeight - container.scrollTop - container.clientHeight + setIsFarFromBottom(distanceFromBottom > 100) + }, []) useEffect(() => { - recomputeDistanceFromBottom(); - }, [recomputeDistanceFromBottom, ...triggerKeys]); + recomputeDistanceFromBottom() + }, [recomputeDistanceFromBottom, ...triggerKeys]) function onScroll() { - recomputeDistanceFromBottom(); + recomputeDistanceFromBottom() } return { @@ -203,11 +202,11 @@ function useStickyAutoScroll(triggerKeys: ReadonlyArray<unknown>) { onScroll, enableAutoScroll, scrollToBottom, - } as const; + } as const } export function ChatMessages() { - const { selectedProject } = useProject(); + const { selectedProject } = useProject() const { currentChatId, setCurrentChatId, @@ -215,12 +214,14 @@ export function ChatMessages() { getCurrentConversation, setConversationTitle, getCurrentChat, - } = usePersistentChat(); + } = usePersistentChat() - const activeChatIdRef = useRef<string | null>(null); - const shouldGenerateTitleRef = useRef<boolean>(false); + const [input, setInput] = useState("") + const activeChatIdRef = useRef<string | null>(null) + const shouldGenerateTitleRef = useRef<boolean>(false) + const hasRunInitialMessageRef = useRef<boolean>(false) - const { setDocumentIds } = useGraphHighlights(); + const { setDocumentIds } = useGraphHighlights() const { messages, sendMessage, status, stop, setMessages, id, regenerate } = useChat({ @@ -232,91 +233,118 @@ export function ChatMessages() { }), maxSteps: 2, onFinish: (result) => { - const activeId = activeChatIdRef.current; - if (!activeId) return; - if (result.message.role !== "assistant") return; + 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(); + ) as any + const text = textPart?.text?.trim() if (text) { - shouldGenerateTitleRef.current = false; - complete(text); + shouldGenerateTitleRef.current = false + complete(text) } } }, - }); + }) useEffect(() => { - activeChatIdRef.current = currentChatId ?? id ?? null; - }, [currentChatId, id]); + activeChatIdRef.current = currentChatId ?? id ?? null + }, [currentChatId, id]) + + useEffect(() => { + if (currentChatId && !hasRunInitialMessageRef.current) { + // Check if there's an initial message from the home page in sessionStorage + const storageKey = `chat-initial-${currentChatId}` + const initialMessage = sessionStorage.getItem(storageKey) + + if (initialMessage) { + // Clean up the storage and send the message + sessionStorage.removeItem(storageKey) + sendMessage({ text: initialMessage }) + hasRunInitialMessageRef.current = true + } + } + }, [currentChatId]) useEffect(() => { if (id && id !== currentChatId) { - setCurrentChatId(id); + setCurrentChatId(id) } - }, [id, currentChatId, setCurrentChatId]); + }, [id]) useEffect(() => { - const msgs = getCurrentConversation(); - setMessages(msgs ?? []); - setInput(""); - }, [currentChatId]); + const msgs = getCurrentConversation() + if (msgs && msgs.length > 0) { + setMessages(msgs) + } else if (!currentChatId) { + setMessages([]) + } + setInput("") + }, [currentChatId]) useEffect(() => { - const activeId = currentChatId ?? id; + const activeId = currentChatId ?? id if (activeId && messages.length > 0) { - setConversation(activeId, messages); + setConversation(activeId, messages) } - }, [messages, currentChatId, id, setConversation]); + }, [messages, currentChatId, id]) - 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 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; + .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; + ) + 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); + setDocumentIds(ids) } } catch {} - }, [messages, setDocumentIds]); + }, [messages]) useEffect(() => { - const currentSummary = getCurrentChat(); + const currentSummary = getCurrentChat() const hasTitle = Boolean( currentSummary?.title && currentSummary.title.trim().length > 0, - ); - shouldGenerateTitleRef.current = !hasTitle; - }, [getCurrentChat]); + ) + shouldGenerateTitleRef.current = !hasTitle + }, []) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + sendMessage({ text: input }) + setInput("") + } + } + const { scrollContainerRef, bottomRef, @@ -324,39 +352,48 @@ export function ChatMessages() { onScroll, enableAutoScroll, scrollToBottom, - } = useStickyAutoScroll([messages, status]); + } = useStickyAutoScroll([messages, status]) return ( - <> - <div className="relative grow"> + <div className="h-full flex flex-col w-full"> + <div className="flex-1 relative"> <div - className="flex flex-col gap-2 absolute inset-0 overflow-y-auto px-4 pt-4 pb-7 scroll-pb-7" + className="flex flex-col gap-2 absolute inset-0 overflow-y-auto px-4 pt-4 pb-7 scroll-pb-7 custom-scrollbar" onScroll={onScroll} ref={scrollContainerRef} > {messages.map((message) => ( <div className={cn( - "flex flex-col", - message.role === "user" ? "items-end" : "items-start", + "flex my-2", + message.role === "user" + ? "items-center flex-row-reverse gap-2" + : "flex-col", )} key={message.id} > - <div className="flex flex-col gap-2 max-w-4/5 bg-white/10 py-3 px-4 rounded-lg"> + <div + className={cn( + "flex flex-col gap-2 max-w-4/5", + message.role === "user" + ? "bg-accent/50 px-3 py-1.5 border border-border rounded-lg" + : "", + )} + > {message.parts .filter((part) => ["text", "tool-searchMemories", "tool-addMemory"].includes( part.type, ), ) - .map((part) => { + .map((part, index) => { switch (part.type) { case "text": return ( - <div key={message.id + part.type}> + <div key={`${message.id}-${part.type}-${index}`}> <Streamdown>{part.text}</Streamdown> </div> - ); + ) case "tool-searchMemories": { switch (part.state) { case "input-available": @@ -364,46 +401,46 @@ export function ChatMessages() { return ( <div className="text-sm flex items-center gap-2 text-muted-foreground" - key={message.id + part.type} + key={`${message.id}-${part.type}-${index}`} > <Spinner className="size-4" /> Searching memories... </div> - ); + ) case "output-error": return ( <div className="text-sm flex items-center gap-2 text-muted-foreground" - key={message.id + part.type} + key={`${message.id}-${part.type}-${index}`} > <X className="size-4" /> Error recalling memories </div> - ); + ) case "output-available": { - const output = part.output; + const output = part.output const foundCount = typeof output === "object" && output !== null && "count" in output ? Number(output.count) || 0 - : 0; + : 0 // @ts-expect-error const results = Array.isArray(output?.results) ? // @ts-expect-error output.results - : []; + : [] return ( <ExpandableMemories foundCount={foundCount} - key={message.id + part.type} + key={`${message.id}-${part.type}-${index}`} results={results} /> - ); + ) } default: - return null; + return null } } case "tool-addMemory": { @@ -412,44 +449,44 @@ export function ChatMessages() { return ( <div className="text-sm flex items-center gap-2 text-muted-foreground" - key={message.id + part.type} + key={`${message.id}-${part.type}-${index}`} > <Spinner className="size-4" /> Adding memory... </div> - ); + ) case "output-error": return ( <div className="text-sm flex items-center gap-2 text-muted-foreground" - key={message.id + part.type} + key={`${message.id}-${part.type}-${index}`} > <X className="size-4" /> Error adding memory </div> - ); + ) case "output-available": return ( <div className="text-sm flex items-center gap-2 text-muted-foreground" - key={message.id + part.type} + key={`${message.id}-${part.type}-${index}`} > <Check className="size-4" /> Memory added </div> - ); + ) case "input-streaming": return ( <div className="text-sm flex items-center gap-2 text-muted-foreground" - key={message.id + part.type} + key={`${message.id}-${part.type}-${index}`} > <Spinner className="size-4" /> Adding memory... </div> - ); + ) default: - return null; + return null } } default: - return null; + return null } })} </div> @@ -463,8 +500,8 @@ export function ChatMessages() { .filter((p) => p.type === "text") ?.map((p) => (p as any).text) .join("\n") ?? "", - ); - toast.success("Copied to clipboard"); + ) + toast.success("Copied to clipboard") }} size="icon" variant="ghost" @@ -503,8 +540,8 @@ export function ChatMessages() { : "opacity-0 scale-95 pointer-events-none", )} onClick={() => { - enableAutoScroll(); - scrollToBottom("smooth"); + enableAutoScroll() + scrollToBottom("smooth") }} size="sm" type="button" @@ -514,41 +551,44 @@ export function ChatMessages() { </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" - disabled={status === "submitted"} - onChange={(e) => setInput(e.target.value)} - placeholder="Say something..." - value={input} - /> - <Button disabled={status === "submitted"} type="submit"> - {status === "ready" ? ( - <ArrowUp className="size-4" /> - ) : status === "submitted" ? ( - <Spinner className="size-4" /> - ) : ( - <X className="size-4" /> - )} - </Button> - </form> - </> - ); + <div className="px-4 pb-4 pt-1 relative flex-shrink-0"> + <form + className="flex flex-col items-end gap-3 bg-card border border-border rounded-[22px] p-3 relative shadow-lg dark:shadow-2xl" + onSubmit={(e) => { + e.preventDefault() + if (status === "submitted") return + if (status === "streaming") { + stop() + return + } + if (input.trim()) { + enableAutoScroll() + scrollToBottom("auto") + sendMessage({ text: input }) + setInput("") + } + }} + > + <textarea + value={input} + onChange={(e) => setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask your follow-up question..." + className="w-full text-foreground placeholder:text-muted-foreground rounded-md outline-none resize-none text-base leading-relaxed px-3 py-3 bg-transparent" + rows={3} + /> + <div className="absolute bottom-2 right-2"> + <Button + type="submit" + disabled={!input.trim()} + className="text-primary-foreground rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed bg-primary hover:bg-primary/90" + size="icon" + > + <ArrowUp className="size-4" /> + </Button> + </div> + </form> + </div> + </div> + ) } diff --git a/apps/web/components/views/connections-tab-content.tsx b/apps/web/components/views/connections-tab-content.tsx index 14a63e8f..3aa06776 100644 --- a/apps/web/components/views/connections-tab-content.tsx +++ b/apps/web/components/views/connections-tab-content.tsx @@ -183,16 +183,16 @@ export function ConnectionsTabContent() { return ( <div className="space-y-4"> <div className="mb-4"> - <p className="text-sm text-white/70"> + <p className="text-sm text-foreground/70"> Connect your favorite services to import documents </p> {isProUser && !autumn.isLoading && ( - <p className="text-xs text-white/50 mt-1"> + <p className="text-xs text-foreground/50 mt-1"> {connectionsUsed} of {connectionsLimit} connections used </p> )} {!isProUser && !autumn.isLoading && ( - <p className="text-xs text-white/50 mt-1"> + <p className="text-xs text-foreground/50 mt-1"> Connections require a Pro subscription </p> )} @@ -208,7 +208,7 @@ export function ConnectionsTabContent() { <p className="text-sm text-yellow-400 mb-2"> 🔌 Connections are a Pro feature </p> - <p className="text-xs text-white/60 mb-3"> + <p className="text-xs text-foreground/60 mb-3"> Connect Google Drive, Notion, OneDrive and more to automatically sync your documents. </p> @@ -228,12 +228,12 @@ export function ConnectionsTabContent() { {[...Array(2)].map((_, i) => ( <motion.div animate={{ opacity: 1 }} - className="p-4 bg-white/5 rounded-lg" + className="p-4 bg-foreground/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" /> + <Skeleton className="h-12 w-full bg-foreground/10" /> </motion.div> ))} </div> @@ -244,8 +244,8 @@ export function ConnectionsTabContent() { 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"> + <p className="text-foreground/50 mb-2">No connections yet</p> + <p className="text-xs text-foreground/40"> Choose a service below to connect </p> </motion.div> @@ -255,7 +255,7 @@ export function ConnectionsTabContent() { {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" + className="flex items-center justify-between p-3 bg-foreground/5 rounded-lg hover:bg-foreground/10 transition-colors" exit={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: -20 }} key={connection.id} @@ -263,19 +263,13 @@ export function ConnectionsTabContent() { 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> + {getProviderIcon(connection.provider)} <div> - <p className="font-medium text-white capitalize"> + <p className="font-medium text-foreground capitalize"> {connection.provider.replace("-", " ")} </p> {connection.email && ( - <p className="text-sm text-white/60"> + <p className="text-sm text-foreground/60"> {connection.email} </p> )} @@ -286,7 +280,7 @@ export function ConnectionsTabContent() { whileTap={{ scale: 0.9 }} > <Button - className="text-white/50 hover:text-red-400" + className="text-foreground/50 hover:text-red-400" disabled={deleteConnectionMutation.isPending} onClick={() => deleteConnectionMutation.mutate(connection.id) @@ -305,9 +299,7 @@ export function ConnectionsTabContent() { {/* Available Connections Section */} <div className="mt-6"> - <h3 className="text-lg font-medium text-white mb-4"> - Available Connections - </h3> + <h3 className="text-lg font-medium mb-4">Available Connections</h3> <div className="grid gap-3"> {Object.entries(CONNECTORS).map(([provider, config], index) => { const Icon = config.icon @@ -317,11 +309,9 @@ export function ConnectionsTabContent() { 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" + className="justify-start h-auto p-4 bg-foreground/5 hover:bg-foreground/10 border-foreground/10 w-full" disabled={addConnectionMutation.isPending} onClick={() => { addConnectionMutation.mutate(provider as ConnectorProvider) @@ -331,7 +321,7 @@ export function ConnectionsTabContent() { <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"> + <div className="text-sm text-foreground/60 mt-0.5"> {config.description} </div> </div> diff --git a/apps/web/components/views/integrations.tsx b/apps/web/components/views/integrations.tsx index b3e3c92d..dadd7dc6 100644 --- a/apps/web/components/views/integrations.tsx +++ b/apps/web/components/views/integrations.tsx @@ -118,7 +118,10 @@ export function IntegrationsView() { } }, [autumn.isLoading, autumn.customer]) - const { data: connectionsCheck } = fetchConnectionsFeature(autumn) + const { data: connectionsCheck } = fetchConnectionsFeature( + autumn, + !autumn.isLoading, + ) const connectionsUsed = connectionsCheck?.balance ?? 0 const connectionsLimit = connectionsCheck?.included_usage ?? 0 @@ -281,25 +284,25 @@ export function IntegrationsView() { return ( <div className="space-y-4 sm:space-y-4 custom-scrollbar"> {/* iOS Shortcuts */} - <div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden"> + <div className="bg-card rounded-xl border border-border overflow-hidden shadow-sm"> <div className="p-4 sm:p-5"> <div className="flex items-start gap-3 mb-3"> - <div className="p-2 bg-blue-500/20 rounded-lg flex-shrink-0"> - <Smartphone className="h-5 w-5 text-blue-400" /> + <div className="p-2 bg-primary/10 rounded-lg flex-shrink-0"> + <Smartphone className="h-5 w-5 text-primary" /> </div> <div className="flex-1 min-w-0"> - <h3 className="text-white font-semibold text-base mb-1"> + <h3 className="text-card-foreground font-semibold text-base mb-1"> Apple shortcuts </h3> - <p className="text-white/70 text-sm leading-relaxed"> + <p className="text-muted-foreground text-sm leading-relaxed"> Add memories directly from iPhone, iPad or Mac. </p> </div> </div> <div className="flex flex-col sm:flex-row gap-2 sm:gap-3"> <Button - variant="ghost" - className="flex-1 text-white hover:bg-blue-500/10 bg-[#171F59]/75 " + variant="secondary" + className="flex-1" onClick={() => handleShortcutClick("add")} disabled={createApiKeyMutation.isPending} > @@ -314,8 +317,8 @@ export function IntegrationsView() { : "Add Memory Shortcut"} </Button> <Button - variant="ghost" - className="flex-1 text-white hover:bg-blue-500/10 bg-[#171F59]/75" + variant="secondary" + className="flex-1" onClick={() => handleShortcutClick("search")} disabled={createApiKeyMutation.isPending} > @@ -334,19 +337,19 @@ export function IntegrationsView() { </div> {/* Chrome Extension */} - <div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden"> + <div className="bg-card rounded-xl border border-border overflow-hidden shadow-sm"> <div className="p-4 sm:p-5"> <div className="flex items-start gap-3"> - <div className="p-2 bg-orange-500/20 rounded-lg flex-shrink-0"> - <ChromeIcon className="h-5 w-5 text-orange-400" /> + <div className="p-2 bg-primary/10 rounded-lg flex-shrink-0"> + <ChromeIcon className="h-5 w-5 text-primary" /> </div> <div className="flex-1 min-w-0 mb-3"> <div className="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-2 mb-1"> - <h3 className="text-white font-semibold text-base"> + <h3 className="text-card-foreground font-semibold text-base"> Chrome Extension </h3> <Button - className="text-white bg-secondary w-fit" + className="bg-secondary text-secondary-foreground hover:bg-secondary/80 w-fit" onClick={() => { window.open( "https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc", @@ -366,20 +369,20 @@ export function IntegrationsView() { </div> <div className="space-y-2"> <div className="flex items-center gap-3"> - <div className="w-1.5 h-1.5 bg-orange-400 rounded-full flex-shrink-0" /> - <p className="text-white/80 text-sm"> + <div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" /> + <p className="text-muted-foreground text-sm"> Save any webpage to supermemory </p> </div> <div className="flex items-center gap-3"> - <div className="w-1.5 h-1.5 bg-orange-400 rounded-full flex-shrink-0" /> - <p className="text-white/80 text-sm"> + <div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" /> + <p className="text-muted-foreground text-sm"> Import All your Twitter Bookmarks </p> </div> <div className="flex items-center gap-3"> - <div className="w-1.5 h-1.5 bg-orange-400 rounded-full flex-shrink-0" /> - <p className="text-white/80 text-sm"> + <div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" /> + <p className="text-muted-foreground text-sm"> Bring all your chatGPT memories to Supermemory </p> </div> @@ -388,12 +391,12 @@ export function IntegrationsView() { </div> {/* Connections Section */} - <div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden"> + <div className="bg-card rounded-xl border border-border overflow-hidden shadow-sm"> <div className="p-4 sm:p-5"> <div className="flex items-start gap-3 mb-3"> - <div className="p-2 bg-green-500/20 rounded-lg flex-shrink-0"> + <div className="p-2 bg-primary/10 rounded-lg flex-shrink-0"> <svg - className="h-5 w-5 text-green-400" + className="h-5 w-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24" @@ -408,14 +411,14 @@ export function IntegrationsView() { </svg> </div> <div className="flex-1 min-w-0"> - <h3 className="text-white font-semibold text-base mb-1"> + <h3 className="text-card-foreground font-semibold text-base mb-1"> Connections </h3> - <p className="text-white/70 text-sm leading-relaxed mb-2"> + <p className="text-muted-foreground text-sm leading-relaxed mb-2"> Connect your accounts to sync document. </p> {!isProUser && ( - <p className="text-xs text-white/50"> + <p className="text-xs text-muted-foreground/70"> Connections require a Pro subscription </p> )} @@ -426,21 +429,21 @@ export function IntegrationsView() { {!autumn.isLoading && !isProUser && ( <motion.div animate={{ opacity: 1, y: 0 }} - className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg mb-3" + className="p-4 bg-accent border border-border rounded-lg mb-3" initial={{ opacity: 0, y: -10 }} > - <p className="text-sm text-yellow-400 mb-2"> + <p className="text-sm text-accent-foreground mb-2 font-medium"> 🔌 Connections are a Pro feature </p> - <p className="text-xs text-white/60 mb-3"> + <p className="text-xs text-muted-foreground mb-3"> Connect Google Drive, Notion, OneDrive and more to automatically sync your documents. </p> <Button - className="bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border-yellow-500/30 w-full sm:w-auto" + className="w-full sm:w-auto" onClick={handleUpgrade} size="sm" - variant="secondary" + variant="default" > Upgrade to Pro </Button> @@ -453,12 +456,12 @@ export function IntegrationsView() { {Object.keys(CONNECTORS).map((_, i) => ( <motion.div animate={{ opacity: 1 }} - className="p-3 bg-white/5 rounded-lg" + className="p-3 bg-accent rounded-lg" initial={{ opacity: 0 }} key={`skeleton-${Date.now()}-${i}`} transition={{ delay: i * 0.1 }} > - <Skeleton className="h-12 w-full bg-white/10" /> + <Skeleton className="h-12 w-full bg-muted" /> </motion.div> ))} </div> @@ -474,46 +477,39 @@ export function IntegrationsView() { return ( <motion.div animate={{ opacity: 1, y: 0 }} - className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors" + className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-accent rounded-lg hover:bg-accent/80 transition-colors border border-border/50" initial={{ opacity: 0, y: 20 }} key={provider} transition={{ delay: index * 0.05 }} > <div className="flex items-center gap-3 flex-1"> - <motion.div - animate={{ rotate: 0, opacity: 1 }} - className="flex-shrink-0" - initial={{ rotate: -180, opacity: 0 }} - transition={{ delay: index * 0.05 + 0.2 }} - > - <Icon className="h-8 w-8" /> - </motion.div> + <Icon className="h-8 w-8" /> <div className="flex-1 min-w-0"> <div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2"> - <p className="font-medium text-white text-sm"> + <p className="font-medium text-card-foreground text-sm"> {config.title} </p> {isConnected ? ( <div className="flex items-center gap-1"> - <div className="w-2 h-2 bg-green-400 rounded-full" /> - <span className="text-xs text-green-400 font-medium"> + <div className="w-2 h-2 bg-chart-2 rounded-full" /> + <span className="text-xs text-chart-2 font-medium"> Connected </span> </div> ) : ( <div className="hidden sm:flex items-center gap-1"> - <div className="w-2 h-2 bg-gray-400 rounded-full" /> - <span className="text-xs text-gray-400 font-medium"> + <div className="w-2 h-2 bg-muted-foreground rounded-full" /> + <span className="text-xs text-muted-foreground font-medium"> Disconnected </span> </div> )} </div> - <p className="text-xs text-white/60 mt-0.5"> + <p className="text-xs text-muted-foreground mt-0.5"> {config.description} </p> {connection?.email && ( - <p className="text-xs text-white/50 mt-1"> + <p className="text-xs text-muted-foreground/70 mt-1"> {connection.email} </p> )} @@ -527,7 +523,7 @@ export function IntegrationsView() { whileTap={{ scale: 0.95 }} > <Button - className="text-white/70 hover:text-red-400 hover:bg-red-500/10 w-full sm:w-auto" + className="text-destructive hover:bg-destructive/10 w-full sm:w-auto" disabled={deleteConnectionMutation.isPending} onClick={() => deleteConnectionMutation.mutate(connection.id) @@ -542,8 +538,8 @@ export function IntegrationsView() { ) : ( <div className="flex items-center justify-between gap-2 w-full sm:w-auto"> <div className="sm:hidden flex items-center gap-1"> - <div className="w-2 h-2 bg-gray-400 rounded-full" /> - <span className="text-xs text-gray-400 font-medium"> + <div className="w-2 h-2 bg-muted-foreground rounded-full" /> + <span className="text-xs text-muted-foreground font-medium"> Disconnected </span> </div> @@ -553,7 +549,7 @@ export function IntegrationsView() { className="flex-shrink-0" > <Button - className="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border-blue-600/30 min-w-[80px] disabled:cursor-not-allowed" + className="min-w-[80px] disabled:cursor-not-allowed" disabled={ addConnectionMutation.isPending || !isProUser } @@ -563,7 +559,7 @@ export function IntegrationsView() { ) }} size="sm" - variant="outline" + variant="default" > {addConnectionMutation.isPending && addConnectionMutation.variables === provider @@ -583,14 +579,14 @@ export function IntegrationsView() { </div> <div className="p-3"> - <p className="text-white/70 text-sm leading-relaxed text-center"> + <p className="text-muted-foreground text-sm leading-relaxed text-center"> More integrations are coming soon! Have a suggestion? Share it with us on{" "} <a href="https://x.com/supermemoryai" target="_blank" rel="noopener noreferrer" - className="text-orange-500 hover:text-orange-400 underline" + className="text-primary hover:text-primary/80 underline" > X </a> @@ -601,9 +597,9 @@ export function IntegrationsView() { {/* API Key Modal */} <Dialog open={showApiKeyModal} onOpenChange={handleDialogClose}> <DialogPortal> - <DialogContent className="bg-[#0f1419] border-white/10 text-white md:max-w-md z-[100]"> + <DialogContent className="bg-card border-border text-card-foreground md:max-w-md z-[100]"> <DialogHeader> - <DialogTitle className="text-white text-lg font-semibold"> + <DialogTitle className="text-card-foreground text-lg font-semibold"> Setup{" "} {selectedShortcutType === "add" ? "Add Memory" @@ -619,7 +615,7 @@ export function IntegrationsView() { <div className="space-y-2"> <label htmlFor={apiKeyId} - className="text-sm font-medium text-white/80" + className="text-sm font-medium text-muted-foreground" > Your API Key </label> @@ -629,16 +625,16 @@ export function IntegrationsView() { type="text" value={apiKey} readOnly - className="flex-1 bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-sm text-white font-mono" + className="flex-1 bg-input border border-border rounded-lg px-3 py-2 text-sm text-foreground font-mono" /> <Button size="sm" variant="ghost" onClick={handleCopyApiKey} - className="text-white/70 hover:text-white hover:bg-white/10" + className="hover:bg-accent" > {copied ? ( - <Check className="h-4 w-4 text-green-400" /> + <Check className="h-4 w-4 text-chart-2" /> ) : ( <Copy className="h-4 w-4" /> )} @@ -648,31 +644,31 @@ export function IntegrationsView() { {/* Steps */} <div className="space-y-3"> - <h4 className="text-sm font-medium text-white/80"> + <h4 className="text-sm font-medium text-muted-foreground"> Follow these steps: </h4> <div className="space-y-2"> <div className="flex items-start gap-3"> - <div className="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-400 rounded-full flex items-center justify-center text-xs font-medium"> + <div className="flex-shrink-0 w-6 h-6 bg-primary/20 text-primary rounded-full flex items-center justify-center text-xs font-medium"> 1 </div> - <p className="text-sm text-white/70"> + <p className="text-sm text-muted-foreground"> Click "Add to Shortcuts" below to open the shortcut </p> </div> <div className="flex items-start gap-3"> - <div className="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-400 rounded-full flex items-center justify-center text-xs font-medium"> + <div className="flex-shrink-0 w-6 h-6 bg-primary/20 text-primary rounded-full flex items-center justify-center text-xs font-medium"> 2 </div> - <p className="text-sm text-white/70"> + <p className="text-sm text-muted-foreground"> Paste your API key when prompted </p> </div> <div className="flex items-start gap-3"> - <div className="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-400 rounded-full flex items-center justify-center text-xs font-medium"> + <div className="flex-shrink-0 w-6 h-6 bg-primary/20 text-primary rounded-full flex items-center justify-center text-xs font-medium"> 3 </div> - <p className="text-sm text-white/70"> + <p className="text-sm text-muted-foreground"> Start using your shortcut! </p> </div> @@ -682,8 +678,9 @@ export function IntegrationsView() { <div className="flex gap-2 pt-2"> <Button onClick={handleOpenShortcut} - className="flex-1 bg-blue-600 hover:bg-blue-700 text-white" + className="flex-1" disabled={!selectedShortcutType} + variant="default" > <Image src="/images/ios-shortcuts.png" diff --git a/apps/web/components/views/profile.tsx b/apps/web/components/views/profile.tsx index 3d49d395..93330da4 100644 --- a/apps/web/components/views/profile.tsx +++ b/apps/web/components/views/profile.tsx @@ -1,115 +1,61 @@ "use client" -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 { Skeleton } from "@repo/ui/components/skeleton" 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 { CheckCircle, CreditCard, LoaderIcon, User, X } from "lucide-react" import { motion } from "motion/react" import Link from "next/link" -import { useRouter } from "next/navigation" -import { useEffect, useState } from "react" -import { analytics } from "@/lib/analytics" -import { $fetch } from "@lib/api" +import { useState } from "react" export function ProfileView() { - const router = useRouter() - const { user: session } = useAuth() - const { - customer, - isLoading: isCustomerLoading, - openBillingPortal, - attach, - } = useCustomer() + const { user: session, org } = useAuth() + const organizations = org + const autumn = useCustomer() const [isLoading, setIsLoading] = useState(false) - const [billingData, setBillingData] = useState<{ - isPro: boolean - memoriesUsed: number - memoriesLimit: number - connectionsUsed: number - connectionsLimit: number - }>({ - isPro: false, - memoriesUsed: 0, - memoriesLimit: 0, - connectionsUsed: 0, - connectionsLimit: 0, - }) - useEffect(() => { - if (!isCustomerLoading) { - const memoriesFeature = customer?.features?.memories ?? { - usage: 0, - included_usage: 0, - } - const connectionsFeature = customer?.features?.connections ?? { - usage: 0, - included_usage: 0, - } + const { + data: status = { + consumer_pro: null, + }, + isLoading: isCheckingStatus, + } = fetchSubscriptionStatus(autumn, !autumn.isLoading) - setBillingData({ - isPro: - customer?.products?.some( - (product) => product.id === "consumer_pro", - ) ?? false, - memoriesUsed: memoriesFeature?.usage ?? 0, - memoriesLimit: memoriesFeature?.included_usage ?? 0, - connectionsUsed: connectionsFeature?.usage ?? 0, - connectionsLimit: connectionsFeature?.included_usage ?? 0, - }) - } - }, [isCustomerLoading, customer]) + const isPro = status.consumer_pro - const handleLogout = () => { - analytics.userSignedOut() - authClient.signOut() - router.push("/login") - } + const { data: memoriesCheck } = fetchMemoriesFeature( + autumn, + !isCheckingStatus && !autumn.isLoading, + ) + const memoriesUsed = memoriesCheck?.usage ?? 0 + const memoriesLimit = memoriesCheck?.included_usage ?? 0 + + const { data: connectionsCheck } = fetchConnectionsFeature(autumn, !isCheckingStatus && !autumn.isLoading) + const connectionsUsed = connectionsCheck?.usage ?? 0 const handleUpgrade = async () => { setIsLoading(true) try { - const upgradeResult = await attach({ + await autumn.attach({ productId: "consumer_pro", successUrl: "https://app.supermemory.ai/", }) - if ( - upgradeResult.statusCode === 200 && - upgradeResult.data && - "code" in upgradeResult.data - ) { - const isProPlanActivated = - upgradeResult.data.code === "new_product_attached" - if (isProPlanActivated && session?.name && session?.email) { - try { - await $fetch("@post/emails/welcome/pro", { - body: { - email: session?.email, - firstName: session?.name, - }, - }) - } catch (error) { - console.error(error) - } - } - } + window.location.reload() } catch (error) { console.error(error) setIsLoading(false) } } - // Handle manage billing const handleManageBilling = async () => { - await openBillingPortal({ + await autumn.openBillingPortal({ returnUrl: "https://app.supermemory.ai", }) } @@ -123,13 +69,13 @@ export function ProfileView() { initial={{ opacity: 0, scale: 0.9 }} transition={{ type: "spring", damping: 20 }} > - <p className="text-white/70 mb-4"> + <p className="text-foreground/70 mb-4"> Sign in to access your profile and billing </p> <motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <Button asChild - className="bg-white/10 hover:bg-white/20 text-white border-white/20" + className="bg-muted hover:bg-muted/80 text-foreground border-border" size="sm" > <Link href="/login">Sign in</Link> @@ -143,148 +89,193 @@ export function ProfileView() { return ( <div className="space-y-4"> {/* Profile Section */} - <div className="bg-white/5 rounded-lg p-4 space-y-3"> - <div className="flex items-center gap-3"> - <div className="w-10 h-10 bg-white/10 rounded-full flex items-center justify-center"> - <User className="w-5 h-5 text-white/80" /> + <div className="bg-card border border-border rounded-lg p-3 sm:p-4 space-y-4"> + <div className="flex items-start gap-3 sm:gap-4"> + <div className="w-12 h-12 sm:w-16 sm:h-16 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center flex-shrink-0"> + {session?.image ? ( + <img + src={session.image} + alt={session?.name || session?.email || "User"} + className="w-full h-full rounded-full object-cover" + /> + ) : ( + <User className="w-6 h-6 sm:w-8 sm:h-8 text-white" /> + )} </div> - <div className="flex-1"> - <p className="text-white font-medium text-sm">{session?.email}</p> - <p className="text-white/60 text-xs">Logged in</p> + <div className="flex-1 min-w-0"> + <div className="space-y-1"> + {session?.name && ( + <h3 className="text-foreground font-semibold text-base sm:text-lg truncate"> + {session.name} + </h3> + )} + <p className="text-foreground font-medium text-sm truncate"> + {session?.email} + </p> + </div> + </div> + </div> + + {/* Additional Profile Details */} + <div className="border-t border-border pt-3 space-y-2"> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 text-xs"> + <div> + <span className="text-muted-foreground block">Organization</span> + <span className="text-foreground font-medium"> + {organizations?.name || "Personal"} + </span> + </div> + <div> + <span className="text-muted-foreground block">Member since</span> + <span className="text-foreground font-medium"> + {session?.createdAt + ? new Date(session.createdAt).toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }) + : "Recent"} + </span> + </div> </div> </div> </div> - {isCustomerLoading ? ( - <div className="bg-white/5 rounded-lg p-4 space-y-3 flex items-center justify-center"> - <LoaderIcon className="h-4 w-4 animate-spin mr-2" /> - Loading... + {/* Billing Section */} + {autumn.isLoading || isCheckingStatus ? ( + <div className="bg-card border border-border rounded-lg p-3 sm:p-4 space-y-3"> + <div className="flex items-center gap-3 mb-3"> + <Skeleton className="w-8 h-8 sm:w-10 sm:h-10 rounded-full" /> + <div className="flex-1 space-y-2"> + <Skeleton className="h-4 w-20" /> + <Skeleton className="h-3 w-32" /> + </div> + </div> + <div className="space-y-2"> + <div className="flex justify-between items-center"> + <Skeleton className="h-3 w-16" /> + <Skeleton className="h-3 w-12" /> + </div> + <Skeleton className="h-2 w-full rounded-full" /> + </div> + <div className="flex justify-between items-center"> + <Skeleton className="h-3 w-20" /> + <Skeleton className="h-3 w-8" /> + </div> + <div className="pt-2"> + <Skeleton className="h-8 w-full rounded" /> + </div> </div> ) : ( - <> - {/* Billing Section */} - <div className="bg-white/5 rounded-lg p-4 space-y-3"> - <div className="flex items-center gap-3 mb-3"> - <div className="w-10 h-10 bg-white/10 rounded-full flex items-center justify-center"> - <CreditCard className="w-5 h-5 text-white/80" /> - </div> - <div className="flex-1"> - <HeadingH3Bold className="text-white text-sm"> - {billingData.isPro ? "Pro Plan" : "Free Plan"} - {billingData.isPro && ( - <span className="ml-2 text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full"> - Active - </span> - )} - </HeadingH3Bold> - <p className="text-white/60 text-xs"> - {billingData.isPro - ? "Expanded memory capacity" - : "Basic plan"} - </p> - </div> + <div className="bg-card border border-border rounded-lg p-3 sm:p-4 space-y-3"> + <div className="flex items-center gap-3 mb-3"> + <div className="w-8 h-8 sm:w-10 sm:h-10 bg-muted rounded-full flex items-center justify-center"> + <CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground" /> </div> - - {/* Usage Stats */} - <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 ${billingData.memoriesUsed >= billingData.memoriesLimit ? "text-red-400" : "text-white/90"}`} - > - {billingData.memoriesUsed} / {billingData.memoriesLimit} - </span> - </div> - <div className="w-full bg-white/10 rounded-full h-2"> - <div - className={`h-2 rounded-full transition-all ${ - billingData.memoriesUsed >= billingData.memoriesLimit - ? "bg-red-500" - : billingData.isPro - ? "bg-green-500" - : "bg-blue-500" - }`} - style={{ - width: `${Math.min((billingData.memoriesUsed / billingData.memoriesLimit) * 100, 100)}%`, - }} - /> - </div> + <div className="flex-1"> + <HeadingH3Bold className="text-foreground text-sm"> + {isPro ? "Pro Plan" : "Free Plan"} + {isPro && ( + <span className="ml-2 text-xs bg-green-500/20 text-green-600 dark:text-green-400 px-2 py-0.5 rounded-full"> + Active + </span> + )} + </HeadingH3Bold> + <p className="text-muted-foreground text-xs"> + {isPro ? "Expanded memory capacity" : "Basic plan"} + </p> </div> + </div> - {billingData.isPro && ( - <div className="flex justify-between items-center"> - <span className="text-sm text-white/70">Connections</span> - <span className="text-sm text-white/90"> - {billingData.connectionsUsed} / 10 - </span> - </div> - )} - - {/* Billing Actions */} - <div className="pt-2"> - {billingData.isPro ? ( - <motion.div - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - <Button - className="w-full bg-white/10 hover:bg-white/20 text-white border-white/20" - onClick={handleManageBilling} - size="sm" - variant="outline" - > - Manage Billing - </Button> - </motion.div> - ) : ( - <motion.div - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - <Button - className="w-full bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white border-0" - disabled={isLoading || isCustomerLoading} - onClick={handleUpgrade} - size="sm" - > - {isLoading || isCustomerLoading ? ( - <> - <LoaderIcon className="h-4 w-4 animate-spin mr-2" /> - Upgrading... - </> - ) : ( - "Upgrade to Pro - $15/month" - )} - </Button> - </motion.div> - )} + {/* Usage Stats */} + <div className="space-y-2"> + <div className="flex justify-between items-center"> + <span className="text-sm text-muted-foreground">Memories</span> + <span + className={`text-sm ${memoriesUsed >= memoriesLimit ? "text-red-500" : "text-foreground"}`} + > + {memoriesUsed} / {memoriesLimit} + </span> + </div> + <div className="w-full bg-muted-foreground/50 rounded-full h-2"> + <div + className={`h-2 rounded-full transition-all ${ + memoriesUsed >= memoriesLimit + ? "bg-red-500" + : isPro + ? "bg-green-500" + : "bg-blue-500" + }`} + style={{ + width: `${Math.min((memoriesUsed / memoriesLimit) * 100, 100)}%`, + }} + /> </div> </div> - {/* Plan Comparison - Only show for free users */} - {!billingData.isPro && ( - <div className="bg-white/5 rounded-lg p-4 space-y-4"> - <HeadingH3Bold className="text-white text-sm"> - Upgrade to Pro - </HeadingH3Bold> + {isPro && ( + <div className="flex justify-between items-center"> + <span className="text-sm text-muted-foreground">Connections</span> + <span className="text-sm text-foreground"> + {connectionsUsed} / 10 + </span> + </div> + )} - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* Billing Actions */} + <div className="pt-2"> + {isPro ? ( + <Button + className="w-full" + onClick={handleManageBilling} + size="sm" + variant="default" + > + Manage Billing + </Button> + ) : ( + <Button + className="w-full bg-[#267ffa] hover:bg-[#267ffa]/90 text-white border-0" + disabled={isLoading || isCheckingStatus} + onClick={handleUpgrade} + size="lg" + > + {isLoading || isCheckingStatus ? ( + <> + <LoaderIcon className="h-4 w-4 animate-spin mr-2" /> + <span className="hidden sm:inline">Upgrading...</span> + <span className="sm:hidden">Loading...</span> + </> + ) : ( + <> + <span className="hidden sm:inline"> + Upgrade to Pro - $15/month + </span> + <span className="sm:hidden">Upgrade to Pro</span> + </> + )} + </Button> + )} + </div> + + {!isPro && ( + <div className="space-y-4"> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm: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"> + <div className="p-3 bg-muted/50 rounded-lg border border-border"> + <h4 className="font-medium text-foreground 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" /> + <li className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground"> + <CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" /> 200 memories </li> - <li className="flex items-center gap-2 text-sm text-white/70"> - <X className="h-4 w-4 text-red-400" /> + <li className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground"> + <X className="h-3 w-3 sm:h-4 sm:w-4 text-red-500 flex-shrink-0" /> No connections </li> - <li className="flex items-center gap-2 text-sm text-white/70"> - <CheckCircle className="h-4 w-4 text-green-400" /> + <li className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground"> + <CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" /> Basic search </li> </ul> @@ -292,50 +283,43 @@ export function ProfileView() { {/* Pro Plan */} <div className="p-3 bg-gradient-to-br from-blue-500/10 to-purple-500/10 rounded-lg border border-blue-500/20"> - <h4 className="font-medium text-white mb-3 flex items-center gap-2 text-sm"> - Pro Plan - <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded-full"> - Recommended - </span> + <h4 className="font-medium text-foreground mb-3 text-sm"> + <div className="flex items-center gap-2 flex-wrap"> + <span>Pro Plan</span> + <span className="text-xs bg-blue-500/20 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded-full"> + Recommended + </span> + </div> </h4> <ul className="space-y-2"> - <li className="flex items-center gap-2 text-sm text-white/90"> + <li className="flex items-center gap-2 text-sm text-black/90"> <CheckCircle className="h-4 w-4 text-green-400" /> Unlimited memories </li> - <li className="flex items-center gap-2 text-sm text-white/90"> - <CheckCircle className="h-4 w-4 text-green-400" /> + <li className="flex items-center gap-2 text-xs sm:text-sm text-foreground"> + <CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" /> 10 connections </li> - <li className="flex items-center gap-2 text-sm text-white/90"> - <CheckCircle className="h-4 w-4 text-green-400" /> + <li className="flex items-center gap-2 text-xs sm:text-sm text-foreground"> + <CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" /> Advanced search </li> - <li className="flex items-center gap-2 text-sm text-white/90"> - <CheckCircle className="h-4 w-4 text-green-400" /> + <li className="flex items-center gap-2 text-xs sm:text-sm text-foreground"> + <CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-500 flex-shrink-0" /> Priority support </li> </ul> </div> </div> - <p className="text-xs text-white/50 text-center"> + <p className="text-xs text-muted-foreground text-center leading-relaxed"> $15/month (only for first 100 users) • Cancel anytime. No questions asked. </p> </div> )} - </> + </div> )} - - <Button - className="w-full bg-red-500/20 hover:bg-red-500/30 text-red-200 border-red-500/30" - onClick={handleLogout} - variant="destructive" - > - <LogOut className="w-4 h-4 mr-2" /> - Sign Out - </Button> </div> ) -} +}
\ No newline at end of file |