mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-20 05:54:01 +00:00
665 lines
20 KiB
TypeScript
665 lines
20 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react"
|
|
import { AnimatePresence, motion } from "motion/react"
|
|
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
|
import type { z } from "zod"
|
|
import { cn } from "@lib/utils"
|
|
import { dmSansClassName } from "@/lib/fonts"
|
|
import { isYouTubeUrl } from "@/lib/url-helpers"
|
|
import { SyncLogoIcon } from "@ui/assets/icons"
|
|
import { DocumentIcon } from "@/components/document-icon"
|
|
import { CheckIcon, 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 (isYouTubeUrl(doc.url))
|
|
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 || ""
|
|
}
|
|
|
|
function isTemporaryId(id: string | null | undefined): boolean {
|
|
if (!id) return false
|
|
return id.startsWith("temp-") || id.startsWith("temp-file-")
|
|
}
|
|
|
|
function SelectionBox({
|
|
isSelected,
|
|
isPartial = false,
|
|
}: {
|
|
isSelected: boolean
|
|
isPartial?: boolean
|
|
}) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors",
|
|
isSelected
|
|
? "border-[#369BFD] bg-[#369BFD]"
|
|
: isPartial
|
|
? "border-[#369BFD] bg-[#369BFD]/20"
|
|
: "border-[#737373] bg-transparent",
|
|
)}
|
|
aria-hidden
|
|
>
|
|
{isSelected ? (
|
|
<CheckIcon className="size-3 text-white" strokeWidth={3} />
|
|
) : isPartial ? (
|
|
<span className="h-0.5 w-2 rounded-full bg-[#369BFD]" />
|
|
) : null}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ─── 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.flatMap((key) => {
|
|
const entry = categoryMap.get(key)
|
|
return entry ? [{ categoryInfo: entry.info, docs: entry.docs }] : []
|
|
}),
|
|
}
|
|
})
|
|
}
|
|
|
|
// ─── Individual timeline card ─────────────────────────────────────────────────
|
|
|
|
function TimelineCard({
|
|
doc,
|
|
onOpenDocument,
|
|
isSelectionMode = false,
|
|
isSelected = false,
|
|
onToggleSelection,
|
|
indent = false,
|
|
isLast = false,
|
|
}: {
|
|
doc: DocumentWithMemories
|
|
onOpenDocument: (doc: DocumentWithMemories) => void
|
|
isSelectionMode?: boolean
|
|
isSelected?: boolean
|
|
onToggleSelection?: (doc: DocumentWithMemories) => void
|
|
indent?: boolean
|
|
isLast?: 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
|
|
const canSelect = !isTemporaryId(doc.id) && !isTemporaryId(doc.customId)
|
|
|
|
const handleClick = () => {
|
|
if (isSelectionMode && canSelect) {
|
|
onToggleSelection?.(doc)
|
|
return
|
|
}
|
|
onOpenDocument(doc)
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"relative w-full text-left px-3 py-3 cursor-pointer transition-colors sm:px-4",
|
|
indent
|
|
? "bg-[#121820] hover:bg-[#17202A]"
|
|
: "rounded-xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D] sm:rounded-2xl",
|
|
indent && !isLast && "border-b border-[#252B35]/70",
|
|
indent && isLast && "rounded-b-xl sm:rounded-b-2xl",
|
|
isSelectionMode && canSelect && "pl-14 sm:pl-16",
|
|
isSelectionMode && isSelected && "border-[#369BFD]/70 bg-[#00173C]/45",
|
|
dmSansClassName(),
|
|
)}
|
|
onClick={handleClick}
|
|
aria-pressed={isSelectionMode ? isSelected : undefined}
|
|
>
|
|
{isSelectionMode && canSelect && (
|
|
<span className="absolute left-5 top-4 z-10">
|
|
<SelectionBox isSelected={isSelected} />
|
|
</span>
|
|
)}
|
|
|
|
{/* 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="size-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,
|
|
isSelectionMode,
|
|
selectedDocumentIds,
|
|
onToggleSelection,
|
|
expandKey,
|
|
}: {
|
|
group: TypeGroup
|
|
isExpanded: boolean
|
|
onToggle: () => void
|
|
onOpenDocument: (doc: DocumentWithMemories) => void
|
|
isSelectionMode: boolean
|
|
selectedDocumentIds: Set<string>
|
|
onToggleSelection?: (documentId: string) => void
|
|
expandKey: string
|
|
}) {
|
|
const firstDoc = group.docs[0]
|
|
if (!firstDoc) return null
|
|
|
|
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,
|
|
)
|
|
const selectableDocs = group.docs.filter(
|
|
(doc) => !isTemporaryId(doc.id) && !isTemporaryId(doc.customId) && doc.id,
|
|
)
|
|
const selectedCount = selectableDocs.filter(
|
|
(doc) => doc.id && selectedDocumentIds.has(doc.id),
|
|
).length
|
|
const isGroupSelected =
|
|
selectableDocs.length > 0 && selectedCount === selectableDocs.length
|
|
const isGroupPartial = selectedCount > 0 && !isGroupSelected
|
|
|
|
const handleGroupSelect = () => {
|
|
for (const doc of selectableDocs) {
|
|
if (!doc.id) continue
|
|
const shouldToggle = isGroupSelected
|
|
? selectedDocumentIds.has(doc.id)
|
|
: !selectedDocumentIds.has(doc.id)
|
|
if (shouldToggle) onToggleSelection?.(doc.id)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden rounded-xl border border-[#252B35] bg-[#1B1F24] sm:rounded-2xl",
|
|
isSelectionMode &&
|
|
(isGroupSelected || isGroupPartial) &&
|
|
"border-[#369BFD]/70 bg-[#00173C]/45",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex w-full items-stretch transition-colors hover:bg-[#21262D]",
|
|
isExpanded && "border-b border-[#252B35]",
|
|
isSelectionMode &&
|
|
(isGroupSelected || isGroupPartial) &&
|
|
"bg-[#00173C]/45",
|
|
)}
|
|
>
|
|
{isSelectionMode && selectableDocs.length > 0 && (
|
|
<button
|
|
type="button"
|
|
className="flex w-14 shrink-0 cursor-pointer items-start justify-center py-4 sm:w-16"
|
|
onClick={handleGroupSelect}
|
|
aria-label={isGroupSelected ? "Deselect group" : "Select group"}
|
|
aria-pressed={isGroupSelected}
|
|
>
|
|
<SelectionBox
|
|
isSelected={isGroupSelected}
|
|
isPartial={isGroupPartial}
|
|
/>
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex min-w-0 flex-1 cursor-pointer items-start justify-between gap-2 py-3 pr-3 text-left sm:items-center sm:gap-3 sm:pr-4",
|
|
isSelectionMode && selectableDocs.length > 0
|
|
? "pl-1"
|
|
: "pl-3 sm:pl-4",
|
|
dmSansClassName(),
|
|
)}
|
|
onClick={onToggle}
|
|
aria-expanded={isExpanded}
|
|
>
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2.5 gap-y-1">
|
|
<DocumentIcon
|
|
type={firstDoc.type}
|
|
source={firstDoc.source ?? undefined}
|
|
url={firstDoc.url ?? undefined}
|
|
className="size-3.5 shrink-0 opacity-60"
|
|
/>
|
|
<span className="text-[13px] text-white/75 font-medium whitespace-nowrap shrink-0">
|
|
{countLabel}
|
|
</span>
|
|
{preview && (
|
|
<span className="basis-full text-[12px] leading-relaxed text-white/35 line-clamp-2 sm:basis-auto sm:truncate sm:leading-normal">
|
|
· {preview}
|
|
</span>
|
|
)}
|
|
{totalMemories > 0 && (
|
|
<span
|
|
className="shrink-0 text-[11px] font-medium sm: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(
|
|
"mt-0.5 size-3.5 text-white/20 shrink-0 transition-transform duration-200 sm:mt-0",
|
|
isExpanded && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<AnimatePresence initial={false}>
|
|
{isExpanded && (
|
|
<motion.div
|
|
id={`group-${expandKey}`}
|
|
className="overflow-hidden bg-[#121820] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
|
|
initial={{ height: 0, opacity: 0, y: -4 }}
|
|
animate={{ height: "auto", opacity: 1, y: 0 }}
|
|
exit={{ height: 0, opacity: 0, y: -3 }}
|
|
transition={{
|
|
height: { duration: 0.28, ease: [0.4, 0, 0.2, 1] },
|
|
opacity: { duration: 0.18 },
|
|
y: { duration: 0.22, ease: [0.4, 0, 0.2, 1] },
|
|
}}
|
|
>
|
|
{group.docs.map((doc, index) => (
|
|
<motion.div
|
|
key={doc.id}
|
|
initial={{ opacity: 0, y: -4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -3 }}
|
|
transition={{
|
|
duration: 0.18,
|
|
delay: Math.min(index * 0.025, 0.12),
|
|
ease: [0.4, 0, 0.2, 1],
|
|
}}
|
|
>
|
|
<TimelineCard
|
|
doc={doc}
|
|
onOpenDocument={onOpenDocument}
|
|
isSelectionMode={isSelectionMode}
|
|
isSelected={doc.id ? selectedDocumentIds.has(doc.id) : false}
|
|
onToggleSelection={(doc) => {
|
|
if (doc.id) onToggleSelection?.(doc.id)
|
|
}}
|
|
indent
|
|
isLast={index === group.docs.length - 1}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Main TimelineView ────────────────────────────────────────────────────────
|
|
|
|
interface TimelineViewProps {
|
|
documents: DocumentWithMemories[]
|
|
onOpenDocument: (document: DocumentWithMemories) => void
|
|
hasNextPage?: boolean
|
|
isFetchingNextPage?: boolean
|
|
onLoadMore?: () => void
|
|
isSelectionMode?: boolean
|
|
selectedDocumentIds?: Set<string>
|
|
onToggleSelection?: (documentId: string) => void
|
|
}
|
|
|
|
export function TimelineView({
|
|
documents,
|
|
onOpenDocument,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
onLoadMore,
|
|
isSelectionMode = false,
|
|
selectedDocumentIds = new Set(),
|
|
onToggleSelection,
|
|
}: 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)
|
|
const handleTimelineCardSelection = useCallback(
|
|
(doc: DocumentWithMemories) => {
|
|
if (doc.id) onToggleSelection?.(doc.id)
|
|
},
|
|
[onToggleSelection],
|
|
)
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"w-full max-w-[820px] mx-auto py-3 pb-12 space-y-5 sm:py-4 sm:space-y-7",
|
|
dmSansClassName(),
|
|
)}
|
|
>
|
|
{periodGroups.map((period, periodIndex) => {
|
|
const periodHasExpandedGroup = period.typeGroups.some((group) =>
|
|
expandedGroups.has(`${period.label}::${group.categoryInfo.key}`),
|
|
)
|
|
|
|
return (
|
|
<div
|
|
key={period.label}
|
|
className="grid grid-cols-[22px_minmax(0,1fr)] gap-x-2 gap-y-2 sm:grid-cols-[96px_24px_minmax(0,1fr)] sm:gap-x-3 sm:gap-y-0"
|
|
>
|
|
<div className="col-start-2 row-start-1 shrink-0 pt-0 sm:col-start-1 sm:row-start-1 sm:pt-3 sm:text-right">
|
|
<span className="inline-flex rounded-full border border-[#252B35] bg-[#0D121A] px-2.5 py-1 text-[10px] font-medium uppercase leading-none tracking-[0.15em] text-white/45 sm:border-transparent sm:bg-transparent sm:px-0 sm:py-0 sm:text-white/30">
|
|
{period.label}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="relative col-start-1 row-start-1 row-span-2 flex justify-center sm:col-start-2 sm:row-start-1 sm:row-span-1">
|
|
<div
|
|
className={cn(
|
|
"absolute top-4 bottom-[-20px] w-px bg-[#1F2835] sm:top-5 sm:bottom-[-28px]",
|
|
periodIndex === periodGroups.length - 1 && "bottom-2",
|
|
)}
|
|
/>
|
|
<motion.div
|
|
className={cn(
|
|
"absolute top-4 bottom-[-20px] w-px origin-top bg-linear-to-b from-[#369BFD] via-[#369BFD]/70 to-transparent sm:top-5 sm:bottom-[-28px]",
|
|
periodIndex === periodGroups.length - 1 && "bottom-2",
|
|
)}
|
|
initial={false}
|
|
animate={{
|
|
scaleY: periodHasExpandedGroup ? 1 : 0,
|
|
opacity: periodHasExpandedGroup ? 1 : 0,
|
|
}}
|
|
transition={{ duration: 0.34, ease: [0.4, 0, 0.2, 1] }}
|
|
/>
|
|
<motion.div
|
|
className="relative mt-1.5 flex size-3 items-center justify-center rounded-full border border-[#369BFD]/35 bg-[#0D121A] shadow-[0_0_0_4px_rgba(54,155,253,0.06)] sm:mt-3"
|
|
initial={false}
|
|
animate={{
|
|
borderColor: periodHasExpandedGroup
|
|
? "rgba(54,155,253,0.8)"
|
|
: "rgba(54,155,253,0.35)",
|
|
boxShadow: periodHasExpandedGroup
|
|
? "0 0 0 7px rgba(54,155,253,0.09)"
|
|
: "0 0 0 4px rgba(54,155,253,0.06)",
|
|
}}
|
|
transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }}
|
|
>
|
|
<AnimatePresence>
|
|
{periodHasExpandedGroup && (
|
|
<motion.div
|
|
className="absolute inset-[-6px] rounded-full border border-[#369BFD]/35"
|
|
initial={{ scale: 0.6, opacity: 0.8 }}
|
|
animate={{ scale: 1.45, opacity: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{
|
|
duration: 0.52,
|
|
ease: [0.2, 0.8, 0.2, 1],
|
|
}}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
<motion.div
|
|
className="size-1.5 rounded-full bg-[#369BFD]"
|
|
initial={false}
|
|
animate={{ scale: periodHasExpandedGroup ? 1.15 : 1 }}
|
|
transition={{ duration: 0.24, ease: [0.4, 0, 0.2, 1] }}
|
|
/>
|
|
</motion.div>
|
|
</div>
|
|
|
|
<div className="col-start-2 row-start-2 min-w-0 space-y-1.5 sm:col-start-3 sm:row-start-1">
|
|
{period.typeGroups.map((group) => {
|
|
const expandKey = `${period.label}::${group.categoryInfo.key}`
|
|
|
|
if (group.docs.length === 1) {
|
|
const doc = group.docs[0]
|
|
if (!doc) return null
|
|
|
|
return (
|
|
<TimelineCard
|
|
key={expandKey}
|
|
doc={doc}
|
|
onOpenDocument={onOpenDocument}
|
|
isSelectionMode={isSelectionMode}
|
|
isSelected={
|
|
doc.id ? selectedDocumentIds.has(doc.id) : false
|
|
}
|
|
onToggleSelection={handleTimelineCardSelection}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<GroupCard
|
|
key={expandKey}
|
|
group={group}
|
|
expandKey={expandKey}
|
|
isExpanded={expandedGroups.has(expandKey)}
|
|
onToggle={() => toggleGroup(expandKey)}
|
|
onOpenDocument={onOpenDocument}
|
|
isSelectionMode={isSelectionMode}
|
|
selectedDocumentIds={selectedDocumentIds}
|
|
onToggleSelection={onToggleSelection}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
<div ref={sentinelRef} className="h-1" />
|
|
</div>
|
|
)
|
|
}
|