diff options
| author | Mahesh Sanikommmu <[email protected]> | 2025-08-23 00:38:57 -0700 |
|---|---|---|
| committer | Mahesh Sanikommmu <[email protected]> | 2025-08-23 00:38:57 -0700 |
| commit | 3816666e2d9b5eaa6d8a0d0f0c838ede41a69f44 (patch) | |
| tree | 92ad10b35b9b9a07155304b560a283b53602a16c | |
| parent | fix: env vars (diff) | |
| download | supermemory-3816666e2d9b5eaa6d8a0d0f0c838ede41a69f44.tar.xz supermemory-3816666e2d9b5eaa6d8a0d0f0c838ede41a69f44.zip | |
ui (memory detail): improved memory detail view and open chat
| -rw-r--r-- | apps/web/app/page.tsx | 556 | ||||
| -rw-r--r-- | apps/web/components/memories/index.tsx | 53 | ||||
| -rw-r--r-- | apps/web/components/memories/memory-detail.tsx | 375 | ||||
| -rw-r--r-- | apps/web/components/memory-list-view.tsx | 497 | ||||
| -rw-r--r-- | apps/web/lib/document-icon.tsx | 54 | ||||
| -rw-r--r-- | packages/ui/components/sheet.tsx | 2 |
6 files changed, 768 insertions, 769 deletions
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 335d79e3..d6edc122 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -388,284 +388,284 @@ const MemoryGraphPage = () => { }, []); return ( - <div className="relative h-screen bg-[#0f1419] overflow-hidden touch-none"> - {/* Main content area */} - <motion.div - animate={{ - marginRight: isOpen && !isMobile ? 600 : 0, - }} - className="h-full relative" - transition={{ - duration: 0.2, - ease: [0.4, 0, 0.2, 1], // Material Design easing - snappy but smooth - }} - > - <motion.div - animate={{ opacity: 1, y: 0 }} - className="absolute md:top-4 md:right-4 md:bottom-auto md:left-auto bottom-8 left-6 z-20 rounded-xl overflow-hidden" - id={TOUR_STEP_IDS.VIEW_TOGGLE} - initial={{ opacity: 0, y: -20 }} - transition={{ type: "spring", stiffness: 300, damping: 25 }} - > - <GlassMenuEffect rounded="rounded-xl" /> - <div className="relative z-10 p-2 flex gap-1"> - <motion.button - animate={{ - color: viewMode === "graph" ? "#93c5fd" : "#cbd5e1", - }} - className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors" - onClick={() => handleViewModeChange("graph")} - transition={{ duration: 0.2 }} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - {viewMode === "graph" && ( - <motion.div - className="absolute inset-0 bg-blue-500/20 rounded-md" - layoutId="activeBackground" - transition={{ - type: "spring", - stiffness: 400, - damping: 30, - }} - /> - )} - <span className="relative z-10 flex items-center gap-2"> - <LayoutGrid className="w-4 h-4" /> - <span className="hidden md:inline">Graph</span> - </span> - </motion.button> - - <motion.button - animate={{ - color: viewMode === "list" ? "#93c5fd" : "#cbd5e1", - }} - className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors" - onClick={() => handleViewModeChange("list")} - transition={{ duration: 0.2 }} - whileHover={{ scale: 1.02 }} - whileTap={{ scale: 0.98 }} - > - {viewMode === "list" && ( - <motion.div - className="absolute inset-0 bg-blue-500/20 rounded-md" - layoutId="activeBackground" - transition={{ - type: "spring", - stiffness: 400, - damping: 30, - }} - /> - )} - <span className="relative z-10 flex items-center gap-2"> - <List className="w-4 h-4" /> - <span className="hidden md:inline">List</span> - </span> - </motion.button> - </div> - </motion.div> - - {/* Animated content switching */} - <AnimatePresence mode="wait"> - {viewMode === "graph" ? ( - <motion.div - animate={{ opacity: 1, scale: 1 }} - className="absolute inset-0" - exit={{ opacity: 0, scale: 0.95 }} - id={TOUR_STEP_IDS.MEMORY_GRAPH} - initial={{ opacity: 0, scale: 0.95 }} - key="graph" - transition={{ - type: "spring", - stiffness: 500, - damping: 30, - }} - > - <MemoryGraph - documents={allDocuments} - error={error} - hasMore={hasMore} - isLoading={isPending} - isLoadingMore={isLoadingMore} - legendId={TOUR_STEP_IDS.LEGEND} - loadMoreDocuments={loadMoreDocuments} - showSpacesSelector={false} - totalLoaded={totalLoaded} - variant="consumer" - highlightDocumentIds={allHighlightDocumentIds} - highlightsVisible={isOpen} - occludedRightPx={isOpen && !isMobile ? 600 : 0} - autoLoadOnViewport={false} - isExperimental={isCurrentProjectExperimental} - > - <div className="absolute inset-0 flex items-center justify-center"> - <div className="rounded-xl overflow-hidden"> - <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> - <p className="text-lg font-medium mb-2"> - No Memories to Visualize - </p> - <button - type="button" - className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" - onClick={() => setShowAddMemoryView(true)} - > - Create one? - </button> - </div> - </div> - </div> - </MemoryGraph> - </motion.div> - ) : ( - <motion.div - animate={{ opacity: 1, scale: 1 }} - className="absolute inset-0 md:ml-18" - exit={{ opacity: 0, scale: 0.95 }} - id={TOUR_STEP_IDS.MEMORY_LIST} - initial={{ opacity: 0, scale: 0.95 }} - key="list" - transition={{ - type: "spring", - stiffness: 500, - damping: 30, - }} - > - <MemoryListView - documents={allDocuments} - error={error} - hasMore={hasMore} - isLoading={isPending} - isLoadingMore={isLoadingMore} - loadMoreDocuments={loadMoreDocuments} - totalLoaded={totalLoaded} - > - <div className="absolute inset-0 flex items-center justify-center"> - <div className="rounded-xl overflow-hidden"> - <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> - <p className="text-lg font-medium mb-2"> - No Memories to Visualize - </p> - <button - className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" - onClick={() => setShowAddMemoryView(true)} - type="button" - > - Create one? - </button> - </div> - </div> - </div> - </MemoryListView> - </motion.div> - )} - </AnimatePresence> - - {/* Top Bar */} - <div className="absolute top-2 left-0 right-0 z-10 p-4 flex items-center justify-between"> - <div className="flex items-center gap-3 justify-between w-full md:w-fit md:justify-start"> - <Link - className="pointer-events-auto" - href="https://supermemory.ai" - rel="noopener noreferrer" - target="_blank" - > - <LogoFull - className="h-8 hidden md:block" - id={TOUR_STEP_IDS.LOGO} - /> - <Logo className="h-8 md:hidden" id={TOUR_STEP_IDS.LOGO} /> - </Link> - - <div className="hidden sm:block"> - <ProjectSelector /> - </div> - - <ConnectAIModal> - <Button - variant="outline" - size="sm" - className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3" - > - <Unplug className="h-4 w-4" /> - <span className="hidden sm:inline ml-2"> - Connect to your AI - </span> - <span className="sm:hidden ml-1">Connect AI</span> - </Button> - </ConnectAIModal> - </div> - - <div> - <Menu /> - </div> - </div> - - {/* Floating Open Chat Button */} - {!isOpen && !isMobile && ( - <motion.div - animate={{ opacity: 1, scale: 1 }} - className="fixed bottom-6 right-6 z-50" - initial={{ opacity: 0, scale: 0.8 }} - transition={{ - type: "spring", - stiffness: 300, - damping: 25, - }} - > - <Button - className="h-14 px-4 bg-blue-600 hover:bg-blue-700 text-white shadow-lg hover:shadow-xl transition-all duration-200 rounded-full flex items-center gap-2" - onClick={() => setIsOpen(true)} - size="lg" - > - <MessageSquare className="h-5 w-5" /> - <span className="font-medium">Open Chat</span> - </Button> - </motion.div> - )} - </motion.div> - - {/* Chat panel - positioned absolutely */} - <motion.div - className="fixed top-0 right-0 h-full z-50 md:z-auto" - style={{ - width: isOpen ? (isMobile ? "100vw" : "600px") : 0, - pointerEvents: isOpen ? "auto" : "none", - }} - id={TOUR_STEP_IDS.FLOATING_CHAT} - > - <motion.div - animate={{ x: isOpen ? 0 : isMobile ? "100%" : 600 }} - className="absolute inset-0" - exit={{ x: isMobile ? "100%" : 600 }} - initial={{ x: isMobile ? "100%" : 600 }} - key="chat" - transition={{ - type: "spring", - stiffness: 500, - damping: 40, - }} - > - <ChatRewrite /> - </motion.div> - </motion.div> - - {showAddMemoryView && ( - <AddMemoryView - initialTab="note" - onClose={() => setShowAddMemoryView(false)} - /> - )} - - {/* Tour Alert Dialog */} - <TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} /> - - {/* Referral/Upgrade Modal */} - <ReferralUpgradeModal - isOpen={showReferralModal} - onClose={() => setShowReferralModal(false)} - /> - </div> - ); + <div className="relative h-screen bg-[#0f1419] overflow-hidden touch-none"> + {/* Main content area */} + <motion.div + animate={{ + marginRight: isOpen && !isMobile ? 600 : 0, + }} + className="h-full relative" + transition={{ + duration: 0.2, + ease: [0.4, 0, 0.2, 1], // Material Design easing - snappy but smooth + }} + > + <motion.div + animate={{ opacity: 1, y: 0 }} + className="absolute md:top-4 md:right-4 md:bottom-auto md:left-auto bottom-8 left-6 z-20 rounded-xl overflow-hidden" + id={TOUR_STEP_IDS.VIEW_TOGGLE} + initial={{ opacity: 0, y: -20 }} + transition={{ type: 'spring', stiffness: 300, damping: 25 }} + > + <GlassMenuEffect rounded="rounded-xl" /> + <div className="relative z-10 p-2 flex gap-1"> + <motion.button + animate={{ + color: viewMode === 'graph' ? '#93c5fd' : '#cbd5e1', + }} + className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors" + onClick={() => handleViewModeChange('graph')} + transition={{ duration: 0.2 }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + {viewMode === 'graph' && ( + <motion.div + className="absolute inset-0 bg-blue-500/20 rounded-md" + layoutId="activeBackground" + transition={{ + type: 'spring', + stiffness: 400, + damping: 30, + }} + /> + )} + <span className="relative z-10 flex items-center gap-2"> + <LayoutGrid className="w-4 h-4" /> + <span className="hidden md:inline">Graph</span> + </span> + </motion.button> + + <motion.button + animate={{ + color: viewMode === 'list' ? '#93c5fd' : '#cbd5e1', + }} + className="relative h-8 px-3 flex items-center gap-2 text-sm font-medium rounded-md transition-colors" + onClick={() => handleViewModeChange('list')} + transition={{ duration: 0.2 }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + {viewMode === 'list' && ( + <motion.div + className="absolute inset-0 bg-blue-500/20 rounded-md" + layoutId="activeBackground" + transition={{ + type: 'spring', + stiffness: 400, + damping: 30, + }} + /> + )} + <span className="relative z-10 flex items-center gap-2"> + <List className="w-4 h-4" /> + <span className="hidden md:inline">List</span> + </span> + </motion.button> + </div> + </motion.div> + + {/* Animated content switching */} + <AnimatePresence mode="wait"> + {viewMode === 'graph' ? ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="absolute inset-0" + exit={{ opacity: 0, scale: 0.95 }} + id={TOUR_STEP_IDS.MEMORY_GRAPH} + initial={{ opacity: 0, scale: 0.95 }} + key="graph" + transition={{ + type: 'spring', + stiffness: 500, + damping: 30, + }} + > + <MemoryGraph + documents={allDocuments} + error={error} + hasMore={hasMore} + isLoading={isPending} + isLoadingMore={isLoadingMore} + legendId={TOUR_STEP_IDS.LEGEND} + loadMoreDocuments={loadMoreDocuments} + showSpacesSelector={false} + totalLoaded={totalLoaded} + variant="consumer" + highlightDocumentIds={allHighlightDocumentIds} + highlightsVisible={isOpen} + occludedRightPx={isOpen && !isMobile ? 600 : 0} + autoLoadOnViewport={false} + isExperimental={isCurrentProjectExperimental} + > + <div className="absolute inset-0 flex items-center justify-center"> + <div className="rounded-xl overflow-hidden"> + <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> + <p className="text-lg font-medium mb-2"> + No Memories to Visualize + </p> + <button + type="button" + className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" + onClick={() => setShowAddMemoryView(true)} + > + Create one? + </button> + </div> + </div> + </div> + </MemoryGraph> + </motion.div> + ) : ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="absolute inset-0 md:ml-18" + exit={{ opacity: 0, scale: 0.95 }} + id={TOUR_STEP_IDS.MEMORY_LIST} + initial={{ opacity: 0, scale: 0.95 }} + key="list" + transition={{ + type: 'spring', + stiffness: 500, + damping: 30, + }} + > + <MemoryListView + documents={allDocuments} + error={error} + hasMore={hasMore} + isLoading={isPending} + isLoadingMore={isLoadingMore} + loadMoreDocuments={loadMoreDocuments} + totalLoaded={totalLoaded} + > + <div className="absolute inset-0 flex items-center justify-center"> + <div className="rounded-xl overflow-hidden"> + <div className="relative z-10 text-slate-200 px-6 py-4 text-center"> + <p className="text-lg font-medium mb-2"> + No Memories to Visualize + </p> + <button + className="text-sm text-blue-400 hover:text-blue-300 transition-colors cursor-pointer underline" + onClick={() => setShowAddMemoryView(true)} + type="button" + > + Create one? + </button> + </div> + </div> + </div> + </MemoryListView> + </motion.div> + )} + </AnimatePresence> + + {/* Top Bar */} + <div className="absolute top-2 left-0 right-0 z-10 p-4 flex items-center justify-between"> + <div className="flex items-center gap-3 justify-between w-full md:w-fit md:justify-start"> + <Link + className="pointer-events-auto" + href="https://supermemory.ai" + rel="noopener noreferrer" + target="_blank" + > + <LogoFull + className="h-8 hidden md:block" + id={TOUR_STEP_IDS.LOGO} + /> + <Logo className="h-8 md:hidden" id={TOUR_STEP_IDS.LOGO} /> + </Link> + + <div className="hidden sm:block"> + <ProjectSelector /> + </div> + + <ConnectAIModal> + <Button + variant="outline" + size="sm" + className="bg-white/5 hover:bg-white/10 border-white/20 text-white hover:text-white px-2 sm:px-3" + > + <Unplug className="h-4 w-4" /> + <span className="hidden sm:inline ml-2"> + Connect to your AI + </span> + <span className="sm:hidden ml-1">Connect AI</span> + </Button> + </ConnectAIModal> + </div> + + <div> + <Menu /> + </div> + </div> + + {/* Floating Open Chat Button */} + {!isOpen && !isMobile && ( + <motion.div + animate={{ opacity: 1, scale: 1 }} + className="fixed bottom-6 right-6 z-50" + initial={{ opacity: 0, scale: 0.8 }} + transition={{ + type: 'spring', + stiffness: 300, + damping: 25, + }} + > + <Button + className="px-4 bg-white hover:bg-white/80 text-[#001A39] shadow-lg hover:shadow-xl transition-all duration-200 rounded-full flex items-center gap-2 cursor-pointer" + onClick={() => setIsOpen(true)} + size="lg" + > + <MessageSquare className="h-5 w-5" /> + <span className="font-medium">Open Chat</span> + </Button> + </motion.div> + )} + </motion.div> + + {/* Chat panel - positioned absolutely */} + <motion.div + className="fixed top-0 right-0 h-full z-50 md:z-auto" + style={{ + width: isOpen ? (isMobile ? '100vw' : '600px') : 0, + pointerEvents: isOpen ? 'auto' : 'none', + }} + id={TOUR_STEP_IDS.FLOATING_CHAT} + > + <motion.div + animate={{ x: isOpen ? 0 : isMobile ? '100%' : 600 }} + className="absolute inset-0" + exit={{ x: isMobile ? '100%' : 600 }} + initial={{ x: isMobile ? '100%' : 600 }} + key="chat" + transition={{ + type: 'spring', + stiffness: 500, + damping: 40, + }} + > + <ChatRewrite /> + </motion.div> + </motion.div> + + {showAddMemoryView && ( + <AddMemoryView + initialTab="note" + onClose={() => setShowAddMemoryView(false)} + /> + )} + + {/* Tour Alert Dialog */} + <TourAlertDialog onOpenChange={setShowTourDialog} open={showTourDialog} /> + + {/* Referral/Upgrade Modal */} + <ReferralUpgradeModal + isOpen={showReferralModal} + onClose={() => setShowReferralModal(false)} + /> + </div> + ); }; // Wrapper component to handle auth and waitlist checks diff --git a/apps/web/components/memories/index.tsx b/apps/web/components/memories/index.tsx new file mode 100644 index 00000000..97ef57bd --- /dev/null +++ b/apps/web/components/memories/index.tsx @@ -0,0 +1,53 @@ +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 new file mode 100644 index 00000000..eeef6d89 --- /dev/null +++ b/apps/web/components/memories/memory-detail.tsx @@ -0,0 +1,375 @@ +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 { colors } from '@repo/ui/memory-graph/constants'; +import type { DocumentsWithMemoriesResponseSchema } from '@repo/validation/api'; +import { Badge } from '@ui/components/badge'; +import { Brain, Calendar, ExternalLink, 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'; + +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 [isSummaryExpanded, setIsSummaryExpanded] = useState(false); + const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten); + const forgottenMemories = document.memoryEntries.filter( + (m) => m.isForgotten + ); + + const HeaderContent = ({ + TitleComponent, + }: { + TitleComponent: typeof SheetTitle | typeof DrawerTitle; + }) => ( + <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 SummarySection = () => { + if (!document.summary) return null; + + const shouldShowToggle = document.summary.length > 200; // Show toggle for longer summaries + + return ( + <div + className="mt-4 p-3 rounded-lg" + style={{ + backgroundColor: 'rgba(255, 255, 255, 0.03)', + border: '1px solid rgba(255, 255, 255, 0.08)', + }} + > + <p + className={`text-sm ${!isSummaryExpanded ? 'line-clamp-3' : ''}`} + style={{ color: colors.text.muted }} + > + {document.content} + </p> + {shouldShowToggle && ( + <button + onClick={() => setIsSummaryExpanded(!isSummaryExpanded)} + className="mt-2 text-xs hover:underline transition-all" + style={{ color: colors.accent.primary }} + type="button" + > + {isSummaryExpanded ? 'Show less' : 'Show more'} + </button> + )} + </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, index) => ( + <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> + + <SummarySection /> + </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> + + <SummarySection /> + </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 8269562a..2cff96fd 100644 --- a/apps/web/components/memory-list-view.tsx +++ b/apps/web/components/memory-list-view.tsx @@ -2,42 +2,14 @@ import { useIsMobile } from "@hooks/use-mobile"; import { cn } from "@lib/utils"; -import { - GoogleDocs, - GoogleDrive, - GoogleSheets, - GoogleSlides, - MicrosoftExcel, - MicrosoftOneNote, - MicrosoftPowerpoint, - MicrosoftWord, - NotionDoc, - OneDrive, - PDF, -} from "@repo/ui/assets/icons"; import { Badge } from "@repo/ui/components/badge"; import { Card, CardContent, CardHeader } from "@repo/ui/components/card"; -import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle, -} from "@repo/ui/components/drawer"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from "@repo/ui/components/sheet"; import { colors } from "@repo/ui/memory-graph/constants"; import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { Label1Regular } from "@ui/text/label/label-1-regular"; import { Brain, - Calendar, ExternalLink, - FileText, Sparkles, } from "lucide-react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -45,9 +17,12 @@ import type { z } from "zod"; import useResizeObserver from "@/hooks/use-resize-observer"; import { analytics } from "@/lib/analytics"; +import { MemoryDetail } from "./memories/memory-detail"; +import { getDocumentIcon } from "@/lib/document-icon"; +import { formatDate, getSourceUrl } from "./memories"; + type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>; type DocumentWithMemories = DocumentsResponse["documents"][0]; -type MemoryEntry = DocumentWithMemories["memoryEntries"][0]; interface MemoryListViewProps { children?: React.ReactNode; @@ -85,222 +60,6 @@ const GreetingMessage = memo(() => { ); }); -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}`; -}; - -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 getDocumentIcon = (type: string, className: string) => { - const iconProps = { - className, - style: { color: colors.text.muted }, - }; - - switch (type) { - case "google_doc": - return <GoogleDocs {...iconProps} />; - case "google_sheet": - return <GoogleSheets {...iconProps} />; - case "google_slide": - return <GoogleSlides {...iconProps} />; - case "google_drive": - return <GoogleDrive {...iconProps} />; - case "notion": - case "notion_doc": - return <NotionDoc {...iconProps} />; - case "word": - case "microsoft_word": - return <MicrosoftWord {...iconProps} />; - case "excel": - case "microsoft_excel": - return <MicrosoftExcel {...iconProps} />; - case "powerpoint": - case "microsoft_powerpoint": - return <MicrosoftPowerpoint {...iconProps} />; - case "onenote": - case "microsoft_onenote": - return <MicrosoftOneNote {...iconProps} />; - case "onedrive": - return <OneDrive {...iconProps} />; - case "pdf": - return <PDF {...iconProps} />; - default: - return <FileText {...iconProps} />; - } -}; - -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; -}; - -const MemoryDetailItem = memo(({ memory }: { memory: MemoryEntry }) => { - return ( - <button - className="p-4 rounded-lg border transition-all relative overflow-hidden cursor-pointer" - style={{ - backgroundColor: memory.isLatest - ? colors.memory.primary - : "rgba(255, 255, 255, 0.02)", - borderColor: memory.isLatest - ? colors.memory.border - : "rgba(255, 255, 255, 0.1)", - backdropFilter: "blur(8px)", - WebkitBackdropFilter: "blur(8px)", - }} - 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 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 border-blue-400/30 backdrop-blur-sm" - style={{ - backgroundColor: colors.memory.secondary, - color: colors.accent.primary, - backdropFilter: "blur(4px)", - WebkitBackdropFilter: "blur(4px)", - }} - variant="default" - > - Latest - </Badge> - )} - {memory.forgetAfter && ( - <Badge - className="text-xs backdrop-blur-sm" - style={{ - borderColor: colors.status.expiring, - 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 - 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> - </div> - </button> - ); -}); - const DocumentCard = memo( ({ document, @@ -361,12 +120,12 @@ const DocumentCard = memo( </div> </CardHeader> <CardContent className="relative z-10 px-0"> - {document.summary && ( + {document.content && ( <p className="text-xs line-clamp-2 mb-3" style={{ color: colors.text.muted }} > - {document.summary} + {document.content} </p> )} <div className="flex items-center gap-2 flex-wrap"> @@ -402,248 +161,6 @@ const DocumentCard = memo( }, ); -const DocumentDetailSheet = memo( - ({ - document, - isOpen, - onClose, - isMobile, - }: { - document: DocumentWithMemories | null; - isOpen: boolean; - onClose: () => void; - isMobile: boolean; - }) => { - if (!document) return null; - - const [isSummaryExpanded, setIsSummaryExpanded] = useState(false); - const activeMemories = document.memoryEntries.filter((m) => !m.isForgotten); - const forgottenMemories = document.memoryEntries.filter( - (m) => m.isForgotten, - ); - - const HeaderContent = ({ - TitleComponent, - }: { - TitleComponent: typeof SheetTitle | typeof DrawerTitle; - }) => ( - <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.document.secondary, - border: `1px solid ${colors.document.border}`, - }} - > - {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 SummarySection = () => { - if (!document.summary) return null; - - const shouldShowToggle = document.summary.length > 200; // Show toggle for longer summaries - - return ( - <div - className="mt-4 p-3 rounded-lg" - style={{ - backgroundColor: "rgba(255, 255, 255, 0.03)", - border: "1px solid rgba(255, 255, 255, 0.08)", - }} - > - <p - className={`text-sm ${!isSummaryExpanded ? "line-clamp-3" : ""}`} - style={{ color: colors.text.muted }} - > - {document.summary} - </p> - {shouldShowToggle && ( - <button - onClick={() => setIsSummaryExpanded(!isSummaryExpanded)} - className="mt-2 text-xs hover:underline transition-all" - style={{ color: colors.accent.primary }} - type="button" - > - {isSummaryExpanded ? "Show less" : "Show more"} - </button> - )} - </div> - ); - }; - - const MemoryContent = () => ( - <div className="p-6 space-y-6"> - {activeMemories.length > 0 && ( - <div> - <div - className="text-sm font-medium mb-4 flex items-start gap-2 px-3 py-2 rounded-lg" - style={{ - color: colors.text.secondary, - backgroundColor: colors.memory.primary, - border: `1px solid ${colors.memory.border}`, - }} - > - <Brain className="w-4 h-4 text-blue-400" /> - Active Memories ({activeMemories.length}) - </div> - <div className="space-y-3"> - {activeMemories.map((memory, index) => ( - <div - className="animate-in fade-in slide-in-from-right-2" - key={memory.id} - style={{ animationDelay: `${index * 50}ms` }} - > - <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)", - border: "1px solid rgba(255, 255, 255, 0.08)", - }} - > - 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)", - border: "1px solid rgba(255, 255, 255, 0.08)", - }} - > - <Brain - className="w-12 h-12 mx-auto mb-4 opacity-30" - style={{ color: colors.text.muted }} - /> - <p style={{ color: colors.text.muted }}> - No memories found for this document - </p> - </div> - )} - </div> - ); - - if (isMobile) { - return ( - <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> - - <SummarySection /> - </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, - borderLeft: `1px solid ${colors.document.border}`, - backdropFilter: "blur(20px)", - WebkitBackdropFilter: "blur(20px)", - }} - > - {/* Header section with glass effect */} - <div - className="p-6 relative" - style={{ - backgroundColor: "rgba(255, 255, 255, 0.02)", - borderBottom: `1px solid ${colors.document.border}`, - }} - > - <SheetHeader className="pb-0"> - <HeaderContent TitleComponent={SheetTitle} /> - </SheetHeader> - - <SummarySection /> - </div> - - <div className="h-[calc(100vh-200px)] memory-sheet-scroll overflow-y-auto"> - <MemoryContent /> - </div> - </SheetContent> - </Sheet> - ); - }, -); - export const MemoryListView = ({ children, documents, @@ -831,7 +348,7 @@ export const MemoryListView = ({ )} </div> - <DocumentDetailSheet + <MemoryDetail document={selectedDocument} isOpen={isDetailOpen} onClose={handleCloseDetails} diff --git a/apps/web/lib/document-icon.tsx b/apps/web/lib/document-icon.tsx new file mode 100644 index 00000000..3a80b2e0 --- /dev/null +++ b/apps/web/lib/document-icon.tsx @@ -0,0 +1,54 @@ +import { colors } from '@repo/ui/memory-graph/constants'; +import { + GoogleDocs, + MicrosoftWord, + NotionDoc, + GoogleDrive, + GoogleSheets, + GoogleSlides, + PDF, + OneDrive, + MicrosoftOneNote, + MicrosoftPowerpoint, + MicrosoftExcel, +} from '@ui/assets/icons'; +import { FileText } from 'lucide-react'; + +export const getDocumentIcon = (type: string, className: string) => { + const iconProps = { + className, + style: { color: colors.text.muted }, + }; + + switch (type) { + case 'google_doc': + return <GoogleDocs {...iconProps} />; + case 'google_sheet': + return <GoogleSheets {...iconProps} />; + case 'google_slide': + return <GoogleSlides {...iconProps} />; + case 'google_drive': + return <GoogleDrive {...iconProps} />; + case 'notion': + case 'notion_doc': + return <NotionDoc {...iconProps} />; + case 'word': + case 'microsoft_word': + return <MicrosoftWord {...iconProps} />; + case 'excel': + case 'microsoft_excel': + return <MicrosoftExcel {...iconProps} />; + case 'powerpoint': + case 'microsoft_powerpoint': + return <MicrosoftPowerpoint {...iconProps} />; + case 'onenote': + case 'microsoft_onenote': + return <MicrosoftOneNote {...iconProps} />; + case 'onedrive': + return <OneDrive {...iconProps} />; + case 'pdf': + return <PDF {...iconProps} />; + default: + return <FileText {...iconProps} />; + } +}; diff --git a/packages/ui/components/sheet.tsx b/packages/ui/components/sheet.tsx index 242a4688..fc49af38 100644 --- a/packages/ui/components/sheet.tsx +++ b/packages/ui/components/sheet.tsx @@ -83,7 +83,7 @@ function SheetContent({ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div - className={cn("flex flex-col gap-1.5 p-4", className)} + className={cn("flex flex-col gap-1.5 py-4", className)} data-slot="sheet-header" {...props} /> |