supermemory/apps/web/components/new/documents-command-palette.tsx
MaheshtheDev 8c195cece6 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.
2026-01-23 22:17:44 +00:00

235 lines
7.3 KiB
TypeScript

"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
initialSearch?: string
}
export function DocumentsCommandPalette({
open,
onOpenChange,
projectId,
onOpenDocument,
initialSearch = "",
}: 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(initialSearch)
setSelectedIndex(0)
}
}, [open, queryClient, projectId, initialSearch])
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>
)
}