chore: cmdk, google docs viewer, image preview, document icons (#691)

This commit is contained in:
MaheshtheDev 2026-01-22 02:35:35 +00:00
parent 0a8c5fa049
commit 7972e543c4
22 changed files with 1230 additions and 700 deletions

View file

@ -1,28 +1,51 @@
"use client"
import { useState } from "react"
import { useState, useCallback } from "react"
import { Header } from "@/components/new/header"
import { ChatSidebar } from "@/components/new/chat"
import { MemoriesGrid } from "@/components/new/memories-grid"
import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background"
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 { 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 { analytics } from "@/lib/analytics"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export default function NewPage() {
const isMobile = useIsMobile()
const { selectedProject } = useProject()
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
const [isMCPModalOpen, setIsMCPModalOpen] = useState(false)
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null)
const [isDocumentModalOpen, setIsDocumentModalOpen] = useState(false)
useHotkeys("c", () => {
analytics.addDocumentModalOpened()
setIsAddDocumentOpen(true)
})
useHotkeys("mod+k", (e) => {
e.preventDefault()
setIsSearchOpen(true)
})
const [isChatOpen, setIsChatOpen] = useState(!isMobile)
const handleOpenDocument = useCallback((document: DocumentWithMemories) => {
setSelectedDocument(document)
setIsDocumentModalOpen(true)
}, [])
return (
<HotkeysProvider>
<div className="bg-black min-h-screen">
@ -40,13 +63,17 @@ export default function NewPage() {
setIsMCPModalOpen(true)
}}
onOpenChat={() => setIsChatOpen(true)}
onOpenSearch={() => setIsSearchOpen(true)}
/>
<main
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">
<MemoriesGrid isChatOpen={isChatOpen} />
<MemoriesGrid
isChatOpen={isChatOpen}
onOpenDocument={handleOpenDocument}
/>
</div>
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
<AnimatePresence mode="popLayout">
@ -70,6 +97,17 @@ export default function NewPage() {
isOpen={isMCPModalOpen}
onClose={() => setIsMCPModalOpen(false)}
/>
<DocumentsCommandPalette
open={isSearchOpen}
onOpenChange={setIsSearchOpen}
projectId={selectedProject}
onOpenDocument={handleOpenDocument}
/>
<DocumentModal
document={selectedDocument}
isOpen={isDocumentModalOpen}
onClose={() => setIsDocumentModalOpen(false)}
/>
</div>
</HotkeysProvider>
)

View file

@ -383,7 +383,7 @@ export function ChatSidebar({
className={cn(
"flex items-start justify-start",
isMobile
? "fixed bottom-4 right-4 z-50"
? "fixed bottom-5 right-0 left-0 z-50 justify-center items-center"
: "absolute top-0 right-0 m-4",
dmSansClassName(),
)}
@ -392,15 +392,21 @@ export function ChatSidebar({
<motion.button
onClick={toggleChat}
className={cn(
"flex items-center gap-3 rounded-full px-3 py-1.5 text-sm font-medium border border-[#17181A] text-white cursor-pointer whitespace-nowrap shadow-lg",
isMobile && "px-4 py-2",
"flex items-center gap-3 rounded-full px-3 py-1.5 text-sm font-medium border text-white cursor-pointer whitespace-nowrap",
isMobile
? "gap-2.5 px-5 py-3 text-[15px] border-[#1E2128] shadow-[0_8px_32px_rgba(0,0,0,0.5),0_2px_8px_rgba(0,0,0,0.3)]"
: "border-[#17181A] shadow-lg",
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
background: isMobile
? "linear-gradient(135deg, #12161C 0%, #0A0D12 100%)"
: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<NovaOrb size={24} className="blur-[0.6px]! z-10" />
{!isMobile && "Chat with Nova"}
<NovaOrb size={isMobile ? 26 : 24} className="blur-[0.6px]! z-10" />
<span className={cn(isMobile && "font-medium")}>Chat with Nova</span>
</motion.button>
</motion.div>
) : (

View file

@ -5,71 +5,12 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { PDF } from "@ui/assets/icons"
import { FileText, Image, Video } from "lucide-react"
import { DocumentIcon } from "@/components/new/document-icon"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
function PDFIcon() {
return (
<svg
width="8"
height="10"
viewBox="0 0 8 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>PDF Icon</title>
<g filter="url(#filter0_i_719_6586)">
<path
d="M1 10C0.725 10 0.489583 9.90208 0.29375 9.70625C0.0979167 9.51042 0 9.275 0 9V1C0 0.725 0.0979167 0.489583 0.29375 0.29375C0.489583 0.0979167 0.725 0 1 0H5L8 3V9C8 9.275 7.90208 9.51042 7.70625 9.70625C7.51042 9.90208 7.275 10 7 10H1ZM4.5 3.5V1H1V9H7V3.5H4.5Z"
fill="#FF7673"
/>
</g>
<defs>
<filter
id="filter0_i_719_6586"
x="0"
y="0"
width="8.25216"
height="10.2522"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.252163" dy="0.252163" />
<feGaussianBlur stdDeviation="0.504325" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_719_6586"
/>
</filter>
</defs>
</svg>
)
}
function getFileTypeInfo(document: DocumentWithMemories): {
icon: React.ReactNode
extension: string
color?: string
} {
@ -78,56 +19,33 @@ function getFileTypeInfo(document: DocumentWithMemories): {
if (mimeType) {
if (mimeType === "application/pdf") {
return {
icon: <PDFIcon />,
extension: ".pdf",
color: "#FF7673",
}
return { extension: ".pdf", color: "#FF7673" }
}
if (mimeType.startsWith("image/")) {
const ext = mimeType.split("/")[1] || "jpg"
return {
icon: <Image className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: `.${ext}`,
}
return { extension: `.${ext}` }
}
if (mimeType.startsWith("video/")) {
const ext = mimeType.split("/")[1] || "mp4"
return {
icon: <Video className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: `.${ext}`,
}
return { extension: `.${ext}` }
}
}
switch (type) {
case "pdf":
return {
icon: <PDF className="w-4 h-4 text-[#FF7673]" />,
extension: ".pdf",
color: "#FF7673",
}
return { extension: ".pdf", color: "#FF7673" }
case "image":
return {
icon: <Image className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: ".jpg",
}
return { extension: ".jpg" }
case "video":
return {
icon: <Video className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: ".mp4",
}
return { extension: ".mp4" }
default:
return {
icon: <FileText className="w-4 h-4" style={{ color: "#FAFAFA" }} />,
extension: ".file",
}
return { extension: ".file" }
}
}
export function FilePreview({ document }: { document: DocumentWithMemories }) {
const [imageError, setImageError] = useState(false)
const { icon, extension, color } = getFileTypeInfo(document)
const { extension, color } = getFileTypeInfo(document)
const type = document.type?.toLowerCase()
const mimeType = document.metadata?.mimeType as string | undefined
@ -168,7 +86,7 @@ export function FilePreview({ document }: { document: DocumentWithMemories }) {
) : (
<div className="p-3">
<div className="flex items-center gap-1 mb-2">
{icon}
<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

@ -4,6 +4,10 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import {
DocumentIcon,
getDocumentTypeLabel,
} from "@/components/new/document-icon"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -13,49 +17,28 @@ export function GoogleDocsPreview({
}: {
document: DocumentWithMemories
}) {
const label = getDocumentTypeLabel(document.type)
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] gap-3">
<div className="flex items-center gap-2 mb-2">
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 87.3 78"
aria-label="Google Docs"
>
<title>Google Docs</title>
<path
fill="#0066da"
d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3L27.5 53H0c0 1.55.4 3.1 1.2 4.5z"
/>
<path
fill="#00ac47"
d="M43.65 25 29.9 1.2c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44A9.06 9.06 0 0 0 0 53h27.5z"
/>
<path
fill="#ea4335"
d="M73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75L86.1 57.5c.8-1.4 1.2-2.95 1.2-4.5H59.798l5.852 11.5z"
/>
<path
fill="#00832d"
d="M43.65 25 57.4 1.2C56.05.4 54.5 0 52.9 0H34.4c-1.6 0-3.15.45-4.5 1.2z"
/>
<path
fill="#2684fc"
d="M59.8 53H27.5L13.75 76.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
/>
<path
fill="#ffba00"
d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3L43.65 25 59.8 53h27.45c0-1.55-.4-3.1-1.2-4.5z"
/>
</svg>
<DocumentIcon type={document.type} url={document.url} className="w-4 h-4" />
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
Google Docs
{label}
</p>
</div>
{document.content && (
{document.summary ? (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.summary}
</p>
) : document.content ? (
<p className="text-[10px] text-[#737373] line-clamp-4">
{document.content}
</p>
) : (
<p className="text-[10px] text-[#737373] line-clamp-4">
No summary available
</p>
)}
</div>
)

View file

@ -4,138 +4,16 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { useMemo } from "react"
import { DocumentIcon } from "@/components/new/document-icon"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
type TipTapNode = {
type: string
text?: string
content?: TipTapNode[]
attrs?: Record<string, unknown>
}
function extractTextFromTipTapContent(content: string): string {
try {
const json = JSON.parse(content) as TipTapNode
return extractTextFromNode(json)
} catch {
return content
}
}
function extractTextFromNode(node: TipTapNode): string {
if (node.type === "text" && node.text) {
return node.text
}
if (!node.content) {
return ""
}
const texts: string[] = []
for (const child of node.content) {
const text = extractTextFromNode(child)
if (text) {
texts.push(text)
}
}
const blockTypes = [
"paragraph",
"heading",
"listItem",
"blockquote",
"codeBlock",
]
if (blockTypes.includes(node.type)) {
return `${texts.join("")}\n`
}
return texts.join("")
}
function NoteIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Note Icon</title>
<mask
id="mask0_344_4970"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="14"
height="14"
>
<rect width="14" height="14" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_344_4970)">
<g filter="url(#filter0_i_344_4970)">
<path
d="M3.50002 8.75008H7.58335L8.75002 7.58341H3.50002V8.75008ZM3.50002 6.41675H7.00002V5.25008H3.50002V6.41675ZM2.33335 4.08341V9.91675H6.41669L5.25002 11.0834H1.16669V2.91675H12.8334V4.66675H11.6667V4.08341H2.33335ZM13.3584 7.17508C13.407 7.22369 13.4313 7.27716 13.4313 7.3355C13.4313 7.39383 13.407 7.4473 13.3584 7.49591L12.8334 8.02091L11.8125 7.00008L12.3375 6.47508C12.3861 6.42647 12.4396 6.40216 12.4979 6.40216C12.5563 6.40216 12.6097 6.42647 12.6584 6.47508L13.3584 7.17508ZM7.58335 12.2501V11.2292L11.4625 7.35008L12.4834 8.37091L8.60419 12.2501H7.58335Z"
fill="#FAFAFA"
/>
</g>
</g>
<defs>
<filter
id="filter0_i_344_4970"
x="1.16669"
y="2.91675"
width="12.6176"
height="9.68628"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.353028" dy="0.353028" />
<feGaussianBlur stdDeviation="0.706055" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_344_4970"
/>
</filter>
</defs>
</svg>
)
}
export function NotePreview({ document }: { document: DocumentWithMemories }) {
const previewText = useMemo(() => {
if (!document.content) return ""
return extractTextFromTipTapContent(document.content).trim()
}, [document.content])
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center gap-1">
<NoteIcon />
<DocumentIcon type="note" className="w-4 h-4" />
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
Note
</p>
@ -151,9 +29,9 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
{document.title}
</p>
)}
{previewText && (
{document.summary && (
<p className="text-[10px] text-[#737373] line-clamp-4">
{previewText}
{document.summary}
</p>
)}
</div>

View file

@ -3,50 +3,41 @@
import { useState } from "react"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
type OgData = {
title?: string
image?: string
}
export function WebsitePreview({
document,
ogData,
}: {
document: DocumentWithMemories
ogData?: OgData | null
}) {
const [imageError, setImageError] = useState(false)
const ogImage = (document as DocumentWithMemories & { ogImage?: string })
.ogImage
const displayOgImage = ogImage || ogData?.image
return (
<div className="bg-[#0B1017] rounded-[18px] overflow-hidden">
{ogImage && !imageError ? (
{displayOgImage && !imageError ? (
<div className="relative w-full aspect-video bg-gray-100 overflow-hidden">
<img
src={ogImage}
src={displayOgImage}
alt={document.title || "Website preview"}
className="w-full h-full object-cover"
onError={() => setImageError(true)}
loading="lazy"
/>
</div>
) : (
<div className="p-3 gap-2">
<p
className={cn(
dmSansClassName(),
"text-[12px] font-semibold text-[#E5E5E5] line-clamp-2 mb-1",
)}
>
{document.title || "Untitled Document"}
</p>
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-3">
{document.content}
</p>
)}
</div>
)}
) : null}
</div>
)
}

View file

@ -0,0 +1,322 @@
"use client"
import type React from "react"
import { useState } from "react"
import { MCPIcon } from "@/components/menu"
import {
GoogleDocs,
GoogleSheets,
GoogleSlides,
GoogleDrive,
MicrosoftWord,
MicrosoftExcel,
MicrosoftPowerpoint,
MicrosoftOneNote,
OneDrive,
NotionDoc,
PDF,
} from "@ui/assets/icons"
import { Globe, FileText, Image } from "lucide-react"
import { cn } from "@lib/utils"
const BRAND_COLORS: Record<string, string> = {
google_doc: "#4285F4",
google_sheet: "#0F9D58",
google_slide: "#F4B400",
google_drive: "#4285F4",
notion: "#FFFFFF",
notion_doc: "#FFFFFF",
microsoft_word: "#2B579A",
word: "#2B579A",
microsoft_excel: "#217346",
excel: "#217346",
microsoft_powerpoint: "#D24726",
powerpoint: "#D24726",
microsoft_onenote: "#7719AA",
onenote: "#7719AA",
onedrive: "#0078D4",
pdf: "#FF7673",
text: "#FAFAFA",
note: "#FAFAFA",
image: "#FAFAFA",
video: "#FAFAFA",
webpage: "#737373",
url: "#737373",
}
function getFaviconUrl(url: string): string {
try {
const domain = new URL(url).hostname
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
} catch {
return ""
}
}
function FaviconIcon({
url,
className,
}: {
url: string
className?: string
}) {
const [hasError, setHasError] = useState(false)
const faviconUrl = getFaviconUrl(url)
if (hasError || !faviconUrl) {
return <Globe className={cn("text-[#737373]", className)} />
}
return (
<img
src={faviconUrl}
alt="Website favicon"
className={className}
style={{
width: "1em",
height: "1em",
objectFit: "contain",
}}
onError={() => setHasError(true)}
/>
)
}
function YouTubeIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 20 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>YouTube</title>
<path
d="M8 10L13.19 7L8 4V10ZM19.56 2.17C19.69 2.64 19.78 3.27 19.84 4.07C19.91 4.87 19.94 5.56 19.94 6.16L20 7C20 9.19 19.84 10.8 19.56 11.83C19.31 12.73 18.73 13.31 17.83 13.56C17.36 13.69 16.5 13.78 15.18 13.84C13.88 13.91 12.69 13.94 11.59 13.94L10 14C5.81 14 3.2 13.84 2.17 13.56C1.27 13.31 0.69 12.73 0.44 11.83C0.31 11.36 0.22 10.73 0.16 9.93C0.0900001 9.13 0.0599999 8.44 0.0599999 7.84L0 7C0 4.81 0.16 3.2 0.44 2.17C0.69 1.27 1.27 0.69 2.17 0.44C2.64 0.31 3.5 0.22 4.82 0.16C6.12 0.0899998 7.31 0.0599999 8.41 0.0599999L10 0C14.19 0 16.8 0.16 17.83 0.44C18.73 0.69 19.31 1.27 19.56 2.17Z"
fill="#FF0000"
/>
<path d="M8 10L13.19 7L8 4V10Z" fill="white" />
</svg>
)
}
function TextDocumentIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 18 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>Text Document</title>
<path
d="M3.33333 8.33333H9.16667L10.8333 6.66667H3.33333V8.33333ZM3.33333 5H8.33333V3.33333H3.33333V5ZM1.66667 1.66667V10H7.5L5.83333 11.6667H0V0H16.6667V2.5H15V1.66667H1.66667ZM17.4167 6.08333C17.4861 6.15278 17.5208 6.22917 17.5208 6.3125C17.5208 6.39583 17.4861 6.47222 17.4167 6.54167L16.6667 7.29167L15.2083 5.83333L15.9583 5.08333C16.0278 5.01389 16.1042 4.97917 16.1875 4.97917C16.2708 4.97917 16.3472 5.01389 16.4167 5.08333L17.4167 6.08333ZM9.16667 13.3333V11.875L14.7083 6.33333L16.1667 7.79167L10.625 13.3333H9.16667Z"
fill="currentColor"
/>
</svg>
)
}
function XIcon({ className }: { className?: string }) {
return (
<span className={cn("font-bold", className)} style={{ color: "#FFFFFF" }}>
𝕏
</span>
)
}
export interface DocumentIconProps {
type: string | null | undefined
source?: string | null
url?: string | null
className?: string
}
export function DocumentIcon({
type,
source,
url,
className,
}: DocumentIconProps) {
const iconClassName = cn("w-4 h-4", className)
if (source === "mcp") {
return <MCPIcon className={iconClassName} />
}
if (url?.includes("youtube.com") || url?.includes("youtu.be")) {
return <YouTubeIcon className={iconClassName} />
}
if (
type === "webpage" ||
type === "url" ||
(url && (type === "unknown" || !type))
) {
if (url) {
return <FaviconIcon url={url} className={iconClassName} />
}
return <Globe className={iconClassName} style={{ color: "#737373" }} />
}
const brandColor = BRAND_COLORS[type ?? ""] ?? "#FAFAFA"
switch (type) {
case "tweet":
return <XIcon className={iconClassName} />
case "google_doc":
return (
<span style={{ color: brandColor }}>
<GoogleDocs className={iconClassName} />
</span>
)
case "google_sheet":
return (
<span style={{ color: brandColor }}>
<GoogleSheets className={iconClassName} />
</span>
)
case "google_slide":
return (
<span style={{ color: brandColor }}>
<GoogleSlides className={iconClassName} />
</span>
)
case "google_drive":
return (
<span style={{ color: brandColor }}>
<GoogleDrive className={iconClassName} />
</span>
)
case "notion":
case "notion_doc":
return (
<span style={{ color: brandColor }}>
<NotionDoc className={iconClassName} />
</span>
)
case "word":
case "microsoft_word":
return (
<span style={{ color: brandColor }}>
<MicrosoftWord className={iconClassName} />
</span>
)
case "excel":
case "microsoft_excel":
return (
<span style={{ color: brandColor }}>
<MicrosoftExcel className={iconClassName} />
</span>
)
case "powerpoint":
case "microsoft_powerpoint":
return (
<span style={{ color: brandColor }}>
<MicrosoftPowerpoint className={iconClassName} />
</span>
)
case "onenote":
case "microsoft_onenote":
return (
<span style={{ color: brandColor }}>
<MicrosoftOneNote className={iconClassName} />
</span>
)
case "onedrive":
return (
<span style={{ color: brandColor }}>
<OneDrive className={iconClassName} />
</span>
)
case "pdf":
return <PDF className={iconClassName} />
case "youtube":
case "video":
return <YouTubeIcon className={iconClassName} />
case "image":
return <Image className={iconClassName} style={{ color: brandColor }} />
case "text":
case "note":
return <TextDocumentIcon className={iconClassName} />
default:
return <FileText className={iconClassName} style={{ color: "#FAFAFA" }} />
}
}
/**
* @deprecated Use <DocumentIcon /> component instead
* Backward-compatible function for legacy code
*/
export function getDocumentIcon(
type: string,
className: string,
source?: string,
url?: string,
): React.ReactNode {
return (
<DocumentIcon type={type} source={source} url={url} className={className} />
)
}
export function getDocumentTypeLabel(type: string | null | undefined): string {
switch (type) {
case "google_doc":
return "Google Docs"
case "google_sheet":
return "Google Sheets"
case "google_slide":
return "Google Slides"
case "google_drive":
return "Google Drive"
case "notion":
case "notion_doc":
return "Notion"
case "word":
case "microsoft_word":
return "Word"
case "excel":
case "microsoft_excel":
return "Excel"
case "powerpoint":
case "microsoft_powerpoint":
return "PowerPoint"
case "onenote":
case "microsoft_onenote":
return "OneNote"
case "onedrive":
return "OneDrive"
case "pdf":
return "PDF"
case "youtube":
case "video":
return "Video"
case "image":
return "Image"
case "text":
case "note":
return "Note"
case "tweet":
return "Tweet"
case "webpage":
case "url":
return "Webpage"
default:
return "Document"
}
}

View file

@ -0,0 +1,66 @@
"use client"
import { useState } from "react"
import { Loader2 } from "lucide-react"
import {
extractGoogleDocId,
getGoogleEmbedUrl,
} from "@/lib/url-helpers"
interface GoogleDocViewerProps {
url: string | null | undefined
customId: string | null | undefined
type: "google_doc" | "google_sheet" | "google_slide"
}
export function GoogleDocViewer({ url, customId, type }: GoogleDocViewerProps) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const docId = customId ?? (url ? extractGoogleDocId(url) : null)
if (!docId) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
Unable to load document - no document ID found
</div>
)
}
const embedUrl = getGoogleEmbedUrl(docId, type)
const typeLabels = {
google_doc: "Google Doc",
google_sheet: "Google Sheet",
google_slide: "Google Slides",
}
return (
<div className="flex flex-col h-full w-full overflow-hidden">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-[#14161A] z-10">
<div className="flex items-center gap-2 text-gray-400">
<Loader2 className="w-5 h-5 animate-spin" />
<span>Loading {typeLabels[type]}...</span>
</div>
</div>
)}
{error && (
<div className="flex items-center justify-center h-full text-red-400">
{error}
</div>
)}
<iframe
src={embedUrl}
className="flex-1 w-full h-full border-0 rounded-[14px]"
onLoad={() => setLoading(false)}
onError={() => {
setLoading(false)
setError("Failed to load document")
}}
allow="autoplay"
title={typeLabels[type]}
/>
</div>
)
}

View file

@ -0,0 +1,56 @@
"use client"
import { useState } from "react"
import { cn } from "@lib/utils"
interface ImagePreviewProps {
url: string
title?: string | null
}
export function ImagePreview({ url, title }: ImagePreviewProps) {
const [imageError, setImageError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
if (imageError || !url) {
return (
<div className="flex items-center justify-center h-full text-[#737373]">
<p>Failed to load image</p>
</div>
)
}
return (
<div className="relative w-full h-full overflow-hidden flex items-center justify-center bg-[#0B1017]">
{isLoading && (
<div className="absolute inset-0 bg-cover bg-center animate-pulse">
<div className="w-full h-full bg-[#1B1F24]" />
</div>
)}
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url(${url})`,
filter: "blur(100px)",
transform: "scale(1.1)",
opacity: isLoading ? 0.5 : 1,
}}
/>
<div className="absolute inset-0 bg-black/30" />
<img
src={url}
alt={title || "Image preview"}
className={cn(
"relative max-w-full max-h-full w-auto h-auto object-contain z-10",
isLoading && "opacity-0",
)}
onError={() => {
setImageError(true)
setIsLoading(false)
}}
onLoad={() => setIsLoading(false)}
loading="lazy"
/>
</div>
)
}

View file

@ -0,0 +1,143 @@
"use client"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import dynamic from "next/dynamic"
import { isTwitterUrl } from "@/lib/url-helpers"
import { ImagePreview } from "./image-preview"
import { TweetContent } from "./tweet"
import { NotionDoc } from "./notion-doc"
import { YoutubeVideo } from "./yt-video"
import { WebPageContent } from "./web-page"
import { TextEditorContent } from "./text-editor-content"
import { GoogleDocViewer } from "./google-doc"
import type { TextEditorProps } from "./text-editor-content"
export type { TextEditorProps }
const PdfViewer = dynamic(
() => import("./pdf").then((mod) => ({ default: mod.PdfViewer })),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full text-gray-400">
Loading PDF viewer...
</div>
),
},
) as typeof import("./pdf").PdfViewer
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
interface DocumentContentProps {
document: DocumentWithMemories | null
textEditorProps: TextEditorProps
}
type ContentType =
| "image"
| "tweet"
| "text"
| "pdf"
| "notion"
| "youtube"
| "webpage"
| "google_doc"
| "google_sheet"
| "google_slide"
| null
function getContentType(document: DocumentWithMemories | null): ContentType {
if (!document) return null
const isImage =
document.type === "image" ||
document.metadata?.mimeType?.toString().startsWith("image/")
if (isImage && document.url) return "image"
if (
document.type === "tweet" ||
(document.url && isTwitterUrl(document.url))
)
return "tweet"
if (document.type === "text") return "text"
if (document.type === "pdf") return "pdf"
if (document.type === "notion_doc") return "notion"
if (document.type === "google_doc") return "google_doc"
if (document.type === "google_sheet") return "google_sheet"
if (document.type === "google_slide") return "google_slide"
if (document.url?.includes("youtube.com")) return "youtube"
if (document.type === "webpage") return "webpage"
return null
}
export function DocumentContent({
document,
textEditorProps,
}: DocumentContentProps) {
const contentType = getContentType(document)
if (!document || !contentType) return null
switch (contentType) {
case "image":
return (
<ImagePreview url={document.url ?? ""} title={document.title} />
)
case "tweet":
return (
<TweetContent
url={document.url}
tweetMetadata={document.metadata?.sm_internal_twitter_metadata}
/>
)
case "text":
return <TextEditorContent {...textEditorProps} />
case "pdf":
return <PdfViewer url={document.url} />
case "notion":
return <NotionDoc content={document.content ?? ""} />
case "youtube":
return <YoutubeVideo url={document.url ?? ""} />
case "webpage":
return <WebPageContent content={document.content ?? ""} />
case "google_doc":
return (
<GoogleDocViewer
url={document.url}
customId={document.customId}
type="google_doc"
/>
)
case "google_sheet":
return (
<GoogleDocViewer
url={document.url}
customId={document.customId}
type="google_sheet"
/>
)
case "google_slide":
return (
<GoogleDocViewer
url={document.url}
customId={document.customId}
type="google_slide"
/>
)
default:
return null
}
}

View file

@ -0,0 +1,91 @@
"use client"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { TextEditor } from "../../text-editor"
import { motion, AnimatePresence } from "motion/react"
import { Button } from "@repo/ui/components/button"
import { Loader2 } from "lucide-react"
export interface TextEditorProps {
documentId: string
editorResetNonce: number
initialEditorContent: string | undefined
hasUnsavedChanges: boolean
isSaving: boolean
onContentChange: (content: string) => void
onSave: () => void
onReset: () => void
}
export function TextEditorContent({
documentId,
editorResetNonce,
initialEditorContent,
hasUnsavedChanges,
isSaving,
onContentChange,
onSave,
onReset,
}: TextEditorProps) {
return (
<>
<div className="p-4 overflow-y-auto flex-1 scrollbar-thin">
<TextEditor
key={`${documentId}-${editorResetNonce}`}
content={initialEditorContent}
onContentChange={onContentChange}
onSubmit={onSave}
/>
</div>
<AnimatePresence>
{hasUnsavedChanges && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-3 px-4 py-2 bg-[#1B1F24] rounded-full shadow-[0_4px_20px_rgba(0,0,0,0.4),inset_1px_1px_1px_rgba(255,255,255,0.1)]"
>
<span className="text-sm text-[#737373]">Unsaved changes</span>
<Button
variant="ghost"
size="sm"
onClick={onReset}
disabled={isSaving}
className="text-[#737373]/80 hover:text-white rounded-full px-3"
>
Cancel
</Button>
<Button
variant="insideOut"
size="sm"
onClick={onSave}
disabled={isSaving}
className="hover:text-white rounded-full px-4"
>
{isSaving ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
Saving...
</>
) : (
<>
Save
<span
className={cn(
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm px-1 py-0.5 text-[10px] flex items-center justify-center",
dmSansClassName(),
)}
>
+Enter
</span>
</>
)}
</Button>
</motion.div>
)}
</AnimatePresence>
</>
)
}

View file

@ -1,263 +1,10 @@
import { MCPIcon } from "@/components/menu"
import { colors } from "@repo/ui/memory-graph/constants"
import {
GoogleDocs,
MicrosoftWord,
NotionDoc,
GoogleDrive,
GoogleSheets,
GoogleSlides,
OneDrive,
MicrosoftOneNote,
MicrosoftPowerpoint,
MicrosoftExcel,
} from "@ui/assets/icons"
import { Globe } from "lucide-react"
import { useState } from "react"
const getFaviconUrl = (url: string): string => {
try {
const domain = new URL(url).hostname
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`
} catch {
return ""
}
}
const FaviconIcon = ({
url,
className,
iconProps,
}: {
url: string
className: string
iconProps: { className: string; style: { color: string } }
}) => {
const [hasError, setHasError] = useState(false)
const faviconUrl = getFaviconUrl(url)
if (hasError || !faviconUrl) {
return <Globe {...iconProps} />
}
return (
<img
src={faviconUrl}
alt="Website favicon"
className={className}
style={{
width: "2em",
height: "2em",
objectFit: "contain",
}}
onError={() => setHasError(true)}
/>
)
}
const PDFIcon = ({ className }: { className: string }) => {
return (
<svg
width="8"
height="10"
viewBox="0 0 8 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>PDF Icon</title>
<g filter="url(#filter0_i_719_6586)">
<path
d="M1 10C0.725 10 0.489583 9.90208 0.29375 9.70625C0.0979167 9.51042 0 9.275 0 9V1C0 0.725 0.0979167 0.489583 0.29375 0.29375C0.489583 0.0979167 0.725 0 1 0H5L8 3V9C8 9.275 7.90208 9.51042 7.70625 9.70625C7.51042 9.90208 7.275 10 7 10H1ZM4.5 3.5V1H1V9H7V3.5H4.5Z"
fill="#FF7673"
/>
</g>
<defs>
<filter
id="filter0_i_719_6586"
x="0"
y="0"
width="8.25216"
height="10.2522"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.252163" dy="0.252163" />
<feGaussianBlur stdDeviation="0.504325" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_719_6586"
/>
</filter>
</defs>
</svg>
)
}
const YouTubeIcon = ({ className }: { className: string }) => {
return (
<svg
width="20"
height="14"
viewBox="0 0 20 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>YouTube Icon</title>
<path
d="M8 10L13.19 7L8 4V10ZM19.56 2.17C19.69 2.64 19.78 3.27 19.84 4.07C19.91 4.87 19.94 5.56 19.94 6.16L20 7C20 9.19 19.84 10.8 19.56 11.83C19.31 12.73 18.73 13.31 17.83 13.56C17.36 13.69 16.5 13.78 15.18 13.84C13.88 13.91 12.69 13.94 11.59 13.94L10 14C5.81 14 3.2 13.84 2.17 13.56C1.27 13.31 0.69 12.73 0.44 11.83C0.31 11.36 0.22 10.73 0.16 9.93C0.0900001 9.13 0.0599999 8.44 0.0599999 7.84L0 7C0 4.81 0.16 3.2 0.44 2.17C0.69 1.27 1.27 0.69 2.17 0.44C2.64 0.31 3.5 0.22 4.82 0.16C6.12 0.0899998 7.31 0.0599999 8.41 0.0599999L10 0C14.19 0 16.8 0.16 17.83 0.44C18.73 0.69 19.31 1.27 19.56 2.17Z"
fill="#FF0034"
/>
<path d="M8 10L13.19 7L8 4V10Z" fill="white" />
</svg>
)
}
const TextDocumentIcon = ({ className }: { className: string }) => {
return (
<svg
width="18"
height="14"
viewBox="0 0 18 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>Text Document Icon</title>
<g filter="url(#filter0_i_724_34196)">
<path
d="M3.33333 8.33333H9.16667L10.8333 6.66667H3.33333V8.33333ZM3.33333 5H8.33333V3.33333H3.33333V5ZM1.66667 1.66667V10H7.5L5.83333 11.6667H0V0H16.6667V2.5H15V1.66667H1.66667ZM17.4167 6.08333C17.4861 6.15278 17.5208 6.22917 17.5208 6.3125C17.5208 6.39583 17.4861 6.47222 17.4167 6.54167L16.6667 7.29167L15.2083 5.83333L15.9583 5.08333C16.0278 5.01389 16.1042 4.97917 16.1875 4.97917C16.2708 4.97917 16.3472 5.01389 16.4167 5.08333L17.4167 6.08333ZM9.16667 13.3333V11.875L14.7083 6.33333L16.1667 7.79167L10.625 13.3333H9.16667Z"
fill="#FAFAFA"
/>
</g>
<defs>
<filter
id="filter0_i_724_34196"
x="0"
y="0"
width="18.0253"
height="13.8376"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.504325" dy="0.504325" />
<feGaussianBlur stdDeviation="1.00865" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.0431373 0 0 0 0 0.0588235 0 0 0 0 0.0823529 0 0 0 0.4 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_724_34196"
/>
</filter>
</defs>
</svg>
)
}
export const getDocumentIcon = (
type: string,
className: string,
source?: string,
url?: string,
) => {
const iconProps = {
className,
style: { color: colors.text.muted },
}
if (source === "mcp") {
return <MCPIcon {...iconProps} />
}
if (url?.includes("youtube.com") || url?.includes("youtu.be")) {
return <YouTubeIcon className={className} />
}
if (
type === "webpage" ||
type === "url" ||
(url && (type === "unknown" || !type))
) {
if (url) {
return (
<FaviconIcon url={url} className={className} iconProps={iconProps} />
)
}
return <Globe {...iconProps} />
}
switch (type) {
case "tweet":
return <span className={className}>𝕏</span>
case "google_doc":
return <GoogleDocs {...iconProps} />
case "google_sheet":
return <GoogleSheets {...iconProps} />
case "google_slide":
return <GoogleSlides {...iconProps} />
case "google_drive":
return <GoogleDrive {...iconProps} />
case "notion":
case "notion_doc":
return <NotionDoc {...iconProps} />
case "word":
case "microsoft_word":
return <MicrosoftWord {...iconProps} />
case "excel":
case "microsoft_excel":
return <MicrosoftExcel {...iconProps} />
case "powerpoint":
case "microsoft_powerpoint":
return <MicrosoftPowerpoint {...iconProps} />
case "onenote":
case "microsoft_onenote":
return <MicrosoftOneNote {...iconProps} />
case "onedrive":
return <OneDrive {...iconProps} />
case "pdf":
return <PDFIcon className={className} />
case "youtube":
case "video":
return <YouTubeIcon className={className} />
default:
return <TextDocumentIcon className={className} />
}
}
/**
* @deprecated Import from "@/components/new/document-icon" instead
* This file is kept for backward compatibility
*/
export {
DocumentIcon,
getDocumentIcon,
getDocumentTypeLabel,
type DocumentIconProps,
} from "@/components/new/document-icon"

View file

@ -1,3 +1,4 @@
import { useState } from "react"
import { cn } from "@lib/utils"
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"
@ -168,6 +169,22 @@ export function GraphListMemories({
}: {
memoryEntries: MemoryEntry[]
}) {
const [expandedMemories, setExpandedMemories] = useState<Set<string>>(
new Set(),
)
const toggleMemory = (memoryId: string) => {
setExpandedMemories((prev) => {
const next = new Set(prev)
if (next.has(memoryId)) {
next.delete(memoryId)
} else {
next.add(memoryId)
}
return next
})
}
return (
<div
id="document-memories"
@ -241,7 +258,7 @@ export function GraphListMemories({
</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid grid-cols-2 gap-2 pt-3 overflow-y-auto pr-1 scrollbar-thin">
<div className="grid grid-cols-2 gap-2 pt-3 overflow-y-auto pr-1 scrollbar-thin items-start">
{memoryEntries.map((memory, idx) => {
const isClickable =
memory.url &&
@ -265,9 +282,18 @@ export function GraphListMemories({
</div>
)}
{memory.memory && (
<div className="text-xs text-[#525D6E]/80 line-clamp-2">
<button
type="button"
className={cn(
"text-xs text-[#525D6E] cursor-pointer transition-all text-left w-full",
expandedMemories.has(memory.id)
? ""
: "line-clamp-2",
)}
onClick={() => toggleMemory(memory.id)}
>
{memory.memory}
</div>
</button>
)}
{memory.url && (
<div className="text-xs text-[#525D6E] truncate">
@ -296,7 +322,7 @@ export function GraphListMemories({
if (isClickable) {
return (
<a
className="block p-2 bg-[#0C1829]/50 rounded-md border border-[#525D6E]/20 hover:bg-[#0C1829]/70 transition-colors cursor-pointer"
className="block p-2 bg-[#0C1829]/50 rounded-md border border-[#525D6E]/20 hover:bg-[#0C1829]/70 transition-colors cursor-pointer self-start"
href={memory.url}
key={memory.id || idx}
rel="noopener noreferrer"
@ -309,7 +335,7 @@ export function GraphListMemories({
return (
<div
className={cn("bg-[#0C1829] rounded-xl")}
className={cn("bg-[#0C1829] rounded-xl self-start")}
key={memory.id || idx}
>
{content}

View file

@ -12,38 +12,18 @@ import {
import type { z } from "zod"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@lib/utils"
import dynamic from "next/dynamic"
import { Title } from "./title"
import { Summary as DocumentSummary } from "./summary"
import { dmSansClassName } from "@/lib/fonts"
import { GraphListMemories, type MemoryEntry } from "./graph-list-memories"
import { YoutubeVideo } from "./content/yt-video"
import { TweetContent } from "./content/tweet"
import { isTwitterUrl } from "@/lib/url-helpers"
import { NotionDoc } from "./content/notion-doc"
import { TextEditor } from "../text-editor"
import { DocumentContent } from "./content"
import { useState, useEffect, useCallback, useMemo } from "react"
import { motion, AnimatePresence } from "motion/react"
import { Button } from "@repo/ui/components/button"
import { useDocumentMutations } from "@/hooks/use-document-mutations"
import type { UseMutationResult } from "@tanstack/react-query"
import { toast } from "sonner"
import { WebPageContent } from "./content/web-page"
import { useIsMobile } from "@hooks/use-mobile"
// Dynamically importing to prevent DOMMatrix error
const PdfViewer = dynamic(
() => import("./content/pdf").then((mod) => ({ default: mod.PdfViewer })),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full text-gray-400">
Loading PDF viewer...
</div>
),
},
) as typeof import("./content/pdf").PdfViewer
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -200,6 +180,28 @@ export function DocumentModal({
)
}, [_document?.id, draftContentString, updateMutation])
const textEditorProps = useMemo(
() => ({
documentId: _document?.id ?? "",
editorResetNonce,
initialEditorContent,
hasUnsavedChanges,
isSaving: updateMutation.isPending,
onContentChange: setDraftContentString,
onSave: handleSave,
onReset: resetEditor,
}),
[
_document?.id,
editorResetNonce,
initialEditorContent,
hasUnsavedChanges,
updateMutation.isPending,
handleSave,
resetEditor,
],
)
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
@ -267,87 +269,10 @@ export function DocumentModal({
"bg-[#14161A] rounded-[14px] overflow-hidden flex flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative",
)}
>
{(_document?.type === "tweet" ||
(_document?.url && isTwitterUrl(_document.url))) && (
<TweetContent
url={_document?.url}
tweetMetadata={
_document?.metadata?.sm_internal_twitter_metadata
}
/>
)}
{_document?.type === "text" && (
<>
<div className="p-4 overflow-y-auto flex-1 scrollbar-thin">
<TextEditor
key={`${_document.id}-${editorResetNonce}`}
content={initialEditorContent}
onContentChange={setDraftContentString}
onSubmit={handleSave}
/>
</div>
<AnimatePresence>
{hasUnsavedChanges && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-3 px-4 py-2 bg-[#1B1F24] rounded-full shadow-[0_4px_20px_rgba(0,0,0,0.4),inset_1px_1px_1px_rgba(255,255,255,0.1)]"
>
<span className="text-sm text-[#737373]">
Unsaved changes
</span>
<Button
variant="ghost"
size="sm"
onClick={resetEditor}
disabled={updateMutation.isPending}
className="text-[#737373]/80 hover:text-white rounded-full px-3"
>
Cancel
</Button>
<Button
variant="insideOut"
size="sm"
onClick={handleSave}
disabled={updateMutation.isPending}
className="hover:text-white rounded-full px-4"
>
{updateMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
Saving...
</>
) : (
<>
Save
<span
className={cn(
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm px-1 py-0.5 text-[10px] flex items-center justify-center",
dmSansClassName(),
)}
>
+Enter
</span>
</>
)}
</Button>
</motion.div>
)}
</AnimatePresence>
</>
)}
{_document?.type === "pdf" && <PdfViewer url={_document.url} />}
{_document?.type === "notion_doc" && (
<NotionDoc content={_document.content ?? ""} />
)}
{_document?.url?.includes("youtube.com") && (
<YoutubeVideo url={_document.url} />
)}
{_document?.type === "webpage" && (
<WebPageContent content={_document.content ?? ""} />
)}
<DocumentContent
document={_document}
textEditorProps={textEditorProps}
/>
</div>
<div
id="document-memories-summary"

View file

@ -1,12 +1,8 @@
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import type { DocumentTypeEnum } from "@repo/validation/schemas"
import type { z } from "zod"
import { getDocumentIcon } from "@/components/new/document-modal/document-icon"
import { DocumentIcon } from "@/components/new/document-icon"
type DocumentType = z.infer<typeof DocumentTypeEnum>
function getFileExtension(documentType: DocumentType): string | null {
function getFileExtension(documentType: string): string | null {
switch (documentType) {
case "pdf":
return ".pdf"
@ -21,7 +17,7 @@ export function Title({
url,
}: {
title: string | null | undefined
documentType: DocumentType
documentType: string
url?: string | null
}) {
const extension = getFileExtension(documentType)
@ -33,13 +29,8 @@ export function Title({
"text-[16px] font-semibold text-[#FAFAFA] leading-[125%] flex items-center gap-3 min-w-0",
)}
>
<div className="pl-1 flex items-center gap-1 w-5 h-5 shrink-0">
{getDocumentIcon(
documentType as DocumentType,
"w-5 h-5",
undefined,
url ?? undefined,
)}
<div className="pl-1 flex items-center gap-1 shrink-0">
<DocumentIcon type={documentType} url={url} className="w-5 h-5" />
{extension && (
<p
className={cn(dmSansClassName(), "text-[12px] font-semibold")}

View file

@ -0,0 +1,233 @@
"use client"
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
import { useQueryClient } from "@tanstack/react-query"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useIsMobile } from "@hooks/use-mobile"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { SearchIcon } from "lucide-react"
import { DocumentIcon } from "@/components/new/document-icon"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
interface DocumentsCommandPaletteProps {
open: boolean
onOpenChange: (open: boolean) => void
projectId: string
onOpenDocument: (document: DocumentWithMemories) => void
}
export function DocumentsCommandPalette({
open,
onOpenChange,
projectId,
onOpenDocument,
}: DocumentsCommandPaletteProps) {
const isMobile = useIsMobile()
const queryClient = useQueryClient()
const [search, setSearch] = useState("")
const [selectedIndex, setSelectedIndex] = useState(0)
const [documents, setDocuments] = useState<DocumentWithMemories[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
// Get documents from the existing query cache when dialog opens
useEffect(() => {
if (open) {
const queryData = queryClient.getQueryData<{
pages: DocumentsResponse[]
pageParams: number[]
}>(["documents-with-memories", projectId])
if (queryData?.pages) {
setDocuments(queryData.pages.flatMap((page) => page.documents ?? []))
}
setTimeout(() => inputRef.current?.focus(), 0)
setSearch("")
setSelectedIndex(0)
}
}, [open, queryClient, projectId])
const filteredDocuments = useMemo(() => {
if (!search.trim()) return documents
const searchLower = search.toLowerCase()
return documents.filter((doc) =>
doc.title?.toLowerCase().includes(searchLower),
)
}, [documents, search])
// Reset selection when filtered results change
const handleSearchChange = useCallback((value: string) => {
setSearch(value)
setSelectedIndex(0)
}, [])
// Scroll selected item into view
useEffect(() => {
const selectedElement = listRef.current?.querySelector(
`[data-index="${selectedIndex}"]`,
)
selectedElement?.scrollIntoView({ block: "nearest" })
}, [selectedIndex])
const handleSelect = useCallback(
(document: DocumentWithMemories) => {
if (!document.id) return
onOpenDocument(document)
onOpenChange(false)
setSearch("")
},
[onOpenDocument, onOpenChange],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setSelectedIndex((i) => (i < filteredDocuments.length - 1 ? i + 1 : i))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setSelectedIndex((i) => (i > 0 ? i - 1 : i))
} else if (e.key === "Enter") {
e.preventDefault()
const document = filteredDocuments[selectedIndex]
if (document) handleSelect(document)
}
},
[filteredDocuments, selectedIndex, handleSelect],
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"bg-[#1B1F24] flex flex-col p-0 gap-0 overflow-hidden top-[15%]! translate-y-0! scrollbar-thin border-none shadow-2xl",
isMobile
? "w-[calc(100vw-2rem)]! max-w-none! rounded-xl"
: "w-[560px]! max-w-[560px]! rounded-xl",
dmSansClassName(),
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
boxShadow: "0px 1.5px 20px 0px rgba(0,0,0,0.65)",
}}
showCloseButton={false}
onKeyDown={handleKeyDown}
>
<DialogTitle className="sr-only">Search Documents</DialogTitle>
<div
id="search-input-container"
className="flex items-center gap-3 px-4 py-3"
>
<SearchIcon className="size-4 text-[#737373] shrink-0" />
<input
ref={inputRef}
type="text"
placeholder="Search documents by title..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
className={cn(
"flex-1 bg-transparent text-white text-sm placeholder:text-[#737373] outline-none",
dmSansClassName(),
)}
/>
</div>
<div
ref={listRef}
id="search-results"
className="flex flex-col min-h-[300px] max-h-[400px] overflow-y-auto py-1.5 px-1.5"
>
{filteredDocuments.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-[#737373] text-sm">No documents found</p>
</div>
) : (
filteredDocuments.map((doc, index) => {
const isSelected = index === selectedIndex
return (
<button
key={doc.id}
type="button"
data-index={index}
onClick={() => handleSelect(doc)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-left transition-colors",
isSelected
? "bg-[#293952]/40"
: "opacity-70 hover:opacity-100 hover:bg-[#293952]/40",
)}
>
<div
className="flex items-center justify-center size-5 rounded-md shrink-0"
style={{
background:
"linear-gradient(180deg, #14161A 0%, #0D0F12 100%)",
boxShadow:
"inset 0px 1px 1px rgba(255,255,255,0.03), inset 0px -1px 1px rgba(0,0,0,0.1)",
}}
>
<DocumentIcon
type={doc.type}
url={doc.url}
className="size-4"
/>
</div>
<div className="flex-1 min-w-0 flex gap-1 justify-between items-center">
<p className="text-sm font-medium text-white truncate">
{doc.title || "Untitled"}
</p>
<p className="text-xs text-[#737373] text-nowrap">
{new Date(doc.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</p>
</div>
</button>
)
})
)}
</div>
<div
id="search-footer"
className="flex items-center justify-between px-4 py-2.5 text-[11px] text-[#737373]"
>
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5">
<span className="flex gap-0.5">
<kbd className="px-1 py-0.5 rounded bg-[#14161A] border border-[#2E3033] text-[10px] font-medium">
</kbd>
<kbd className="px-1 py-0.5 rounded bg-[#14161A] border border-[#2E3033] text-[10px] font-medium">
</kbd>
</span>
<span>Navigate</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 rounded bg-[#14161A] border border-[#2E3033] text-[10px] font-medium">
</kbd>
<span>Open</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 rounded bg-[#14161A] border border-[#2E3033] text-[10px] font-medium">
Esc
</kbd>
<span>Close</span>
</span>
</div>
<span>{filteredDocuments.length} documents</span>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -39,9 +39,15 @@ interface HeaderProps {
onAddMemory?: () => void
onOpenMCP?: () => void
onOpenChat?: () => void
onOpenSearch?: () => void
}
export function Header({ onAddMemory, onOpenMCP, onOpenChat }: HeaderProps) {
export function Header({
onAddMemory,
onOpenMCP,
onOpenChat,
onOpenSearch,
}: HeaderProps) {
const { user } = useAuth()
const { selectedProject } = useProject()
const { switchProject } = useProjectMutations()
@ -247,6 +253,7 @@ export function Header({ onAddMemory, onOpenMCP, onOpenChat }: HeaderProps) {
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={onOpenSearch}
>
<SearchIcon className="size-4" />
<span className="bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm text-[10px] flex items-center justify-center gap-0.5 px-1">
@ -256,7 +263,7 @@ export function Header({ onAddMemory, onOpenMCP, onOpenChat }: HeaderProps) {
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Search Icon</title>
<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"

View file

@ -22,7 +22,7 @@ export function MCPModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className={cn(
"w-[80%]! max-w-[900px]! h-[80%]! max-h-[375px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-3 rounded-[22px]",
"w-[90vw]! max-w-[900px]! max-h-[min(75vh,560px)]! border-none bg-[#1B1F24] flex flex-col p-4 gap-3 rounded-[22px]",
dmSansClassName(),
)}
style={{
@ -37,7 +37,7 @@ export function MCPModal({
Connect your AI to Supermemory
</p>
<p className={cn("text-[#737373] font-medium")}>
Let your AI create and use your memories via MCP. Learn more
Let your AI create and use your memories via MCP.
</p>
</div>
<div className="flex items-center gap-2">
@ -50,7 +50,7 @@ export function MCPModal({
</DialogPrimitive.Close>
</div>
</div>
<div className="w-full px-4 py-4 rounded-[14px] bg-[#14161A] shadow-inside-out overflow-y-auto">
<div className="w-full flex-1 min-h-0 px-4 py-4 rounded-[14px] bg-[#14161A] shadow-inside-out overflow-y-auto">
<MCPSteps variant="embedded" />
</div>
<DialogFooter className="justify-between!">

View file

@ -50,7 +50,7 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
function generateInstallCommand() {
if (!selectedClient) return ""
let command = `npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${selectedClient} --oauth=yes`
let command = `npx -y install-mcp@latest https://mcp.supermemory.ai/mcp --client ${selectedClient} --oauth=yes`
const projectIdForCommand = selectedProject.replace(/^sm_project_/, "")
command += ` --project ${projectIdForCommand}`

View file

@ -4,7 +4,7 @@ import { useAuth } from "@lib/auth-context"
import { $fetch } from "@repo/lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useCallback, memo, useMemo, useState, useRef } from "react"
import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react"
import type { z } from "zod"
import { Masonry, useInfiniteLoader } from "masonic"
import { dmSansClassName } from "@/lib/fonts"
@ -23,22 +23,32 @@ import { YoutubePreview } from "./document-cards/youtube-preview"
import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils"
import { SyncLogoIcon } from "@ui/assets/icons"
import { McpPreview } from "./document-cards/mcp-preview"
import { DocumentModal } from "./document-modal"
import { getFaviconUrl } from "@/lib/url-helpers"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
type OgData = {
title?: string
image?: string
}
const IS_DEV = process.env.NODE_ENV === "development"
const PAGE_SIZE = IS_DEV ? 100 : 100
const MAX_TOTAL = 1000
export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) {
interface MemoriesGridProps {
isChatOpen: boolean
onOpenDocument: (document: DocumentWithMemories) => void
}
export function MemoriesGrid({
isChatOpen,
onOpenDocument,
}: MemoriesGridProps) {
const { user } = useAuth()
const { selectedProject } = useProject()
const isMobile = useIsMobile()
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const {
data,
@ -114,10 +124,12 @@ export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) {
},
)
const handleCardClick = useCallback((document: DocumentWithMemories) => {
setSelectedDocument(document)
setIsModalOpen(true)
}, [])
const handleCardClick = useCallback(
(document: DocumentWithMemories) => {
onOpenDocument(document)
},
[onOpenDocument],
)
const renderDocumentCard = useCallback(
({
@ -198,11 +210,6 @@ export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) {
)}
</div>
)}
<DocumentModal
document={selectedDocument}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</div>
)
}
@ -252,6 +259,45 @@ const DocumentCard = memo(
}) => {
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
const cardRef = useRef<HTMLButtonElement>(null)
const [ogData, setOgData] = useState<OgData | null>(null)
const [isLoadingOg, setIsLoadingOg] = useState(false)
const ogImage = (document as DocumentWithMemories & { ogImage?: string })
.ogImage
const needsOgData =
document.url &&
!document.url.includes("x.com") &&
!document.url.includes("twitter.com") &&
!document.url.includes("files.supermemory.ai") &&
!document.url.includes("docs.googleapis.com") &&
(!document.title || !ogImage)
const hideURL = document.url?.includes("docs.googleapis.com")
useEffect(() => {
if (needsOgData && !ogData && !isLoadingOg && document.url) {
setIsLoadingOg(true)
fetch(`/api/og?url=${encodeURIComponent(document.url)}`)
.then((res) => {
if (!res.ok) return null
return res.json()
})
.then((data) => {
if (data) {
setOgData({
title: data.title,
image: data.image,
})
}
})
.catch(() => {
// Silently fail if OG fetch fails
})
.finally(() => {
setIsLoadingOg(false)
})
}
}, [needsOgData, ogData, isLoadingOg, document.url])
const handleMouseMove = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!cardRef.current) return
@ -297,7 +343,7 @@ const DocumentCard = memo(
transformStyle: "preserve-3d",
}}
>
<ContentPreview document={document} />
<ContentPreview document={document} ogData={ogData} />
{!(
document.type === "image" ||
document.metadata?.mimeType?.toString().startsWith("image/")
@ -308,16 +354,28 @@ const DocumentCard = memo(
!document.url.includes("twitter.com") &&
!document.url.includes("files.supermemory.ai") && (
<div className="px-3">
<p
className={cn(
dmSansClassName(),
"text-[12px] text-[#E5E5E5] line-clamp-1 font-semibold",
<div className="flex justify-between items-center gap-2">
<p
className={cn(
dmSansClassName(),
"text-[12px] text-[#E5E5E5] line-clamp-1 font-semibold",
)}
>
{document.title || ogData?.title || "Untitled Document"}
</p>
{getFaviconUrl(document.url) && needsOgData && (
<img
src={getFaviconUrl(document.url) || ""}
alt=""
className="w-4 h-4 shrink-0 rounded-lg"
onError={(e) => {
e.currentTarget.style.display = "none"
}}
/>
)}
>
{document.title}
</p>
</div>
<DocumentUrlDisplay url={document.url} />
{!hideURL && <DocumentUrlDisplay url={document.url} />}
</div>
)}
<div
@ -372,7 +430,13 @@ const DocumentCard = memo(
DocumentCard.displayName = "DocumentCard"
function ContentPreview({ document }: { document: DocumentWithMemories }) {
function ContentPreview({
document,
ogData,
}: {
document: DocumentWithMemories
ogData?: OgData | null
}) {
if (
document.url?.includes("https://docs.googleapis.com/v1/documents") ||
document.url?.includes("docs.google.com/document") ||
@ -412,7 +476,7 @@ function ContentPreview({ document }: { document: DocumentWithMemories }) {
}
if (document.url?.includes("https://")) {
return <WebsitePreview document={document} />
return <WebsitePreview document={document} ogData={ogData} />
}
// Default to Note

View file

@ -188,3 +188,49 @@ export function toLinkedInProfileUrl(handle: string): string {
if (!handle.trim()) return ""
return `https://linkedin.com/in/${handle.trim()}`
}
/**
* Gets the favicon URL for a given URL.
*/
export function getFaviconUrl(url: string | null | undefined): string | null {
if (!url) return null
try {
const urlObj = new URL(url)
return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=16`
} catch {
return null
}
}
/**
* Extracts the document ID from a Google Docs/Sheets/Slides URL.
* Works with various URL formats:
* - https://docs.google.com/document/d/{id}/edit
* - https://docs.google.com/spreadsheets/d/{id}/edit#gid=0
* - https://docs.google.com/presentation/d/{id}/edit
*/
export function extractGoogleDocId(url: string): string | null {
try {
const match = url.match(/\/d\/([a-zA-Z0-9_-]+)/)
return match?.[1] ?? null
} catch {
return null
}
}
/**
* Generates the embed URL for a Google document based on its type.
*/
export function getGoogleEmbedUrl(
docId: string,
type: "google_doc" | "google_sheet" | "google_slide",
): string {
switch (type) {
case "google_doc":
return `https://docs.google.com/document/d/${docId}/preview`
case "google_sheet":
return `https://docs.google.com/spreadsheets/d/${docId}/preview`
case "google_slide":
return `https://docs.google.com/presentation/d/${docId}/embed?start=false&loop=false&delayms=3000`
}
}

View file

@ -10,15 +10,14 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
className="toaster group"
theme={theme as ToasterProps["theme"]}
closeButton
toastOptions={{
classNames: {
toast:
"!bg-[#0b1017] !border !border-[#1b1f24] !rounded-[10px] !p-3 !shadow-lg",
"!bg-[#0b1017] !border !border-[#1b1f24] !rounded-[10px] !p-3 !shadow-lg px-4",
title:
"!text-[#fafafa] !text-[12px] !leading-[1.35] !tracking-[-0.12px] !font-['DM_Sans',sans-serif]",
description:
"!text-[#fafafa] !text-[12px] !leading-[1.35] !tracking-[-0.12px] !font-['DM_Sans',sans-serif]",
"!text-[#fafafa] !text-[12px] !leading-[1.35] !tracking-[-0.12px] !font-['DM_Sans',sans-serif] opacity-80",
closeButton:
"!bg-transparent !border-none !text-[#fafafa] hover:!bg-white/10 !size-6 !static !ml-2 !shrink-0",
actionButton: "!bg-white/10 !text-[#fafafa]",