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.
This commit is contained in:
MaheshtheDev 2026-01-23 22:17:44 +00:00
parent 3069bd61e6
commit 8c195cece6
22 changed files with 1285 additions and 100 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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