aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/(navigation)/layout.tsx12
-rw-r--r--apps/web/app/api/og/route.ts61
-rw-r--r--apps/web/app/new/page.tsx128
-rw-r--r--apps/web/components/new/chat/index.tsx50
-rw-r--r--apps/web/components/new/chat/message/agent-message.tsx2
-rw-r--r--apps/web/components/new/document-cards/file-preview.tsx6
-rw-r--r--apps/web/components/new/document-cards/google-docs-preview.tsx6
-rw-r--r--apps/web/components/new/document-cards/tweet-preview.tsx145
-rw-r--r--apps/web/components/new/document-icon.tsx8
-rw-r--r--apps/web/components/new/document-modal/content/google-doc.tsx5
-rw-r--r--apps/web/components/new/document-modal/content/index.tsx9
-rw-r--r--apps/web/components/new/document-modal/graph-list-memories.tsx4
-rw-r--r--apps/web/components/new/documents-command-palette.tsx6
-rw-r--r--apps/web/components/new/fullscreen-note-modal.tsx223
-rw-r--r--apps/web/components/new/highlights-card.tsx244
-rw-r--r--apps/web/components/new/memories-grid.tsx230
-rw-r--r--apps/web/components/new/quick-note-card.tsx159
-rw-r--r--apps/web/components/new/text-editor/index.tsx5
-rw-r--r--apps/web/components/superloader.tsx11
-rw-r--r--apps/web/globals.css12
-rw-r--r--apps/web/lib/analytics.ts5
-rw-r--r--apps/web/stores/quick-note-draft.ts54
22 files changed, 1285 insertions, 100 deletions
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<DocumentWithMemories | null>(null)
const [isDocumentModalOpen, setIsDocumentModalOpen] = useState(false)
+ const [isFullScreenNoteOpen, setIsFullScreenNoteOpen] = useState(false)
+ const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
+ const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(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<SpaceHighlightsResponse>({
+ queryKey: ["space-highlights", selectedProject],
+ queryFn: async (): Promise<SpaceHighlightsResponse> => {
+ 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 (
<HotkeysProvider>
<div className="bg-black min-h-screen">
@@ -69,10 +160,21 @@ export default function NewPage() {
key={`main-container-${isChatOpen}`}
className="z-10 flex flex-col md:flex-row relative"
>
- <div className="flex-1 p-4 md:p-6 md:pr-0">
+ <div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
<MemoriesGrid
isChatOpen={isChatOpen}
onOpenDocument={handleOpenDocument}
+ quickNoteProps={{
+ onSave: handleQuickNoteSave,
+ onMaximize: handleMaximize,
+ isSaving: noteMutation.isPending,
+ }}
+ highlightsProps={{
+ items: highlightsData?.highlights || [],
+ onChat: handleHighlightsChat,
+ onShowRelated: handleHighlightsShowRelated,
+ isLoading: isLoadingHighlights,
+ }}
/>
</div>
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
@@ -80,13 +182,22 @@ export default function NewPage() {
<ChatSidebar
isChatOpen={isChatOpen}
setIsChatOpen={setIsChatOpen}
+ queuedMessage={queuedChatSeed}
+ onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
+ emptyStateSuggestions={highlightsData?.questions}
/>
</AnimatePresence>
</div>
</main>
{isMobile && (
- <ChatSidebar isChatOpen={isChatOpen} setIsChatOpen={setIsChatOpen} />
+ <ChatSidebar
+ isChatOpen={isChatOpen}
+ setIsChatOpen={setIsChatOpen}
+ queuedMessage={queuedChatSeed}
+ onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
+ emptyStateSuggestions={highlightsData?.questions}
+ />
)}
<AddDocumentModal
@@ -99,15 +210,26 @@ export default function NewPage() {
/>
<DocumentsCommandPalette
open={isSearchOpen}
- onOpenChange={setIsSearchOpen}
+ onOpenChange={(open) => {
+ setIsSearchOpen(open)
+ if (!open) setSearchPrefill("")
+ }}
projectId={selectedProject}
onOpenDocument={handleOpenDocument}
+ initialSearch={searchPrefill}
/>
<DocumentModal
document={selectedDocument}
isOpen={isDocumentModalOpen}
onClose={() => setIsDocumentModalOpen(false)}
/>
+ <FullscreenNoteModal
+ isOpen={isFullScreenNoteOpen}
+ onClose={() => setIsFullScreenNoteOpen(false)}
+ initialContent={fullscreenInitialContent}
+ onSave={handleFullScreenSave}
+ isSaving={noteMutation.isPending}
+ />
</div>
</HotkeysProvider>
)
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 (
<div
id="chat-empty-state"
@@ -61,11 +63,13 @@ function ChatEmptyStatePlaceholder({
<Button
key={suggestion}
variant="default"
- className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
+ className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit max-w-[400px] py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
onClick={() => onSuggestionClick(suggestion)}
>
- <SearchIcon className="size-4 text-[#267BF1]" />
- <span className="text-[#267BF1] text-[12px]">{suggestion}</span>
+ <SearchIcon className="size-4 text-[#267BF1] shrink-0" />
+ <span className="text-[#267BF1] text-[12px] truncate">
+ {suggestion}
+ </span>
</Button>
))}
</div>
@@ -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 }}
>
<NovaOrb size={isMobile ? 26 : 24} className="blur-[0.6px]! z-10" />
- <span className={cn(isMobile && "font-medium")}>Chat with Nova</span>
+ <span className={cn(isMobile && "font-medium")}>
+ Chat with Nova
+ </span>
</motion.button>
</motion.div>
) : (
@@ -524,6 +549,7 @@ export function ChatSidebar({
onSuggestionClick={(suggestion) => {
sendMessage({ text: suggestion })
}}
+ suggestions={emptyStateSuggestions}
/>
)}
<div
diff --git a/apps/web/components/new/chat/message/agent-message.tsx b/apps/web/components/new/chat/message/agent-message.tsx
index bf9f0eb0..f4528ef5 100644
--- a/apps/web/components/new/chat/message/agent-message.tsx
+++ b/apps/web/components/new/chat/message/agent-message.tsx
@@ -76,7 +76,7 @@ export function AgentMessage({
return (
<div
key={`${message.id}-${partIndex}`}
- className="text-xs text-white/50 italic"
+ className="text-xs text-white italic"
>
Searching memories...
</div>
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 }) {
) : (
<div className="p-3">
<div className="flex items-center gap-1 mb-2">
- <DocumentIcon type={document.type} url={document.url} className="w-4 h-4" />
+ <DocumentIcon
+ type={document.type}
+ url={document.url}
+ className="w-4 h-4"
+ />
<p
className={cn(dmSansClassName(), "text-[10px] font-semibold")}
style={{ color: color }}
diff --git a/apps/web/components/new/document-cards/google-docs-preview.tsx b/apps/web/components/new/document-cards/google-docs-preview.tsx
index 50a56a16..06136e87 100644
--- a/apps/web/components/new/document-cards/google-docs-preview.tsx
+++ b/apps/web/components/new/document-cards/google-docs-preview.tsx
@@ -22,7 +22,11 @@ export function GoogleDocsPreview({
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] gap-3">
<div className="flex items-center gap-2 mb-2">
- <DocumentIcon type={document.type} url={document.url} className="w-4 h-4" />
+ <DocumentIcon
+ type={document.type}
+ url={document.url}
+ className="w-4 h-4"
+ />
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
{label}
</p>
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 (
+ <svg
+ viewBox="0 0 22 22"
+ aria-label="Verified account"
+ className={cn("size-3", className)}
+ >
+ <title>Verified</title>
+ <g>
+ <path
+ fill="#1D9BF0"
+ d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"
+ />
+ </g>
+ </svg>
+ )
+}
+
+function XLogo({ className }: { className?: string }) {
+ return (
+ <svg
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ className={cn("size-3 fill-white", className)}
+ >
+ <title>X</title>
+ <g>
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
+ </g>
+ </svg>
+ )
+}
+
+function CustomTweetHeader({
+ tweet,
+}: {
+ tweet: ReturnType<typeof enrichTweet>
+}) {
+ const user = tweet.user
+ const isVerified = user.verified || user.is_blue_verified
+
+ return (
+ <div className="flex items-start justify-between pr-0.5 w-full">
+ <div className="flex gap-2 items-center">
+ <div className="bg-white overflow-hidden rounded-full shrink-0 size-[30px]">
+ <img
+ src={user.profile_image_url_https}
+ alt={user.name}
+ className="size-full object-cover"
+ />
+ </div>
+ <div className="flex flex-col items-start">
+ <div className="flex gap-0.5 items-center">
+ <p
+ className={cn(
+ "font-semibold leading-tight overflow-hidden text-[#fafafa] text-[12px] truncate tracking-[-0.12px]",
+ dmSansClassName(),
+ )}
+ >
+ {user.name}
+ </p>
+ {isVerified && <VerifiedBadge />}
+ </div>
+ <p
+ className={cn(
+ "font-medium leading-tight overflow-hidden text-[#737373] text-[12px] truncate tracking-[-0.12px]",
+ dmSansClassName(),
+ )}
+ >
+ @{user.screen_name}
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-1.5 items-center">
+ <XLogo />
+ </div>
+ </div>
+ )
+}
+
+function CustomTweetMedia({
+ tweet,
+}: {
+ tweet: ReturnType<typeof enrichTweet>
+}) {
+ 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 (
+ <div className="relative w-full overflow-hidden rounded-[6px] border border-[rgba(47,50,54,0.2)]">
+ <div className="relative w-full aspect-video">
+ <img
+ src={previewUrl}
+ alt="Tweet media"
+ className="w-full h-full object-cover"
+ />
+ {isVideo && (
+ <div className="absolute inset-0 bg-[rgba(4,5,5,0.8)] flex items-center justify-center">
+ <PlayCircle className="size-8 text-white" strokeWidth={1.5} />
+ </div>
+ )}
+ </div>
+ </div>
+ )
+}
export function TweetPreview({
data,
@@ -26,25 +129,19 @@ export function TweetPreview({
return (
<div
className={cn(
- "p-3 sm-tweet-theme w-full min-w-0",
- noBgColor
- ? "bg-transparent rounded-none"
- : "bg-[#0B1017]! rounded-[18px]",
+ "w-full min-w-0",
+ noBgColor ? "bg-transparent" : "bg-black rounded-[18px] p-3",
+ dmSansClassName(),
)}
>
<Suspense fallback={<TweetSkeleton />}>
- <TweetContainer
- className={cn(
- "pb-0! my-0! bg-transparent! border-none! w-full! min-w-0!",
- dmSansClassName(),
- )}
- >
- <TweetHeader tweet={tweet} components={{}} />
- <TweetBody tweet={tweet} />
- {tweet.mediaDetails?.length ? (
- <TweetMedia tweet={tweet} components={{}} />
- ) : null}
- </TweetContainer>
+ <div className="flex flex-col gap-3 w-full">
+ <CustomTweetHeader tweet={tweet} />
+ <div className="sm-tweet-theme w-full min-w-0">
+ <TweetBody tweet={tweet} />
+ </div>
+ <CustomTweetMedia tweet={tweet} />
+ </div>
</Suspense>
</div>
)
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 (
- <ImagePreview url={document.url ?? ""} title={document.title} />
- )
+ return <ImagePreview url={document.url ?? ""} title={document.title} />
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 (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent
+ className={cn(
+ "border-none bg-[#0D121A] flex flex-col p-0 gap-0",
+ "w-screen! h-screen! max-w-none! max-h-none! rounded-none",
+ dmSansClassName(),
+ )}
+ showCloseButton={false}
+ >
+ <DialogTitle className="sr-only">New Note</DialogTitle>
+
+ <header className="flex justify-between items-center p-3 md:p-4">
+ <div className="flex items-center gap-2">
+ <Logo className="h-7" />
+ {userName && (
+ <div className="flex flex-col items-start justify-center ml-2">
+ <p className="text-[#8B8B8B] text-[11px] leading-tight">
+ {userName}
+ </p>
+ <p className="text-white font-bold text-xl leading-none -mt-1">
+ supermemory
+ </p>
+ </div>
+ )}
+ </div>
+
+ <div
+ id="fullscreen-close-controls"
+ className="bg-[#1B1F24] rounded-[8px] px-3 py-2.5 flex items-center gap-2.5"
+ style={{
+ boxShadow:
+ "0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
+ }}
+ >
+ <span
+ className={cn(
+ "bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center justify-center h-4",
+ )}
+ >
+ <span
+ className={cn(
+ dmSansClassName(),
+ "text-[10px] font-medium text-[#737373]",
+ )}
+ >
+ ESC
+ </span>
+ </span>
+ <button
+ type="button"
+ onClick={onClose}
+ className="text-[#fafafa] hover:text-white transition-colors cursor-pointer"
+ aria-label="Close full screen"
+ >
+ <Minimize2 className="size-6" />
+ </button>
+ </div>
+ </header>
+
+ <main className="flex-1 flex flex-col items-center px-4 md:px-[288px] pt-8 md:pt-12 pb-24 overflow-auto">
+ <div className="w-full max-w-[864px] space-y-4">
+ <div className="min-h-[400px] flex-1">
+ <TextEditor
+ content={initialContent || undefined}
+ onContentChange={handleContentChange}
+ onSubmit={handleSave}
+ />
+ </div>
+ </div>
+ </main>
+
+ <div
+ id="fullscreen-save-bar"
+ className="fixed bottom-8 left-1/2 -translate-x-1/2"
+ >
+ <button
+ type="button"
+ onClick={handleSave}
+ disabled={!canSave}
+ className={cn(
+ "bg-[#1B1F24] rounded-[8px] px-4 py-2.5 flex items-center justify-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
+ )}
+ style={{
+ boxShadow:
+ "0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
+ }}
+ >
+ {isSaving ? (
+ <Loader2 className="size-2 animate-spin text-[#fafafa]" />
+ ) : (
+ <Plus className="size-2 text-[#fafafa]" />
+ )}
+ <span
+ className={cn(
+ dmSansClassName(),
+ "text-[14px] font-medium text-[#fafafa]",
+ )}
+ >
+ {isSaving ? "Saving..." : "Save note"}
+ </span>
+
+ <span
+ className={cn(
+ "bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4 ml-1",
+ )}
+ >
+ <svg
+ className="size-[10px]"
+ viewBox="0 0 9 9"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Command Key</title>
+ <path
+ d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
+ stroke="#737373"
+ strokeWidth="0.833333"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ <span
+ className={cn(
+ dmSansClassName(),
+ "text-[10px] font-medium text-[#737373]",
+ )}
+ >
+ Enter
+ </span>
+ </span>
+ </button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
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 (
+ <ul className="list-disc pl-[18px] space-y-0">
+ {lines.map((line, idx) => (
+ <li key={idx} className="text-[12px] leading-normal">
+ {line}
+ </li>
+ ))}
+ </ul>
+ )
+ }
+ case "quote":
+ return (
+ <p className="text-[12px] leading-normal italic border-l-2 border-[#4BA0FA] pl-2">
+ "{content}"
+ </p>
+ )
+ case "one_liner":
+ return <p className="text-[12px] leading-normal font-medium">{content}</p>
+ default:
+ return <p className="text-[12px] leading-normal">{content}</p>
+ }
+}
+
+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 (
+ <div
+ className={cn(
+ "bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px] items-center justify-center",
+ dmSansClassName(),
+ )}
+ style={{ width }}
+ >
+ <Loader2 className="size-5 animate-spin text-[#4BA0FA]" />
+ <span className="text-[10px] text-[#737373]">
+ Loading highlights...
+ </span>
+ </div>
+ )
+ }
+
+ if (!currentItem || items.length === 0) {
+ return (
+ <div
+ className={cn(
+ "bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px]",
+ dmSansClassName(),
+ )}
+ style={{ width }}
+ >
+ <div className="flex items-start justify-between">
+ <div className="flex items-center gap-1">
+ <Logo className="size-[14px]" />
+ <div className="flex items-center gap-0.5">
+ <span className="text-[10px] text-[#4BA0FA] tracking-[-0.3px]">
+ powered by
+ </span>
+ <span className="text-[10px] text-[#4BA0FA] font-medium tracking-[-0.3px]">
+ supermemory
+ </span>
+ </div>
+ </div>
+ </div>
+ <div className="flex-1 flex items-center justify-center">
+ <p className="text-[11px] text-[#737373] text-center">
+ Add some documents to see highlights here
+ </p>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div
+ className={cn(
+ "bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3",
+ dmSansClassName(),
+ )}
+ style={{ width }}
+ >
+ <div id="highlights-header" className="flex items-start justify-between">
+ <div className="flex items-center gap-1">
+ <Logo className="size-[14px]" />
+ <div className="flex items-center gap-0.5">
+ <span className="text-[10px] text-[#4BA0FA] tracking-[-0.3px]">
+ powered by
+ </span>
+ <span className="text-[10px] text-[#4BA0FA] font-medium tracking-[-0.3px]">
+ supermemory
+ </span>
+ </div>
+ </div>
+ <Info className="size-[14px] text-[#737373]" />
+ </div>
+
+ <div id="highlights-body" className="flex flex-col gap-1.5">
+ <p className="text-[12px] font-semibold text-[#FAFAFA] leading-tight truncate">
+ {currentItem.title}
+ </p>
+ <div className="text-[12px] text-[#FAFAFA] leading-normal line-clamp-5">
+ {renderContent(currentItem.content, currentItem.format)}
+ </div>
+ </div>
+
+ <div className="flex items-center justify-between w-full gap-2">
+ <div id="highlights-actions" className="flex gap-2 items-center">
+ <button
+ type="button"
+ onClick={handleChat}
+ className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center gap-1.5 cursor-pointer relative"
+ style={{
+ boxShadow: "0 4px 20px 0 rgba(0, 0, 0, 0.25)",
+ }}
+ aria-label="Chat with Nova"
+ >
+ <MessageSquare className="size-3.5 text-[#FAFAFA]" />
+ <span className="text-[11px] text-[#FAFAFA]">Chat</span>
+ <div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_1px_0_rgba(255,255,255,0.1)]" />
+ </button>
+ <button
+ type="button"
+ onClick={handleShowRelated}
+ className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center gap-1.5 cursor-pointer relative"
+ style={{
+ boxShadow: "0 4px 20px 0 rgba(0, 0, 0, 0.25)",
+ }}
+ aria-label="Show related"
+ >
+ <Link2 className="size-3.5 text-[#FAFAFA]" />
+ <span className="text-[11px] text-[#FAFAFA]">Related</span>
+ <div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_1px_0_rgba(255,255,255,0.1)]" />
+ </button>
+ </div>
+
+ {items.length > 1 && (
+ <div id="highlights-pagination" className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={handlePrev}
+ className="text-[#737373] hover:text-white transition-colors cursor-pointer"
+ aria-label="Previous item"
+ >
+ <ChevronLeft className="size-4" />
+ </button>
+ <div className="flex items-center gap-1">
+ {items.map((_, idx) => (
+ <button
+ key={idx}
+ type="button"
+ onClick={() => setActiveIndex(idx)}
+ className={cn(
+ "rounded-full transition-all cursor-pointer",
+ idx === activeIndex
+ ? "w-4 h-1.5 bg-[#4BA0FA]"
+ : "size-1.5 bg-[#737373] hover:bg-[#999999]",
+ )}
+ aria-label={`Go to item ${idx + 1}`}
+ />
+ ))}
+ </div>
+ <button
+ type="button"
+ onClick={handleNext}
+ className="text-[#737373] hover:text-white transition-colors cursor-pointer"
+ aria-label="Next item"
+ >
+ <ChevronRight className="size-4" />
+ </button>
+ </div>
+ )}
+ </div>
+ </div>
+ )
+}
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<typeof DocumentsWithMemoriesResponseSchema>
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<FacetsResponse> => {
+ 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<DocumentsResponse, Error>({
- 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<void> => {
@@ -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
- }) => (
- <DocumentCard
- index={index}
- data={data}
- width={width}
- onClick={handleCardClick}
- />
- ),
- [handleCardClick],
+ }) => {
+ if (data.type === "quick-note" && quickNoteProps) {
+ return (
+ <div className="p-2" style={{ width }}>
+ <QuickNoteCard {...quickNoteProps} />
+ </div>
+ )
+ }
+
+ if (data.type === "highlights-card" && highlightsProps) {
+ const doubleWidth = width * 2
+ const cardWidth = doubleWidth - 16
+ return (
+ <div className="p-2" style={{ width: doubleWidth }}>
+ <HighlightsCard {...highlightsProps} width={cardWidth} />
+ </div>
+ )
+ }
+
+ if (data.type === "highlights-card-spacer") {
+ return (
+ <div
+ style={{
+ width,
+ height: 220, // Approximate height of HighlightsCard
+ visibility: "hidden",
+ pointerEvents: "none",
+ }}
+ />
+ )
+ }
+
+ if (data.type === "document") {
+ return (
+ <DocumentCard
+ index={index}
+ data={data.data}
+ width={width}
+ onClick={handleCardClick}
+ />
+ )
+ }
+
+ return null
+ },
+ [handleCardClick, quickNoteProps, highlightsProps],
)
if (!user) {
@@ -163,15 +319,37 @@ export function MemoriesGrid({
return (
<div className="relative">
- <Button
- className={cn(
- dmSansClassName(),
- "rounded-full border border-[#161F2C] bg-[#0D121A] px-4 py-2 data-[state=active]:bg-[#00173C] data-[state=active]:border-[#2261CA33] mb-4",
- )}
- data-state="active"
- >
- All
- </Button>
+ <div id="filter-pills" className="flex flex-wrap gap-1.5 mb-3">
+ <Button
+ className={cn(
+ dmSansClassName(),
+ "rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
+ selectedCategories.length === 0 &&
+ "bg-[#00173C] border-[#2261CA33]",
+ )}
+ onClick={handleSelectAll}
+ >
+ All
+ {facetsData?.total !== undefined && (
+ <span className="ml-1 text-[#737373]">({facetsData.total})</span>
+ )}
+ </Button>
+ {facetsData?.facets.map((facet: DocumentFacet) => (
+ <Button
+ key={facet.category}
+ className={cn(
+ dmSansClassName(),
+ "rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
+ selectedCategories.includes(facet.category) &&
+ "bg-[#00173C] border-[#2261CA33]",
+ )}
+ onClick={() => handleCategoryToggle(facet.category)}
+ >
+ {facet.label}
+ <span className="ml-1 text-[#737373]">({facet.count})</span>
+ </Button>
+ ))}
+ </div>
{error ? (
<div className="h-full flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
@@ -191,9 +369,9 @@ export function MemoriesGrid({
) : (
<div className="h-full overflow-auto scrollbar-thin">
<Masonry
- key={`masonry-${documents.length}-${documents.map((d) => 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<HTMLTextAreaElement>(null)
+ const { selectedProject } = useProject()
+ const { draft, setDraft } = useQuickNoteDraft(selectedProject)
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+ setDraft(e.target.value)
+ },
+ [setDraft],
+ )
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+ 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 (
+ <div
+ className="bg-[#1B1F24] rounded-[22px] p-1"
+ style={{
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+ }}
+ >
+ <div
+ id="quick-note-inner"
+ className="bg-[#0B1017] rounded-[18px] p-3 relative"
+ style={{
+ boxShadow: "inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
+ }}
+ >
+ <button
+ type="button"
+ onClick={handleMaximizeClick}
+ className="absolute top-3 right-3 text-[#737373] hover:text-white transition-colors cursor-pointer"
+ aria-label="Expand to full screen"
+ >
+ <Maximize2 className="size-[14px]" />
+ </button>
+
+ <textarea
+ ref={textareaRef}
+ value={draft}
+ onChange={handleChange}
+ onKeyDown={handleKeyDown}
+ placeholder="Start writing..."
+ disabled={isSaving}
+ className={cn(
+ dmSansClassName(),
+ "w-full h-[120px] bg-transparent resize-none outline-none text-[12px] leading-normal text-white placeholder:text-[#737373] pr-5 disabled:opacity-50",
+ )}
+ />
+
+ <div
+ id="quick-note-action-bar"
+ className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center justify-center gap-8 w-full"
+ style={{
+ boxShadow:
+ "0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
+ }}
+ >
+ <button
+ type="button"
+ onClick={handleSaveClick}
+ disabled={!canSave}
+ className={cn(
+ "flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
+ )}
+ >
+ <span className="flex items-center gap-1.5">
+ {isSaving ? (
+ <Loader2 className="size-2 animate-spin text-[#fafafa]" />
+ ) : (
+ <Plus className="size-2 text-[#fafafa]" />
+ )}
+ <span
+ className={cn(
+ dmSansClassName(),
+ "text-[10px] font-medium text-[#fafafa]",
+ )}
+ >
+ {isSaving ? "Saving..." : "Save note"}
+ </span>
+ </span>
+
+ <span
+ className={cn(
+ "bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4",
+ )}
+ >
+ <svg
+ className="size-[10px]"
+ viewBox="0 0 9 9"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Command Key</title>
+ <path
+ d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
+ stroke="#737373"
+ strokeWidth="0.833333"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ <span
+ className={cn(
+ dmSansClassName(),
+ "text-[10px] font-medium text-[#737373]",
+ )}
+ >
+ Enter
+ </span>
+ </span>
+ </button>
+ </div>
+ </div>
+ </div>
+ )
+}
diff --git a/apps/web/components/new/text-editor/index.tsx b/apps/web/components/new/text-editor/index.tsx
index d99a0aea..18de5893 100644
--- a/apps/web/components/new/text-editor/index.tsx
+++ b/apps/web/components/new/text-editor/index.tsx
@@ -54,6 +54,7 @@ export function TextEditor({
handleKeyDown: (_view, event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault()
+ debouncedUpdates.flush()
onSubmitRef.current?.()
return true
}
@@ -104,9 +105,11 @@ export function TextEditor({
useEffect(() => {
return () => {
+ // Flush any pending debounced updates before destroying editor
+ debouncedUpdates.flush()
editor?.destroy()
}
- }, [editor])
+ }, [editor, debouncedUpdates])
return (
<>
diff --git a/apps/web/components/superloader.tsx b/apps/web/components/superloader.tsx
index 1e51df68..e2f1478a 100644
--- a/apps/web/components/superloader.tsx
+++ b/apps/web/components/superloader.tsx
@@ -44,17 +44,16 @@ export function SuperLoader({
const animateVariant = prefersReducedMotion ? "static" : "visible"
return (
- <div
- role="status"
+ <output
aria-label={label}
- className={`inline-flex flex-col items-center gap-2 ${className}`}
+ className={`inline-flex flex-row items-center gap-2 ${className}`}
style={{ width: size + 10 }}
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 21 21"
- width={size}
- height={size}
+ width={size * 0.5}
+ height={size * 0.5}
className={`shrink-0 ${colorClassName}`}
>
<title>Loading...</title>
@@ -93,6 +92,6 @@ export function SuperLoader({
>
{label}
</span>
- </div>
+ </output>
)
}
diff --git a/apps/web/globals.css b/apps/web/globals.css
index c9e5da27..647a85de 100644
--- a/apps/web/globals.css
+++ b/apps/web/globals.css
@@ -46,7 +46,17 @@
.tweet-body-module__ZNRZja__root > * {
font-size: 10px !important;
- line-height: 0.25rem !important;
+ line-height: 1.35 !important;
+ letter-spacing: -0.1px !important;
+ white-space: pre-wrap !important;
+}
+
+.sm-tweet-theme .tweet-body-module__ZNRZja__root {
+ font-family: "DM Sans", sans-serif !important;
+ font-weight: 500 !important;
+ color: #fafafa !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
}
[class*="dmSans"],
diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts
index 9bc3b7f5..84eda62b 100644
--- a/apps/web/lib/analytics.ts
+++ b/apps/web/lib/analytics.ts
@@ -1,7 +1,10 @@
import posthog from "posthog-js"
// Helper function to safely capture events
-const safeCapture = (eventName: string, properties?: Record<string, unknown>) => {
+const safeCapture = (
+ eventName: string,
+ properties?: Record<string, unknown>,
+) => {
if (posthog.__loaded) {
posthog.capture(eventName, properties)
}
diff --git a/apps/web/stores/quick-note-draft.ts b/apps/web/stores/quick-note-draft.ts
new file mode 100644
index 00000000..afd2bc6d
--- /dev/null
+++ b/apps/web/stores/quick-note-draft.ts
@@ -0,0 +1,54 @@
+import { create } from "zustand"
+
+interface QuickNoteDraftState {
+ draftByProject: Record<string, string>
+ setDraft: (projectId: string, draft: string) => void
+ resetDraft: (projectId: string) => void
+}
+
+export const useQuickNoteDraftStore = create<QuickNoteDraftState>()(
+ (set, get) => ({
+ draftByProject: {},
+
+ setDraft: (projectId, draft) => {
+ const current = get().draftByProject[projectId]
+ if (current === draft) return
+ set((state) => ({
+ draftByProject: {
+ ...state.draftByProject,
+ [projectId]: draft,
+ },
+ }))
+ },
+
+ resetDraft: (projectId) => {
+ const current = get().draftByProject[projectId]
+ if (current === undefined || current === "") return
+ set((state) => ({
+ draftByProject: {
+ ...state.draftByProject,
+ [projectId]: "",
+ },
+ }))
+ },
+ }),
+)
+
+export function useQuickNoteDraft(projectId: string) {
+ const draft = useQuickNoteDraftStore(
+ (s) => s.draftByProject[projectId] ?? "",
+ )
+ const setDraft = useQuickNoteDraftStore((s) => s.setDraft)
+ const resetDraft = useQuickNoteDraftStore((s) => s.resetDraft)
+
+ return {
+ draft,
+ setDraft: (value: string) => setDraft(projectId, value),
+ resetDraft: () => resetDraft(projectId),
+ }
+}
+
+export function useQuickNoteDraftReset(projectId: string) {
+ const resetDraft = useQuickNoteDraftStore((s) => s.resetDraft)
+ return () => resetDraft(projectId)
+}