From 8c195cece613356fccec198cddc550212a58aa00 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:17:44 +0000 Subject: feat: full screen note, space highlights, suggestions (#696) Added quick note and highlights features to the new UI, improved OG scraping, and implemented Nova alpha access feature flag. --- apps/web/app/(navigation)/layout.tsx | 12 + apps/web/app/api/og/route.ts | 61 ++++++ apps/web/app/new/page.tsx | 128 ++++++++++- apps/web/components/new/chat/index.tsx | 50 ++++- .../components/new/chat/message/agent-message.tsx | 2 +- .../components/new/document-cards/file-preview.tsx | 6 +- .../new/document-cards/google-docs-preview.tsx | 6 +- .../new/document-cards/tweet-preview.tsx | 145 ++++++++++-- apps/web/components/new/document-icon.tsx | 8 +- .../new/document-modal/content/google-doc.tsx | 5 +- .../new/document-modal/content/index.tsx | 9 +- .../new/document-modal/graph-list-memories.tsx | 4 +- .../components/new/documents-command-palette.tsx | 6 +- apps/web/components/new/fullscreen-note-modal.tsx | 223 +++++++++++++++++++ apps/web/components/new/highlights-card.tsx | 244 +++++++++++++++++++++ apps/web/components/new/memories-grid.tsx | 230 ++++++++++++++++--- apps/web/components/new/quick-note-card.tsx | 159 ++++++++++++++ apps/web/components/new/text-editor/index.tsx | 5 +- apps/web/components/superloader.tsx | 11 +- apps/web/globals.css | 12 +- apps/web/lib/analytics.ts | 5 +- apps/web/stores/quick-note-draft.ts | 54 +++++ 22 files changed, 1285 insertions(+), 100 deletions(-) create mode 100644 apps/web/components/new/fullscreen-note-modal.tsx create mode 100644 apps/web/components/new/highlights-card.tsx create mode 100644 apps/web/components/new/quick-note-card.tsx create mode 100644 apps/web/stores/quick-note-draft.ts diff --git a/apps/web/app/(navigation)/layout.tsx b/apps/web/app/(navigation)/layout.tsx index 7b7628bb..68a67a93 100644 --- a/apps/web/app/(navigation)/layout.tsx +++ b/apps/web/app/(navigation)/layout.tsx @@ -3,6 +3,8 @@ import { GraphDialog } from "@/components/graph-dialog" import { Header } from "@/components/header" import { AddMemoryView } from "@/components/views/add-memory" +import { usePathname, useRouter } from "next/navigation" +import { useFeatureFlagEnabled } from "posthog-js/react" import { useEffect, useState } from "react" export default function NavigationLayout({ @@ -11,6 +13,16 @@ export default function NavigationLayout({ children: React.ReactNode }) { const [showAddMemoryView, setShowAddMemoryView] = useState(false) + const pathname = usePathname() + const router = useRouter() + const flagEnabled = useFeatureFlagEnabled("nova-alpha-access") + + useEffect(() => { + if (flagEnabled && !pathname.includes("/new")) { + router.replace("/new") + } + }, [flagEnabled, router, pathname]) + useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { const target = event.target as HTMLElement diff --git a/apps/web/app/api/og/route.ts b/apps/web/app/api/og/route.ts index 5ca6e44c..ed8a98ff 100644 --- a/apps/web/app/api/og/route.ts +++ b/apps/web/app/api/og/route.ts @@ -41,6 +41,54 @@ function isPrivateHost(hostname: string): boolean { return privateIpPatterns.some((pattern) => pattern.test(hostname)) } +// File extensions that are not HTML and can't be scraped for OG data +const NON_HTML_EXTENSIONS = [ + ".pdf", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".ppt", + ".pptx", + ".zip", + ".rar", + ".7z", + ".tar", + ".gz", + ".mp3", + ".mp4", + ".avi", + ".mov", + ".wmv", + ".flv", + ".webm", + ".wav", + ".ogg", + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp", + ".svg", + ".ico", + ".bmp", + ".tiff", + ".exe", + ".dmg", + ".iso", + ".bin", +] + +function isNonHtmlUrl(url: string): boolean { + try { + const urlObj = new URL(url) + const pathname = urlObj.pathname.toLowerCase() + return NON_HTML_EXTENSIONS.some((ext) => pathname.endsWith(ext)) + } catch { + return false + } +} + function extractImageUrl(image: unknown): string | undefined { if (!image) return undefined @@ -110,6 +158,19 @@ export async function GET(request: Request) { ) } + // Skip OG scraping for non-HTML files (PDFs, images, etc.) + if (isNonHtmlUrl(trimmedUrl)) { + return Response.json( + { title: "", description: "" }, + { + headers: { + "Cache-Control": + "public, s-maxage=3600, stale-while-revalidate=86400", + }, + }, + ) + } + const { result, error } = await ogs({ url: trimmedUrl, timeout: 8000, diff --git a/apps/web/app/new/page.tsx b/apps/web/app/new/page.tsx index afb077b3..31fab182 100644 --- a/apps/web/app/new/page.tsx +++ b/apps/web/app/new/page.tsx @@ -9,12 +9,17 @@ import { AddDocumentModal } from "@/components/new/add-document" import { MCPModal } from "@/components/new/mcp-modal" import { DocumentModal } from "@/components/new/document-modal" import { DocumentsCommandPalette } from "@/components/new/documents-command-palette" +import { FullscreenNoteModal } from "@/components/new/fullscreen-note-modal" +import type { HighlightItem } from "@/components/new/highlights-card" import { HotkeysProvider } from "react-hotkeys-hook" import { useHotkeys } from "react-hotkeys-hook" import { AnimatePresence } from "motion/react" import { useIsMobile } from "@hooks/use-mobile" import { useProject } from "@/stores" +import { useQuickNoteDraftReset } from "@/stores/quick-note-draft" import { analytics } from "@/lib/analytics" +import { useDocumentMutations } from "@/hooks/use-document-mutations" +import { useQuery } from "@tanstack/react-query" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import type { z } from "zod" @@ -31,6 +36,56 @@ export default function NewPage() { useState(null) const [isDocumentModalOpen, setIsDocumentModalOpen] = useState(false) + const [isFullScreenNoteOpen, setIsFullScreenNoteOpen] = useState(false) + const [fullscreenInitialContent, setFullscreenInitialContent] = useState("") + const [queuedChatSeed, setQueuedChatSeed] = useState(null) + const [searchPrefill, setSearchPrefill] = useState("") + + const resetDraft = useQuickNoteDraftReset(selectedProject) + + const { noteMutation } = useDocumentMutations({ + onClose: () => { + resetDraft() + setIsFullScreenNoteOpen(false) + }, + }) + + // Fetch space highlights (highlights + suggested questions) + type SpaceHighlightsResponse = { + highlights: HighlightItem[] + questions: string[] + generatedAt: string + } + const { data: highlightsData, isLoading: isLoadingHighlights } = + useQuery({ + queryKey: ["space-highlights", selectedProject], + queryFn: async (): Promise => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + spaceId: selectedProject || "sm_project_default", + highlightsCount: 3, + questionsCount: 4, + includeHighlights: true, + includeQuestions: true, + }), + }, + ) + + if (!response.ok) { + throw new Error("Failed to fetch space highlights") + } + + return response.json() + }, + staleTime: 4 * 60 * 60 * 1000, // 4 hours (matches backend cache) + refetchOnWindowFocus: false, + }) + useHotkeys("c", () => { analytics.addDocumentModalOpened() setIsAddDocumentOpen(true) @@ -46,6 +101,42 @@ export default function NewPage() { setIsDocumentModalOpen(true) }, []) + const handleQuickNoteSave = useCallback( + (content: string) => { + if (content.trim()) { + noteMutation.mutate({ content, project: selectedProject }) + } + }, + [selectedProject, noteMutation], + ) + + const handleFullScreenSave = useCallback( + (content: string) => { + if (content.trim()) { + noteMutation.mutate({ content, project: selectedProject }) + } + }, + [selectedProject, noteMutation], + ) + + const handleMaximize = useCallback( + (content: string) => { + setFullscreenInitialContent(content) + setIsFullScreenNoteOpen(true) + }, + [], + ) + + const handleHighlightsChat = useCallback((seed: string) => { + setQueuedChatSeed(seed) + setIsChatOpen(true) + }, []) + + const handleHighlightsShowRelated = useCallback((query: string) => { + setSearchPrefill(query) + setIsSearchOpen(true) + }, []) + return (
@@ -69,10 +160,21 @@ export default function NewPage() { key={`main-container-${isChatOpen}`} className="z-10 flex flex-col md:flex-row relative" > -
+
@@ -80,13 +182,22 @@ export default function NewPage() { setQueuedChatSeed(null)} + emptyStateSuggestions={highlightsData?.questions} />
{isMobile && ( - + setQueuedChatSeed(null)} + emptyStateSuggestions={highlightsData?.questions} + /> )} { + setIsSearchOpen(open) + if (!open) setSearchPrefill("") + }} projectId={selectedProject} onOpenDocument={handleOpenDocument} + initialSearch={searchPrefill} /> setIsDocumentModalOpen(false)} /> + setIsFullScreenNoteOpen(false)} + initialContent={fullscreenInitialContent} + onSave={handleFullScreenSave} + isSaving={noteMutation.isPending} + />
) diff --git a/apps/web/components/new/chat/index.tsx b/apps/web/components/new/chat/index.tsx index 32fe116e..e09cf78f 100644 --- a/apps/web/components/new/chat/index.tsx +++ b/apps/web/components/new/chat/index.tsx @@ -28,18 +28,20 @@ import { ChainOfThought } from "./input/chain-of-thought" import { useIsMobile } from "@hooks/use-mobile" import { analytics } from "@/lib/analytics" +const DEFAULT_SUGGESTIONS = [ + "Show me all content related to Supermemory.", + "Summarize the key ideas from My Gita.", + "Which memories connect design and AI?", + "What are the main themes across my memories?", +] + function ChatEmptyStatePlaceholder({ onSuggestionClick, + suggestions = DEFAULT_SUGGESTIONS, }: { onSuggestionClick: (suggestion: string) => void + suggestions?: string[] }) { - const suggestions = [ - "Show me all content related to Supermemory.", - "Summarize the key ideas from My Gita.", - "Which memories connect design and AI?", - "What are the main themes across my memories?", - ] - return (
onSuggestionClick(suggestion)} > - - {suggestion} + + + {suggestion} + ))}
@@ -77,9 +81,15 @@ function ChatEmptyStatePlaceholder({ export function ChatSidebar({ isChatOpen, setIsChatOpen, + queuedMessage, + onConsumeQueuedMessage, + emptyStateSuggestions, }: { isChatOpen: boolean setIsChatOpen: (open: boolean) => void + queuedMessage?: string | null + onConsumeQueuedMessage?: () => void + emptyStateSuggestions?: string[] }) { const isMobile = useIsMobile() const [input, setInput] = useState("") @@ -332,6 +342,19 @@ export function ChatSidebar({ return () => window.removeEventListener("keydown", handleKeyDown) }, [isChatOpen, handleNewChat]) + // Send queued message when chat opens + useEffect(() => { + if ( + isChatOpen && + queuedMessage && + status !== "submitted" && + status !== "streaming" + ) { + sendMessage({ text: queuedMessage }) + onConsumeQueuedMessage?.() + } + }, [isChatOpen, queuedMessage, status, sendMessage, onConsumeQueuedMessage]) + // Scroll to bottom when a new user message is added useEffect(() => { const lastMessage = messages[messages.length - 1] @@ -384,7 +407,7 @@ export function ChatSidebar({ "flex items-start justify-start", isMobile ? "fixed bottom-5 right-0 left-0 z-50 justify-center items-center" - : "absolute top-0 right-0 m-4", + : "absolute top-[-10px] right-0 m-4", dmSansClassName(), )} layoutId="chat-toggle-button" @@ -406,7 +429,9 @@ export function ChatSidebar({ whileTap={{ scale: 0.98 }} > - Chat with Nova + + Chat with Nova + ) : ( @@ -524,6 +549,7 @@ export function ChatSidebar({ onSuggestionClick={(suggestion) => { sendMessage({ text: suggestion }) }} + suggestions={emptyStateSuggestions} /> )}
Searching memories...
diff --git a/apps/web/components/new/document-cards/file-preview.tsx b/apps/web/components/new/document-cards/file-preview.tsx index f30645dc..44c2476b 100644 --- a/apps/web/components/new/document-cards/file-preview.tsx +++ b/apps/web/components/new/document-cards/file-preview.tsx @@ -86,7 +86,11 @@ export function FilePreview({ document }: { document: DocumentWithMemories }) { ) : (
- +

- +

{label}

diff --git a/apps/web/components/new/document-cards/tweet-preview.tsx b/apps/web/components/new/document-cards/tweet-preview.tsx index 291bc18a..2d9baacf 100644 --- a/apps/web/components/new/document-cards/tweet-preview.tsx +++ b/apps/web/components/new/document-cards/tweet-preview.tsx @@ -2,16 +2,119 @@ import { Suspense } from "react" import type { Tweet } from "react-tweet/api" -import { - TweetContainer, - TweetHeader, - TweetBody, - TweetMedia, - enrichTweet, - TweetSkeleton, -} from "react-tweet" +import { TweetBody, enrichTweet, TweetSkeleton } from "react-tweet" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" +import { PlayCircle } from "lucide-react" + +function VerifiedBadge({ className }: { className?: string }) { + return ( + + Verified + + + + + ) +} + +function XLogo({ className }: { className?: string }) { + return ( + + ) +} + +function CustomTweetHeader({ + tweet, +}: { + tweet: ReturnType +}) { + const user = tweet.user + const isVerified = user.verified || user.is_blue_verified + + return ( +
+
+
+ {user.name} +
+
+
+

+ {user.name} +

+ {isVerified && } +
+

+ @{user.screen_name} +

+
+
+
+ +
+
+ ) +} + +function CustomTweetMedia({ + tweet, +}: { + tweet: ReturnType +}) { + const media = tweet.mediaDetails?.[0] + if (!media) return null + + const isVideo = media.type === "video" || media.type === "animated_gif" + const previewUrl = media.media_url_https + + return ( +
+
+ Tweet media + {isVideo && ( +
+ +
+ )} +
+
+ ) +} export function TweetPreview({ data, @@ -26,25 +129,19 @@ export function TweetPreview({ return (
}> - - - - {tweet.mediaDetails?.length ? ( - - ) : null} - +
+ +
+ +
+ +
) diff --git a/apps/web/components/new/document-icon.tsx b/apps/web/components/new/document-icon.tsx index a2a502e1..4861e978 100644 --- a/apps/web/components/new/document-icon.tsx +++ b/apps/web/components/new/document-icon.tsx @@ -53,13 +53,7 @@ function getFaviconUrl(url: string): string { } } -function FaviconIcon({ - url, - className, -}: { - url: string - className?: string -}) { +function FaviconIcon({ url, className }: { url: string; className?: string }) { const [hasError, setHasError] = useState(false) const faviconUrl = getFaviconUrl(url) diff --git a/apps/web/components/new/document-modal/content/google-doc.tsx b/apps/web/components/new/document-modal/content/google-doc.tsx index 562bac12..78dc4359 100644 --- a/apps/web/components/new/document-modal/content/google-doc.tsx +++ b/apps/web/components/new/document-modal/content/google-doc.tsx @@ -2,10 +2,7 @@ import { useState } from "react" import { Loader2 } from "lucide-react" -import { - extractGoogleDocId, - getGoogleEmbedUrl, -} from "@/lib/url-helpers" +import { extractGoogleDocId, getGoogleEmbedUrl } from "@/lib/url-helpers" interface GoogleDocViewerProps { url: string | null | undefined diff --git a/apps/web/components/new/document-modal/content/index.tsx b/apps/web/components/new/document-modal/content/index.tsx index c06bc550..39c5a2f0 100644 --- a/apps/web/components/new/document-modal/content/index.tsx +++ b/apps/web/components/new/document-modal/content/index.tsx @@ -56,10 +56,7 @@ function getContentType(document: DocumentWithMemories | null): ContentType { document.metadata?.mimeType?.toString().startsWith("image/") if (isImage && document.url) return "image" - if ( - document.type === "tweet" || - (document.url && isTwitterUrl(document.url)) - ) + if (document.type === "tweet" || (document.url && isTwitterUrl(document.url))) return "tweet" if (document.type === "text") return "text" if (document.type === "pdf") return "pdf" @@ -83,9 +80,7 @@ export function DocumentContent({ switch (contentType) { case "image": - return ( - - ) + return case "tweet": return ( diff --git a/apps/web/components/new/document-modal/graph-list-memories.tsx b/apps/web/components/new/document-modal/graph-list-memories.tsx index 49f918c2..0c2e418f 100644 --- a/apps/web/components/new/document-modal/graph-list-memories.tsx +++ b/apps/web/components/new/document-modal/graph-list-memories.tsx @@ -286,9 +286,7 @@ export function GraphListMemories({ type="button" className={cn( "text-xs text-[#525D6E] cursor-pointer transition-all text-left w-full", - expandedMemories.has(memory.id) - ? "" - : "line-clamp-2", + expandedMemories.has(memory.id) ? "" : "line-clamp-2", )} onClick={() => toggleMemory(memory.id)} > diff --git a/apps/web/components/new/documents-command-palette.tsx b/apps/web/components/new/documents-command-palette.tsx index 48cafc74..c21b4cf3 100644 --- a/apps/web/components/new/documents-command-palette.tsx +++ b/apps/web/components/new/documents-command-palette.tsx @@ -19,6 +19,7 @@ interface DocumentsCommandPaletteProps { onOpenChange: (open: boolean) => void projectId: string onOpenDocument: (document: DocumentWithMemories) => void + initialSearch?: string } export function DocumentsCommandPalette({ @@ -26,6 +27,7 @@ export function DocumentsCommandPalette({ onOpenChange, projectId, onOpenDocument, + initialSearch = "", }: DocumentsCommandPaletteProps) { const isMobile = useIsMobile() const queryClient = useQueryClient() @@ -47,10 +49,10 @@ export function DocumentsCommandPalette({ setDocuments(queryData.pages.flatMap((page) => page.documents ?? [])) } setTimeout(() => inputRef.current?.focus(), 0) - setSearch("") + setSearch(initialSearch) setSelectedIndex(0) } - }, [open, queryClient, projectId]) + }, [open, queryClient, projectId, initialSearch]) const filteredDocuments = useMemo(() => { if (!search.trim()) return documents diff --git a/apps/web/components/new/fullscreen-note-modal.tsx b/apps/web/components/new/fullscreen-note-modal.tsx new file mode 100644 index 00000000..8ac90ee1 --- /dev/null +++ b/apps/web/components/new/fullscreen-note-modal.tsx @@ -0,0 +1,223 @@ +"use client" + +import { useState, useCallback, useEffect, useRef } from "react" +import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import { Logo } from "@ui/assets/Logo" +import { Minimize2, Plus, Loader2 } from "lucide-react" +import { useAuth } from "@lib/auth-context" +import { TextEditor } from "./text-editor" +import { useProject } from "@/stores" +import { useQuickNoteDraft } from "@/stores/quick-note-draft" + +interface FullscreenNoteModalProps { + isOpen: boolean + onClose: () => void + initialContent?: string + onSave: (content: string) => void + isSaving?: boolean +} + +export function FullscreenNoteModal({ + isOpen, + onClose, + initialContent = "", + onSave, + isSaving = false, +}: FullscreenNoteModalProps) { + const { user } = useAuth() + const { selectedProject } = useProject() + const { setDraft } = useQuickNoteDraft(selectedProject) + const [content, setContent] = useState(initialContent) + const contentRef = useRef(content) + + useEffect(() => { + contentRef.current = content + }, [content]) + + useEffect(() => { + if (isOpen) { + setContent(initialContent) + } + }, [isOpen, initialContent]) + + const displayName = + user?.displayUsername || + (typeof window !== "undefined" && localStorage.getItem("username")) || + (typeof window !== "undefined" && localStorage.getItem("userName")) || + "" + const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My" + + const handleSave = useCallback(() => { + const currentContent = contentRef.current + if (currentContent.trim() && !isSaving) { + onSave(currentContent) + } + }, [isSaving, onSave]) + + const handleContentChange = useCallback( + (newContent: string) => { + console.log("handleContentChange", newContent) + setContent(newContent) + setDraft(newContent) + }, + [setDraft], + ) + + const canSave = content.trim().length > 0 && !isSaving + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen) { + e.preventDefault() + onClose() + } + } + + if (isOpen) { + document.addEventListener("keydown", handleKeyDown) + } + + return () => { + document.removeEventListener("keydown", handleKeyDown) + } + }, [isOpen, onClose]) + + return ( + !open && onClose()}> + + New Note + +
+
+ + {userName && ( +
+

+ {userName} +

+

+ supermemory +

+
+ )} +
+ +
+ + + ESC + + + +
+
+ +
+
+
+ +
+
+
+ +
+ +
+
+
+ ) +} diff --git a/apps/web/components/new/highlights-card.tsx b/apps/web/components/new/highlights-card.tsx new file mode 100644 index 00000000..eb3e0b44 --- /dev/null +++ b/apps/web/components/new/highlights-card.tsx @@ -0,0 +1,244 @@ +"use client" + +import { useState, useCallback } from "react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import { + ChevronLeft, + ChevronRight, + Info, + Loader2, + MessageSquare, + Link2, +} from "lucide-react" +import { Logo } from "@ui/assets/Logo" + +export type HighlightFormat = "paragraph" | "bullets" | "quote" | "one_liner" + +export interface HighlightItem { + id: string + title: string + content: string + format: HighlightFormat + query: string + sourceDocumentIds: string[] +} + +interface HighlightsCardProps { + items: HighlightItem[] + onChat: (seed: string) => void + onShowRelated: (query: string) => void + isLoading?: boolean + width?: number +} + +function renderContent(content: string, format: HighlightFormat) { + switch (format) { + case "bullets": { + const lines = content + .split("\n") + .map((line) => line.replace(/^[-•*]\s*/, "").trim()) + .filter(Boolean) + return ( +
    + {lines.map((line, idx) => ( +
  • + {line} +
  • + ))} +
+ ) + } + case "quote": + return ( +

+ "{content}" +

+ ) + case "one_liner": + return

{content}

+ default: + return

{content}

+ } +} + +export function HighlightsCard({ + items, + onChat, + onShowRelated, + isLoading = false, + width = 216, +}: HighlightsCardProps) { + const [activeIndex, setActiveIndex] = useState(0) + + const currentItem = items[activeIndex] + + const handlePrev = useCallback(() => { + setActiveIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1)) + }, [items.length]) + + const handleNext = useCallback(() => { + setActiveIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0)) + }, [items.length]) + + const handleChat = useCallback(() => { + if (!currentItem) return + const seed = `Tell me more about "${currentItem.title}"` + onChat(seed) + }, [currentItem, onChat]) + + const handleShowRelated = useCallback(() => { + if (!currentItem) return + onShowRelated(currentItem.query || currentItem.title) + }, [currentItem, onShowRelated]) + + if (isLoading) { + return ( +
+ + + Loading highlights... + +
+ ) + } + + if (!currentItem || items.length === 0) { + return ( +
+
+
+ +
+ + powered by + + + supermemory + +
+
+
+
+

+ Add some documents to see highlights here +

+
+
+ ) + } + + return ( +
+
+
+ +
+ + powered by + + + supermemory + +
+
+ +
+ +
+

+ {currentItem.title} +

+
+ {renderContent(currentItem.content, currentItem.format)} +
+
+ +
+
+ + +
+ + {items.length > 1 && ( +
+ +
+ {items.map((_, idx) => ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/web/components/new/memories-grid.tsx b/apps/web/components/new/memories-grid.tsx index a28d7934..e9dac883 100644 --- a/apps/web/components/new/memories-grid.tsx +++ b/apps/web/components/new/memories-grid.tsx @@ -3,14 +3,13 @@ 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 { useInfiniteQuery, useQuery } from "@tanstack/react-query" import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react" import type { z } from "zod" import { Masonry, useInfiniteLoader } from "masonic" import { dmSansClassName } from "@/lib/fonts" import { SuperLoader } from "@/components/superloader" import { cn } from "@lib/utils" -import { Button } from "@ui/components/button" import { useProject } from "@/stores" import { useIsMobile } from "@hooks/use-mobile" import type { Tweet } from "react-tweet/api" @@ -24,6 +23,31 @@ import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils" import { SyncLogoIcon } from "@ui/assets/icons" import { McpPreview } from "./document-cards/mcp-preview" import { getFaviconUrl } from "@/lib/url-helpers" +import { QuickNoteCard } from "./quick-note-card" +import { HighlightsCard, type HighlightItem } from "./highlights-card" +import { Button } from "@ui/components/button" + +// Document category type +type DocumentCategory = + | "webpage" + | "tweet" + | "google_drive" + | "notion" + | "onedrive" + | "files" + | "notes" + | "mcp" + +type DocumentFacet = { + category: DocumentCategory + count: number + label: string +} + +type FacetsResponse = { + facets: DocumentFacet[] + total: number +} type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -37,18 +61,65 @@ const IS_DEV = process.env.NODE_ENV === "development" const PAGE_SIZE = IS_DEV ? 100 : 100 const MAX_TOTAL = 1000 +// Discriminated union for masonry items +type MasonryItem = + | { type: "quick-note"; id: string } + | { type: "highlights-card"; id: string } + | { type: "highlights-card-spacer"; id: string } + | { type: "document"; id: string; data: DocumentWithMemories } + +interface QuickNoteProps { + onSave: (content: string) => void + onMaximize: (content: string) => void + isSaving: boolean +} + +interface HighlightsProps { + items: HighlightItem[] + onChat: (seed: string) => void + onShowRelated: (query: string) => void + isLoading: boolean +} + interface MemoriesGridProps { isChatOpen: boolean onOpenDocument: (document: DocumentWithMemories) => void + quickNoteProps?: QuickNoteProps + highlightsProps?: HighlightsProps } export function MemoriesGrid({ isChatOpen, onOpenDocument, + quickNoteProps, + highlightsProps, }: MemoriesGridProps) { const { user } = useAuth() const { selectedProject } = useProject() const isMobile = useIsMobile() + const [selectedCategories, setSelectedCategories] = useState< + DocumentCategory[] + >([]) + + const { data: facetsData } = useQuery({ + queryKey: ["document-facets", selectedProject], + queryFn: async (): Promise => { + const response = await $fetch("@post/documents/documents/facets", { + body: { + containerTags: selectedProject ? [selectedProject] : undefined, + }, + disableValidation: true, + }) + + if (response.error) { + throw new Error(response.error?.message || "Failed to fetch facets") + } + + return response.data as FacetsResponse + }, + staleTime: 5 * 60 * 1000, + enabled: !!user, + }) const { data, @@ -58,7 +129,7 @@ export function MemoriesGrid({ hasNextPage, fetchNextPage, } = useInfiniteQuery({ - queryKey: ["documents-with-memories", selectedProject], + queryKey: ["documents-with-memories", selectedProject, selectedCategories], initialPageParam: 1, queryFn: async ({ pageParam }) => { const response = await $fetch("@post/documents/documents", { @@ -68,6 +139,8 @@ export function MemoriesGrid({ sort: "createdAt", order: "desc", containerTags: selectedProject ? [selectedProject] : undefined, + categories: + selectedCategories.length > 0 ? selectedCategories : undefined, }, disableValidation: true, }) @@ -95,12 +168,58 @@ export function MemoriesGrid({ enabled: !!user, }) + const handleCategoryToggle = useCallback((category: DocumentCategory) => { + setSelectedCategories((prev) => { + if (prev.includes(category)) { + return prev.filter((c) => c !== category) + } + return [...prev, category] + }) + }, []) + + const handleSelectAll = useCallback(() => { + setSelectedCategories([]) + }, []) + const documents = useMemo(() => { return ( data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? [] ) }, [data]) + const hasQuickNote = !!quickNoteProps + const hasHighlights = !!highlightsProps + + const masonryItems: MasonryItem[] = useMemo(() => { + const items: MasonryItem[] = [] + + if (!isMobile) { + if (hasQuickNote) { + items.push({ type: "quick-note", id: "quick-note" }) + } + if (hasHighlights) { + items.push({ type: "highlights-card", id: "highlights-card" }) + // Add spacer to occupy the second column space for the 2-column highlights card + items.push({ + type: "highlights-card-spacer", + id: "highlights-card-spacer", + }) + } + } + + for (const doc of documents) { + items.push({ type: "document", id: doc.id, data: doc }) + } + + return items + }, [documents, isMobile, hasQuickNote, hasHighlights]) + + // Stable key for Masonry based on document IDs, not item values + const masonryKey = useMemo(() => { + const docIds = documents.map((d) => d.id).join(",") + return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}-${hasHighlights}` + }, [documents, isChatOpen, hasQuickNote, hasHighlights]) + const isLoadingMore = isFetchingNextPage const loadMoreDocuments = useCallback(async (): Promise => { @@ -131,24 +250,61 @@ export function MemoriesGrid({ [onOpenDocument], ) - const renderDocumentCard = useCallback( + const renderMasonryItem = useCallback( ({ index, data, width, }: { index: number - data: DocumentWithMemories + data: MasonryItem width: number - }) => ( - - ), - [handleCardClick], + }) => { + if (data.type === "quick-note" && quickNoteProps) { + return ( +
+ +
+ ) + } + + if (data.type === "highlights-card" && highlightsProps) { + const doubleWidth = width * 2 + const cardWidth = doubleWidth - 16 + return ( +
+ +
+ ) + } + + if (data.type === "highlights-card-spacer") { + return ( +
+ ) + } + + if (data.type === "document") { + return ( + + ) + } + + return null + }, + [handleCardClick, quickNoteProps, highlightsProps], ) if (!user) { @@ -163,15 +319,37 @@ export function MemoriesGrid({ return (
- +
+ + {facetsData?.facets.map((facet: DocumentFacet) => ( + + ))} +
{error ? (
@@ -191,9 +369,9 @@ export function MemoriesGrid({ ) : (
d.id).join(",")}-${isChatOpen}`} - items={documents} - render={renderDocumentCard} + key={masonryKey} + items={masonryItems} + render={renderMasonryItem} columnGutter={0} rowGutter={0} columnWidth={216} diff --git a/apps/web/components/new/quick-note-card.tsx b/apps/web/components/new/quick-note-card.tsx new file mode 100644 index 00000000..224f77d0 --- /dev/null +++ b/apps/web/components/new/quick-note-card.tsx @@ -0,0 +1,159 @@ +"use client" + +import { useRef, useCallback } from "react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import { Maximize2, Plus, Loader2 } from "lucide-react" +import { useProject } from "@/stores" +import { useQuickNoteDraft } from "@/stores/quick-note-draft" + +interface QuickNoteCardProps { + onSave: (content: string) => void + onMaximize: (content: string) => void + isSaving?: boolean +} + +export function QuickNoteCard({ + onSave, + onMaximize, + isSaving = false, +}: QuickNoteCardProps) { + const textareaRef = useRef(null) + const { selectedProject } = useProject() + const { draft, setDraft } = useQuickNoteDraft(selectedProject) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + setDraft(e.target.value) + }, + [setDraft], + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault() + if (draft.trim() && !isSaving) { + onSave(draft) + } + } + }, + [draft, isSaving, onSave], + ) + + const handleSaveClick = useCallback(() => { + if (draft.trim() && !isSaving) { + onSave(draft) + } + }, [draft, isSaving, onSave]) + + const handleMaximizeClick = useCallback(() => { + onMaximize(draft) + }, [draft, onMaximize]) + + const canSave = draft.trim().length > 0 && !isSaving + + return ( +
+
+ + +