mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
enable timeline memory selection (#990)
This commit is contained in:
parent
9f5a9e580c
commit
670d8bf76e
2 changed files with 190 additions and 52 deletions
|
|
@ -775,6 +775,9 @@ export function MemoriesGrid({
|
|||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={loadMoreDocuments}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onToggleSelection={onToggleSelection}
|
||||
/>
|
||||
) : (
|
||||
<Masonry
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ 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"
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -100,6 +100,39 @@ 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[] }
|
||||
|
|
@ -126,7 +159,7 @@ function groupDocuments(
|
|||
}
|
||||
|
||||
return periodOrder.map((label) => {
|
||||
const docs = periodMap.get(label)!
|
||||
const docs = periodMap.get(label) ?? []
|
||||
const categoryMap = new Map<
|
||||
string,
|
||||
{ info: CategoryInfo; docs: DocumentWithMemories[] }
|
||||
|
|
@ -144,9 +177,9 @@ function groupDocuments(
|
|||
|
||||
return {
|
||||
label,
|
||||
typeGroups: categoryOrder.map((key) => {
|
||||
const entry = categoryMap.get(key)!
|
||||
return { categoryInfo: entry.info, docs: entry.docs }
|
||||
typeGroups: categoryOrder.flatMap((key) => {
|
||||
const entry = categoryMap.get(key)
|
||||
return entry ? [{ categoryInfo: entry.info, docs: entry.docs }] : []
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
|
@ -157,10 +190,16 @@ function groupDocuments(
|
|||
function TimelineCard({
|
||||
doc,
|
||||
onOpenDocument,
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelection,
|
||||
indent = false,
|
||||
}: {
|
||||
doc: DocumentWithMemories
|
||||
onOpenDocument: (doc: DocumentWithMemories) => void
|
||||
isSelectionMode?: boolean
|
||||
isSelected?: boolean
|
||||
onToggleSelection?: (doc: DocumentWithMemories) => void
|
||||
indent?: boolean
|
||||
}) {
|
||||
const preview = getPreviewText(doc)
|
||||
|
|
@ -168,19 +207,37 @@ function TimelineCard({
|
|||
? 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(
|
||||
"w-full text-left px-4 py-3 cursor-pointer transition-colors",
|
||||
"relative 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]",
|
||||
isSelectionMode && canSelect && "pl-10",
|
||||
isSelectionMode && isSelected && "border-[#369BFD]/70 bg-[#00173C]/45",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => onOpenDocument(doc)}
|
||||
onClick={handleClick}
|
||||
aria-pressed={isSelectionMode ? isSelected : undefined}
|
||||
>
|
||||
{isSelectionMode && canSelect && (
|
||||
<span className="absolute left-4 top-4">
|
||||
<SelectionBox isSelected={isSelected} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Type label */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<DocumentIcon
|
||||
|
|
@ -243,15 +300,23 @@ function GroupCard({
|
|||
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]!
|
||||
const firstDoc = group.docs[0]
|
||||
if (!firstDoc) return null
|
||||
|
||||
const preview = getPreviewText(firstDoc)
|
||||
const count = group.docs.length
|
||||
const { label, singularLabel } = group.categoryInfo
|
||||
|
|
@ -260,59 +325,101 @@ function GroupCard({
|
|||
(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>
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
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",
|
||||
"flex w-full items-stretch rounded-2xl border border-[#252B35] bg-[#1B1F24] transition-colors hover:bg-[#21262D]",
|
||||
isExpanded && "rounded-b-none border-b-transparent",
|
||||
dmSansClassName(),
|
||||
isSelectionMode &&
|
||||
(isGroupSelected || isGroupPartial) &&
|
||||
"border-[#369BFD]/70 bg-[#00173C]/45",
|
||||
)}
|
||||
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="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="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>
|
||||
{isSelectionMode && selectableDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-start px-4 py-4 cursor-pointer"
|
||||
onClick={handleGroupSelect}
|
||||
aria-label={isGroupSelected ? "Deselect group" : "Select group"}
|
||||
aria-pressed={isGroupSelected}
|
||||
>
|
||||
<SelectionBox
|
||||
isSelected={isGroupSelected}
|
||||
isPartial={isGroupPartial}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ChevronDownIcon
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"size-3.5 text-white/20 shrink-0 transition-transform duration-200",
|
||||
isExpanded && "rotate-180",
|
||||
"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",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
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="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="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(
|
||||
"size-3.5 text-white/20 shrink-0 transition-transform duration-200",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
|
|
@ -324,6 +431,11 @@ function GroupCard({
|
|||
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
|
||||
/>
|
||||
))}
|
||||
|
|
@ -341,6 +453,9 @@ interface TimelineViewProps {
|
|||
hasNextPage?: boolean
|
||||
isFetchingNextPage?: boolean
|
||||
onLoadMore?: () => void
|
||||
isSelectionMode?: boolean
|
||||
selectedDocumentIds?: Set<string>
|
||||
onToggleSelection?: (documentId: string) => void
|
||||
}
|
||||
|
||||
export function TimelineView({
|
||||
|
|
@ -349,6 +464,9 @@ export function TimelineView({
|
|||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
isSelectionMode = false,
|
||||
selectedDocumentIds = new Set(),
|
||||
onToggleSelection,
|
||||
}: TimelineViewProps) {
|
||||
const [now] = useState(() => new Date())
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||
|
|
@ -378,6 +496,12 @@ export function TimelineView({
|
|||
}, [])
|
||||
|
||||
const periodGroups = groupDocuments(documents, now)
|
||||
const handleTimelineCardSelection = useCallback(
|
||||
(doc: DocumentWithMemories) => {
|
||||
if (doc.id) onToggleSelection?.(doc.id)
|
||||
},
|
||||
[onToggleSelection],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -399,11 +523,19 @@ export function TimelineView({
|
|||
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={group.docs[0]!}
|
||||
doc={doc}
|
||||
onOpenDocument={onOpenDocument}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={
|
||||
doc.id ? selectedDocumentIds.has(doc.id) : false
|
||||
}
|
||||
onToggleSelection={handleTimelineCardSelection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -416,6 +548,9 @@ export function TimelineView({
|
|||
isExpanded={expandedGroups.has(expandKey)}
|
||||
onToggle={() => toggleGroup(expandKey)}
|
||||
onOpenDocument={onOpenDocument}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onToggleSelection={onToggleSelection}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue