mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-19 07:42:43 +00:00
feat: major iteration on the app add: dashboard improvements few more improvements add few more improvements add few more changes home improvmeents few more improvements add lot of modifications fix few things
429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react"
|
|
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
|
import type { z } from "zod"
|
|
import { cn } from "@lib/utils"
|
|
import { dmSansClassName } from "@/lib/fonts"
|
|
import { SyncLogoIcon } from "@ui/assets/icons"
|
|
import { DocumentIcon } from "@/components/document-icon"
|
|
import { ChevronDownIcon } from "lucide-react"
|
|
|
|
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
|
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
|
|
|
// ─── Time period helpers ─────────────────────────────────────────────────────
|
|
|
|
function getTimePeriodLabel(date: Date, now: Date): string {
|
|
const docDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
|
const todayDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
const diffDays = Math.round(
|
|
(todayDay.getTime() - docDay.getTime()) / 86400000,
|
|
)
|
|
|
|
if (diffDays === 0) return "Today"
|
|
if (diffDays === 1) return "Yesterday"
|
|
if (diffDays < 7) return date.toLocaleDateString("en-US", { weekday: "long" })
|
|
if (date.getFullYear() === now.getFullYear())
|
|
return date.toLocaleDateString("en-US", { month: "long", day: "numeric" })
|
|
return date.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
})
|
|
}
|
|
|
|
// ─── Document type helpers ────────────────────────────────────────────────────
|
|
|
|
type CategoryInfo = { label: string; singularLabel: string; key: string }
|
|
|
|
function getDocumentTypeInfo(doc: DocumentWithMemories): CategoryInfo {
|
|
if (doc.source === "mcp")
|
|
return { label: "MCP Items", singularLabel: "MCP Item", key: "mcp" }
|
|
if (doc.url?.includes("youtube.com") || doc.url?.includes("youtu.be"))
|
|
return {
|
|
label: "YouTube Videos",
|
|
singularLabel: "YouTube Video",
|
|
key: "youtube",
|
|
}
|
|
switch (doc.type) {
|
|
case "tweet":
|
|
return { label: "Tweets", singularLabel: "Tweet", key: "tweet" }
|
|
case "google_doc":
|
|
return {
|
|
label: "Google Docs",
|
|
singularLabel: "Google Doc",
|
|
key: "google_doc",
|
|
}
|
|
case "google_slide":
|
|
return {
|
|
label: "Google Slides",
|
|
singularLabel: "Google Slide",
|
|
key: "google_slide",
|
|
}
|
|
case "google_sheet":
|
|
return {
|
|
label: "Google Sheets",
|
|
singularLabel: "Google Sheet",
|
|
key: "google_sheet",
|
|
}
|
|
case "notion_doc":
|
|
return {
|
|
label: "Notion Docs",
|
|
singularLabel: "Notion Doc",
|
|
key: "notion_doc",
|
|
}
|
|
case "text":
|
|
return { label: "Notes", singularLabel: "Note", key: "text" }
|
|
case "pdf":
|
|
return { label: "PDFs", singularLabel: "PDF", key: "pdf" }
|
|
case "image":
|
|
return { label: "Images", singularLabel: "Image", key: "image" }
|
|
case "video":
|
|
return { label: "Videos", singularLabel: "Video", key: "video" }
|
|
case "onedrive":
|
|
return {
|
|
label: "OneDrive Files",
|
|
singularLabel: "OneDrive File",
|
|
key: "onedrive",
|
|
}
|
|
case "webpage":
|
|
return { label: "Web Pages", singularLabel: "Web Page", key: "webpage" }
|
|
default:
|
|
return doc.url?.startsWith("https://")
|
|
? { label: "Web Pages", singularLabel: "Web Page", key: "webpage" }
|
|
: { label: "Notes", singularLabel: "Note", key: "text" }
|
|
}
|
|
}
|
|
|
|
function getPreviewText(doc: DocumentWithMemories): string {
|
|
return doc.summary || doc.content || doc.title || ""
|
|
}
|
|
|
|
// ─── Grouped data structures ─────────────────────────────────────────────────
|
|
|
|
type TypeGroup = { categoryInfo: CategoryInfo; docs: DocumentWithMemories[] }
|
|
type PeriodGroup = { label: string; typeGroups: TypeGroup[] }
|
|
|
|
function groupDocuments(
|
|
documents: DocumentWithMemories[],
|
|
now: Date,
|
|
): PeriodGroup[] {
|
|
const sorted = [...documents].sort(
|
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
)
|
|
|
|
const periodMap = new Map<string, DocumentWithMemories[]>()
|
|
const periodOrder: string[] = []
|
|
|
|
for (const doc of sorted) {
|
|
const label = getTimePeriodLabel(new Date(doc.createdAt), now)
|
|
if (!periodMap.has(label)) {
|
|
periodMap.set(label, [])
|
|
periodOrder.push(label)
|
|
}
|
|
periodMap.get(label)?.push(doc)
|
|
}
|
|
|
|
return periodOrder.map((label) => {
|
|
const docs = periodMap.get(label)!
|
|
const categoryMap = new Map<
|
|
string,
|
|
{ info: CategoryInfo; docs: DocumentWithMemories[] }
|
|
>()
|
|
const categoryOrder: string[] = []
|
|
|
|
for (const doc of docs) {
|
|
const info = getDocumentTypeInfo(doc)
|
|
if (!categoryMap.has(info.key)) {
|
|
categoryMap.set(info.key, { info, docs: [] })
|
|
categoryOrder.push(info.key)
|
|
}
|
|
categoryMap.get(info.key)?.docs.push(doc)
|
|
}
|
|
|
|
return {
|
|
label,
|
|
typeGroups: categoryOrder.map((key) => {
|
|
const entry = categoryMap.get(key)!
|
|
return { categoryInfo: entry.info, docs: entry.docs }
|
|
}),
|
|
}
|
|
})
|
|
}
|
|
|
|
// ─── Individual timeline card ─────────────────────────────────────────────────
|
|
|
|
function TimelineCard({
|
|
doc,
|
|
onOpenDocument,
|
|
indent = false,
|
|
}: {
|
|
doc: DocumentWithMemories
|
|
onOpenDocument: (doc: DocumentWithMemories) => void
|
|
indent?: boolean
|
|
}) {
|
|
const preview = getPreviewText(doc)
|
|
const typeLabel = doc.type
|
|
? doc.type.charAt(0).toUpperCase() + doc.type.slice(1).replace(/_/g, " ")
|
|
: "Document"
|
|
const totalMemories = doc.memoryEntries.length
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"w-full text-left px-4 py-3 cursor-pointer transition-colors",
|
|
indent
|
|
? "bg-transparent hover:bg-white/[0.04]"
|
|
: "rounded-2xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
|
|
dmSansClassName(),
|
|
)}
|
|
onClick={() => onOpenDocument(doc)}
|
|
>
|
|
{/* Type label */}
|
|
<div className="flex items-center gap-1.5 mb-2">
|
|
<DocumentIcon
|
|
type={doc.type}
|
|
source={doc.source ?? undefined}
|
|
url={doc.url ?? undefined}
|
|
className="w-3.5 h-3.5 shrink-0 opacity-60"
|
|
/>
|
|
<span className="text-[10px] text-white/40 uppercase tracking-widest">
|
|
{typeLabel}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
{doc.title && (
|
|
<p className="text-[13px] text-white/85 font-medium leading-snug line-clamp-2 mb-1.5">
|
|
{doc.title}
|
|
</p>
|
|
)}
|
|
|
|
{/* Preview */}
|
|
{preview && (
|
|
<p className="text-[12px] text-white/45 line-clamp-3 leading-relaxed">
|
|
{preview}
|
|
</p>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
{totalMemories > 0 && (
|
|
<div className="flex items-center gap-1 mt-2.5">
|
|
<SyncLogoIcon
|
|
className="w-[11px] h-[9px]"
|
|
style={{
|
|
filter:
|
|
"brightness(0) saturate(100%) invert(58%) sepia(69%) saturate(535%) hue-rotate(181deg) brightness(101%) contrast(98%)",
|
|
}}
|
|
/>
|
|
<span
|
|
className="text-[11px] font-medium"
|
|
style={{
|
|
background:
|
|
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
|
backgroundClip: "text",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
}}
|
|
>
|
|
{totalMemories}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// ─── Collapsed group card ─────────────────────────────────────────────────────
|
|
|
|
function GroupCard({
|
|
group,
|
|
isExpanded,
|
|
onToggle,
|
|
onOpenDocument,
|
|
expandKey,
|
|
}: {
|
|
group: TypeGroup
|
|
isExpanded: boolean
|
|
onToggle: () => void
|
|
onOpenDocument: (doc: DocumentWithMemories) => void
|
|
expandKey: string
|
|
}) {
|
|
const firstDoc = group.docs[0]!
|
|
const preview = getPreviewText(firstDoc)
|
|
const count = group.docs.length
|
|
const { label, singularLabel } = group.categoryInfo
|
|
const countLabel = count === 1 ? `1 ${singularLabel}` : `${count} ${label}`
|
|
const totalMemories = group.docs.reduce(
|
|
(sum, d) => sum + d.memoryEntries.length,
|
|
0,
|
|
)
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"w-full text-left rounded-2xl px-4 py-3 cursor-pointer transition-colors",
|
|
"border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
|
|
"flex items-center justify-between gap-3",
|
|
isExpanded && "rounded-b-none border-b-transparent",
|
|
dmSansClassName(),
|
|
)}
|
|
onClick={onToggle}
|
|
aria-expanded={isExpanded}
|
|
>
|
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
|
<DocumentIcon
|
|
type={firstDoc.type}
|
|
source={firstDoc.source ?? undefined}
|
|
url={firstDoc.url ?? undefined}
|
|
className="w-3.5 h-3.5 shrink-0 opacity-60"
|
|
/>
|
|
<span className="text-[13px] text-white/75 font-medium whitespace-nowrap shrink-0">
|
|
{countLabel}
|
|
</span>
|
|
{preview && (
|
|
<span className="text-[12px] text-white/35 truncate">
|
|
— {preview}
|
|
</span>
|
|
)}
|
|
{totalMemories > 0 && (
|
|
<span
|
|
className="text-[11px] font-medium shrink-0 ml-auto"
|
|
style={{
|
|
background:
|
|
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
|
backgroundClip: "text",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
}}
|
|
>
|
|
{totalMemories}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<ChevronDownIcon
|
|
className={cn(
|
|
"w-3.5 h-3.5 text-white/20 shrink-0 transition-transform duration-200",
|
|
isExpanded && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<div
|
|
id={`group-${expandKey}`}
|
|
className="border border-t-0 border-[#252B35] rounded-b-2xl overflow-hidden divide-y divide-[#252B35]"
|
|
>
|
|
{group.docs.map((doc) => (
|
|
<TimelineCard
|
|
key={doc.id}
|
|
doc={doc}
|
|
onOpenDocument={onOpenDocument}
|
|
indent
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Main TimelineView ────────────────────────────────────────────────────────
|
|
|
|
interface TimelineViewProps {
|
|
documents: DocumentWithMemories[]
|
|
onOpenDocument: (document: DocumentWithMemories) => void
|
|
hasNextPage?: boolean
|
|
isFetchingNextPage?: boolean
|
|
onLoadMore?: () => void
|
|
}
|
|
|
|
export function TimelineView({
|
|
documents,
|
|
onOpenDocument,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
onLoadMore,
|
|
}: TimelineViewProps) {
|
|
const [now] = useState(() => new Date())
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
|
const sentinelRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!sentinelRef.current || !onLoadMore) return
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
|
onLoadMore()
|
|
}
|
|
},
|
|
{ threshold: 0.1 },
|
|
)
|
|
observer.observe(sentinelRef.current)
|
|
return () => observer.disconnect()
|
|
}, [hasNextPage, isFetchingNextPage, onLoadMore])
|
|
|
|
const toggleGroup = useCallback((key: string) => {
|
|
setExpandedGroups((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(key)) next.delete(key)
|
|
else next.add(key)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const periodGroups = groupDocuments(documents, now)
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"w-full max-w-[780px] mx-auto py-4 pb-12 space-y-6",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
{periodGroups.map((period) => (
|
|
<div key={period.label} className="grid grid-cols-[88px_1fr] gap-x-4">
|
|
<div className="pt-3 text-right shrink-0">
|
|
<span className="text-[10px] text-white/30 font-medium uppercase tracking-[0.15em] leading-none">
|
|
{period.label}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 min-w-0">
|
|
{period.typeGroups.map((group) => {
|
|
const expandKey = `${period.label}::${group.categoryInfo.key}`
|
|
|
|
if (group.docs.length === 1) {
|
|
return (
|
|
<TimelineCard
|
|
key={expandKey}
|
|
doc={group.docs[0]!}
|
|
onOpenDocument={onOpenDocument}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<GroupCard
|
|
key={expandKey}
|
|
group={group}
|
|
expandKey={expandKey}
|
|
isExpanded={expandedGroups.has(expandKey)}
|
|
onToggle={() => toggleGroup(expandKey)}
|
|
onOpenDocument={onOpenDocument}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<div ref={sentinelRef} className="h-1" />
|
|
</div>
|
|
)
|
|
}
|