mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix timeline responsiveness (#997)
This commit is contained in:
parent
a8d36df38e
commit
d30e53fc1c
2 changed files with 184 additions and 82 deletions
|
|
@ -641,27 +641,29 @@ export function MemoriesGrid({
|
|||
id="selection-toolbar"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex items-center justify-between gap-3 mb-3 mr-2 px-3 py-2 rounded-full border border-[#2261CA33] bg-[#00173C]/40",
|
||||
"flex items-center justify-between gap-1.5 mb-3 mr-2 px-2.5 py-2 rounded-full border border-[#2261CA33] bg-[#00173C]/40 sm:gap-3 sm:px-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex min-w-0 shrink items-center gap-1.5 sm:gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-[#FAFAFA] font-medium shrink-0">
|
||||
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-[#369BFD] text-[#0B0F14] text-[11px] font-semibold">
|
||||
{selectedDocumentIds.size}
|
||||
</span>
|
||||
{selectedDocumentIds.size === 1 ? "selected" : "selected"}
|
||||
<span className="hidden sm:inline">
|
||||
{selectedDocumentIds.size === 1 ? "selected" : "selected"}
|
||||
</span>
|
||||
</span>
|
||||
{selectedDocumentIds.size === 0 && (
|
||||
<span className="text-xs text-[#737373] truncate">
|
||||
<span className="hidden truncate text-xs text-[#737373] sm:inline">
|
||||
Tap documents to select
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-0.5 sm:gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-xs px-2.5 h-7 rounded-full transition-colors cursor-pointer",
|
||||
"h-7 rounded-full px-2 text-xs transition-colors cursor-pointer sm:px-2.5",
|
||||
allVisibleSelected
|
||||
? "text-[#737373] hover:text-white"
|
||||
: "text-[#FAFAFA] hover:bg-white/5",
|
||||
|
|
@ -675,7 +677,7 @@ export function MemoriesGrid({
|
|||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs px-3 h-7 rounded-full transition-colors cursor-pointer",
|
||||
"flex h-7 items-center gap-1 rounded-full px-2 text-xs transition-colors cursor-pointer sm:px-3",
|
||||
selectedDocumentIds.size === 0 || isBulkDeleting
|
||||
? "text-[#737373]/60 cursor-not-allowed"
|
||||
: "text-red-400 hover:text-red-300 hover:bg-red-500/10",
|
||||
|
|
@ -695,7 +697,7 @@ export function MemoriesGrid({
|
|||
<button
|
||||
type="button"
|
||||
aria-label="Exit selection mode"
|
||||
className="flex items-center gap-1 text-xs px-3 h-7 rounded-full text-[#737373] hover:text-white hover:bg-white/5 transition-colors cursor-pointer"
|
||||
className="flex h-7 items-center gap-1 rounded-full px-2 text-xs text-[#737373] transition-colors hover:text-white hover:bg-white/5 cursor-pointer sm:px-3"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"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"
|
||||
|
|
@ -194,6 +195,7 @@ function TimelineCard({
|
|||
isSelected = false,
|
||||
onToggleSelection,
|
||||
indent = false,
|
||||
isLast = false,
|
||||
}: {
|
||||
doc: DocumentWithMemories
|
||||
onOpenDocument: (doc: DocumentWithMemories) => void
|
||||
|
|
@ -201,6 +203,7 @@ function TimelineCard({
|
|||
isSelected?: boolean
|
||||
onToggleSelection?: (doc: DocumentWithMemories) => void
|
||||
indent?: boolean
|
||||
isLast?: boolean
|
||||
}) {
|
||||
const preview = getPreviewText(doc)
|
||||
const typeLabel = doc.type
|
||||
|
|
@ -221,11 +224,13 @@ function TimelineCard({
|
|||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative w-full text-left px-4 py-3 cursor-pointer transition-colors",
|
||||
"relative w-full text-left px-3 py-3 cursor-pointer transition-colors sm:px-4",
|
||||
indent
|
||||
? "bg-transparent hover:bg-white/[0.04]"
|
||||
: "rounded-2xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
|
||||
isSelectionMode && canSelect && "pl-10",
|
||||
? "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(),
|
||||
)}
|
||||
|
|
@ -233,7 +238,7 @@ function TimelineCard({
|
|||
aria-pressed={isSelectionMode ? isSelected : undefined}
|
||||
>
|
||||
{isSelectionMode && canSelect && (
|
||||
<span className="absolute left-4 top-4">
|
||||
<span className="absolute left-5 top-4 z-10">
|
||||
<SelectionBox isSelected={isSelected} />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -346,20 +351,27 @@ function GroupCard({
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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 rounded-2xl border border-[#252B35] bg-[#1B1F24] transition-colors hover:bg-[#21262D]",
|
||||
isExpanded && "rounded-b-none border-b-transparent",
|
||||
"flex w-full items-stretch transition-colors hover:bg-[#21262D]",
|
||||
isExpanded && "border-b border-[#252B35]",
|
||||
isSelectionMode &&
|
||||
(isGroupSelected || isGroupPartial) &&
|
||||
"border-[#369BFD]/70 bg-[#00173C]/45",
|
||||
"bg-[#00173C]/45",
|
||||
)}
|
||||
>
|
||||
{isSelectionMode && selectableDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-start px-4 py-4 cursor-pointer"
|
||||
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}
|
||||
|
|
@ -374,14 +386,16 @@ function GroupCard({
|
|||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 cursor-pointer items-center justify-between gap-3 py-3 pr-4 text-left",
|
||||
isSelectionMode && selectableDocs.length > 0 ? "pl-0" : "pl-4",
|
||||
"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 items-center gap-2.5 min-w-0 flex-1">
|
||||
<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}
|
||||
|
|
@ -392,13 +406,13 @@ function GroupCard({
|
|||
{countLabel}
|
||||
</span>
|
||||
{preview && (
|
||||
<span className="text-[12px] text-white/35 truncate">
|
||||
<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="text-[11px] font-medium shrink-0 ml-auto"
|
||||
className="shrink-0 text-[11px] font-medium sm:ml-auto"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
|
|
@ -414,33 +428,55 @@ function GroupCard({
|
|||
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 text-white/20 shrink-0 transition-transform duration-200",
|
||||
"mt-0.5 size-3.5 text-white/20 shrink-0 transition-transform duration-200 sm:mt-0",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={doc.id ? selectedDocumentIds.has(doc.id) : false}
|
||||
onToggleSelection={(doc) => {
|
||||
if (doc.id) onToggleSelection?.(doc.id)
|
||||
}}
|
||||
indent
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -506,57 +542,121 @@ export function TimelineView({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-[780px] mx-auto py-4 pb-12 space-y-6",
|
||||
"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) => (
|
||||
<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>
|
||||
{periodGroups.map((period, periodIndex) => {
|
||||
const periodHasExpandedGroup = period.typeGroups.some((group) =>
|
||||
expandedGroups.has(`${period.label}::${group.categoryInfo.key}`),
|
||||
)
|
||||
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
{period.typeGroups.map((group) => {
|
||||
const expandKey = `${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>
|
||||
|
||||
if (group.docs.length === 1) {
|
||||
const doc = group.docs[0]
|
||||
if (!doc) return null
|
||||
<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 (
|
||||
<TimelineCard
|
||||
<GroupCard
|
||||
key={expandKey}
|
||||
doc={doc}
|
||||
group={group}
|
||||
expandKey={expandKey}
|
||||
isExpanded={expandedGroups.has(expandKey)}
|
||||
onToggle={() => toggleGroup(expandKey)}
|
||||
onOpenDocument={onOpenDocument}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={
|
||||
doc.id ? selectedDocumentIds.has(doc.id) : false
|
||||
}
|
||||
onToggleSelection={handleTimelineCardSelection}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onToggleSelection={onToggleSelection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue