mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-25 16:32:45 +00:00
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
useState,
|
|
useCallback,
|
|
useRef,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
} from "react"
|
|
import { cn } from "@lib/utils"
|
|
import { dmSansClassName } from "@/lib/fonts"
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Info,
|
|
MessageSquare,
|
|
Link2,
|
|
ArrowUp,
|
|
X,
|
|
} from "lucide-react"
|
|
import { Logo } from "@ui/assets/Logo"
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
|
import { analytics } from "@/lib/analytics"
|
|
|
|
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: (highlightContent: string, userReply: string) => void
|
|
onShowRelated: (query: string) => void
|
|
isLoading?: boolean
|
|
onAddMemory?: () => void
|
|
}
|
|
|
|
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-brand-accent 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,
|
|
onAddMemory,
|
|
}: HighlightsCardProps) {
|
|
const [activeIndex, setActiveIndex] = useState(0)
|
|
const [isReplyOpen, setIsReplyOpen] = useState(false)
|
|
const [replyText, setReplyText] = useState("")
|
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
const [isClamped, setIsClamped] = useState(false)
|
|
const contentRef = useRef<HTMLDivElement>(null)
|
|
const replyInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const currentItem = items[activeIndex]
|
|
|
|
useEffect(() => {
|
|
if (isReplyOpen) replyInputRef.current?.focus()
|
|
}, [isReplyOpen])
|
|
|
|
useEffect(() => {
|
|
setActiveIndex((i) => Math.min(i, Math.max(items.length - 1, 0)))
|
|
setIsReplyOpen(false)
|
|
setReplyText("")
|
|
setIsExpanded(false)
|
|
}, [items])
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run when item or expansion changes to detect clamping
|
|
useLayoutEffect(() => {
|
|
const el = contentRef.current
|
|
if (!el) return
|
|
setIsClamped(el.scrollHeight > el.clientHeight)
|
|
}, [currentItem, isExpanded])
|
|
|
|
const handlePrev = useCallback(() => {
|
|
setActiveIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
|
setIsReplyOpen(false)
|
|
setReplyText("")
|
|
setIsExpanded(false)
|
|
}, [items.length])
|
|
|
|
const handleNext = useCallback(() => {
|
|
setActiveIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
|
setIsReplyOpen(false)
|
|
setReplyText("")
|
|
setIsExpanded(false)
|
|
}, [items.length])
|
|
|
|
const handleChatClick = useCallback(() => {
|
|
if (!currentItem) return
|
|
analytics.highlightClicked({
|
|
highlight_id: currentItem.id,
|
|
action: "chat",
|
|
})
|
|
setIsReplyOpen(true)
|
|
}, [currentItem])
|
|
|
|
const handleReplySubmit = useCallback(() => {
|
|
if (!currentItem || !replyText.trim()) return
|
|
const highlightContent = `${currentItem.title}\n\n${currentItem.content}`
|
|
onChat(highlightContent, replyText.trim())
|
|
setIsReplyOpen(false)
|
|
setReplyText("")
|
|
}, [currentItem, replyText, onChat])
|
|
|
|
const handleReplyKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleReplySubmit()
|
|
}
|
|
if (e.key === "Escape") {
|
|
setIsReplyOpen(false)
|
|
setReplyText("")
|
|
}
|
|
},
|
|
[handleReplySubmit],
|
|
)
|
|
|
|
const handleShowRelated = useCallback(() => {
|
|
if (!currentItem) return
|
|
analytics.highlightClicked({
|
|
highlight_id: currentItem.id,
|
|
action: "related",
|
|
})
|
|
onShowRelated(currentItem.query || currentItem.title)
|
|
}, [currentItem, onShowRelated])
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col gap-3 shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<div className="size-[14px] rounded-full bg-surface-skeleton animate-pulse" />
|
|
<div className="h-2 w-20 rounded bg-surface-skeleton animate-pulse" />
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="h-2.5 w-2/5 rounded bg-surface-skeleton animate-pulse" />
|
|
<div className="h-2 w-full rounded bg-surface-skeleton animate-pulse" />
|
|
<div className="h-2 w-[85%] rounded bg-surface-skeleton animate-pulse" />
|
|
<div className="h-2 w-[65%] rounded bg-surface-skeleton animate-pulse" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-[26px] w-14 rounded-lg bg-surface-skeleton animate-pulse" />
|
|
<div className="h-[26px] w-16 rounded-lg bg-surface-skeleton animate-pulse" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!currentItem || items.length === 0) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px] shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
<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-brand-accent tracking-[-0.3px]">
|
|
powered by
|
|
</span>
|
|
<span className="text-[10px] text-brand-accent font-medium tracking-[-0.3px]">
|
|
supermemory
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
|
<p className="text-[11px] text-fg-muted text-center">
|
|
Add some documents to see highlights here
|
|
</p>
|
|
{onAddMemory ? (
|
|
<button
|
|
type="button"
|
|
onClick={onAddMemory}
|
|
className="text-[11px] font-medium text-brand-accent hover:text-brand-accent/80 transition-colors cursor-pointer"
|
|
>
|
|
Add memory →
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col gap-3 shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
<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-brand-accent tracking-[-0.3px]">
|
|
powered by
|
|
</span>
|
|
<span className="text-[10px] text-brand-accent font-medium tracking-[-0.3px]">
|
|
supermemory
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label="About the Daily Brief"
|
|
className="shrink-0 rounded-full p-0.5 text-fg-subtle transition-colors hover:text-fg-primary focus-visible:outline-none"
|
|
>
|
|
<Info className="size-[14px]" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="end"
|
|
side="bottom"
|
|
className={cn(
|
|
"w-64 rounded-xl border border-[#263348]/60 p-3 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
|
dmSansClassName(),
|
|
)}
|
|
style={{
|
|
background: "linear-gradient(180deg, #101822 0%, #0A0E14 100%)",
|
|
}}
|
|
>
|
|
<p className="mb-1 text-[12px] font-semibold text-fg-primary">
|
|
Daily Brief
|
|
</p>
|
|
<p className="text-[12px] leading-relaxed text-fg-subtle">
|
|
AI-generated highlights and questions drawn from your memories. It
|
|
refreshes automatically every few hours — tap the refresh icon to
|
|
update it now.
|
|
</p>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div id="highlights-body" className="flex flex-col gap-1.5">
|
|
<p className="text-[12px] font-semibold text-fg-primary leading-tight">
|
|
{currentItem.title}
|
|
</p>
|
|
<div
|
|
ref={contentRef}
|
|
className={cn(
|
|
"text-[12px] text-fg-primary leading-normal",
|
|
!isExpanded && "line-clamp-5",
|
|
)}
|
|
>
|
|
{renderContent(currentItem.content, currentItem.format)}
|
|
</div>
|
|
{(isClamped || isExpanded) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsExpanded((v) => !v)}
|
|
className="self-start text-[11px] text-fg-subtle hover:text-fg-primary transition-colors cursor-pointer"
|
|
>
|
|
{isExpanded ? "Show less" : "Show more"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isReplyOpen && (
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="flex-1 flex items-center bg-pill-bg border border-pill-border rounded-full px-3 py-1 gap-1.5 focus-within:border-pill-border-active transition-colors">
|
|
<input
|
|
ref={replyInputRef}
|
|
type="text"
|
|
value={replyText}
|
|
onChange={(e) => setReplyText(e.target.value)}
|
|
onKeyDown={handleReplyKeyDown}
|
|
placeholder={`Ask Nova about "${currentItem.title.length > 36 ? `${currentItem.title.slice(0, 36)}…` : currentItem.title}"`}
|
|
className="flex-1 bg-transparent text-[11px] text-fg-primary placeholder:text-fg-subtle outline-none min-w-0"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleReplySubmit}
|
|
disabled={!replyText.trim()}
|
|
className="shrink-0 flex items-center justify-center text-fg-subtle hover:text-fg-primary disabled:opacity-30 disabled:hover:text-fg-subtle cursor-pointer disabled:cursor-default transition-colors"
|
|
aria-label="Send reply"
|
|
>
|
|
<ArrowUp className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsReplyOpen(false)
|
|
setReplyText("")
|
|
}}
|
|
className="shrink-0 text-fg-subtle hover:text-fg-primary transition-colors cursor-pointer"
|
|
aria-label="Cancel reply"
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</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={handleChatClick}
|
|
className="rounded-full border border-pill-border bg-pill-bg px-2.5 py-1 text-[11px] text-fg-secondary hover:bg-pill-bg-active hover:border-pill-border-active hover:text-fg-primary transition-colors flex items-center gap-1.5 cursor-pointer"
|
|
aria-label="Chat with Nova"
|
|
>
|
|
<MessageSquare className="size-3.5" />
|
|
Chat
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleShowRelated}
|
|
className="rounded-full border border-pill-border bg-pill-bg px-2.5 py-1 text-[11px] text-fg-secondary hover:bg-pill-bg-active hover:border-pill-border-active hover:text-fg-primary transition-colors flex items-center gap-1.5 cursor-pointer"
|
|
aria-label="Show related"
|
|
>
|
|
<Link2 className="size-3.5" />
|
|
Related
|
|
</button>
|
|
</div>
|
|
|
|
{items.length > 1 && (
|
|
<div id="highlights-pagination" className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handlePrev}
|
|
className="text-fg-subtle hover:text-fg-primary 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)
|
|
setIsReplyOpen(false)
|
|
setReplyText("")
|
|
}}
|
|
className={cn(
|
|
"rounded-full transition-all cursor-pointer",
|
|
idx === activeIndex
|
|
? "w-4 h-1.5 bg-brand-accent"
|
|
: "size-1.5 bg-fg-subtle hover:bg-fg-secondary",
|
|
)}
|
|
aria-label={`Go to item ${idx + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleNext}
|
|
className="text-fg-subtle hover:text-fg-primary transition-colors cursor-pointer"
|
|
aria-label="Next item"
|
|
>
|
|
<ChevronRight className="size-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|