Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/components/memories-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,9 @@ export function MemoriesGrid({
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={loadMoreDocuments}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onToggleSelection={onToggleSelection}
/>
) : (
<Masonry
Expand Down
239 changes: 187 additions & 52 deletions apps/web/components/timeline-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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[] }
Expand All @@ -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[] }
Expand All @@ -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 }] : []
}),
}
})
Expand All @@ -157,30 +190,54 @@ 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)
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(
"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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
/>
))}
Expand All @@ -341,6 +453,9 @@ interface TimelineViewProps {
hasNextPage?: boolean
isFetchingNextPage?: boolean
onLoadMore?: () => void
isSelectionMode?: boolean
selectedDocumentIds?: Set<string>
onToggleSelection?: (documentId: string) => void
}

export function TimelineView({
Expand All @@ -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())
Expand Down Expand Up @@ -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
Expand All @@ -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}
/>
)
}
Expand All @@ -416,6 +548,9 @@ export function TimelineView({
isExpanded={expandedGroups.has(expandKey)}
onToggle={() => toggleGroup(expandKey)}
onOpenDocument={onOpenDocument}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onToggleSelection={onToggleSelection}
/>
)
})}
Expand Down
Loading