enable timeline memory selection (#990)

This commit is contained in:
Ishaan Gupta 2026-05-24 00:19:52 +05:30 committed by GitHub
parent 9f5a9e580c
commit 670d8bf76e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 190 additions and 52 deletions

View file

@ -775,6 +775,9 @@ export function MemoriesGrid({
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={loadMoreDocuments}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onToggleSelection={onToggleSelection}
/>
) : (
<Masonry

View file

@ -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}
/>
)
})}