"use client" import { useCallback, useEffect, useRef, useState } from "react" import { formatDistanceToNow } from "date-fns" import { Group, Panel, Separator } from "react-resizable-panels" import { useAllHighlights } from "@/lib/queries/use-all-highlights" import { useDeleteHighlight, useUpdateHighlightNote, } from "@/lib/queries/use-highlight-mutations" import { useIsMobile } from "@/lib/hooks/use-is-mobile" import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" import { EntryDetailPanel } from "@/app/reader/_components/entry-detail-panel" import { ErrorBoundary } from "@/app/reader/_components/error-boundary" import { classNames } from "@/lib/utilities" import type { HighlightWithEntryContext } from "@/lib/types/highlight" function groupHighlightsByEntry( highlights: HighlightWithEntryContext[] ): Map { const grouped = new Map() for (const highlight of highlights) { const existing = grouped.get(highlight.entryIdentifier) if (existing) { existing.push(highlight) } else { grouped.set(highlight.entryIdentifier, [highlight]) } } return grouped } function HighlightItem({ highlight, entryIdentifier, }: { highlight: HighlightWithEntryContext entryIdentifier: string }) { const [showRemoveConfirm, setShowRemoveConfirm] = useState(false) const [isEditingNote, setIsEditingNote] = useState(false) const [editedNote, setEditedNote] = useState(highlight.note ?? "") const deleteHighlight = useDeleteHighlight() const updateNote = useUpdateHighlightNote() function handleSaveNote() { const trimmedNote = editedNote.trim() updateNote.mutate({ highlightIdentifier: highlight.identifier, note: trimmedNote || null, entryIdentifier, }) setIsEditingNote(false) } return (

{highlight.highlightedText}

{isEditingNote ? (
setEditedNote(event.target.value)} placeholder="add a note..." className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" onKeyDown={(event) => { if (event.key === "Enter") handleSaveNote() if (event.key === "Escape") setIsEditingNote(false) }} autoFocus />
) : highlight.note ? (

{highlight.note}

) : null}
{formatDistanceToNow( new Date(highlight.createdAt), { addSuffix: true } )} {showRemoveConfirm ? (
remove?
) : ( )}
) } function HighlightsList({ groupedByEntry, entryIdentifiers, selectedEntryIdentifier, focusedEntryIdentifier, viewMode, onSelect, lastElementReference, hasNextPage, isFetchingNextPage, }: { groupedByEntry: Map entryIdentifiers: string[] selectedEntryIdentifier: string | null focusedEntryIdentifier: string | null viewMode: "compact" | "comfortable" | "expanded" onSelect: (entryIdentifier: string) => void lastElementReference: (node: HTMLDivElement | null) => (() => void) | undefined hasNextPage: boolean isFetchingNextPage: boolean }) { const listReference = useRef(null) useEffect(() => { if (!focusedEntryIdentifier) return const container = listReference.current if (!container) return const items = container.querySelectorAll("[data-highlight-group-item]") const focusedIndex = entryIdentifiers.indexOf(focusedEntryIdentifier) items[focusedIndex]?.scrollIntoView({ block: "nearest" }) }, [focusedEntryIdentifier, entryIdentifiers]) if (groupedByEntry.size === 0) { return (

no highlights yet

select text in an entry and click “highlight” to get started

) } return (
{entryIdentifiers.map((entryIdentifier) => { const highlights = groupedByEntry.get(entryIdentifier)! const firstHighlight = highlights[0]! const isSelected = entryIdentifier === selectedEntryIdentifier const isFocused = entryIdentifier === focusedEntryIdentifier const rowClassName = classNames( "cursor-pointer border-b border-border px-4 transition-colors last:border-b-0", isSelected ? "bg-background-tertiary" : isFocused ? "bg-background-secondary" : "hover:bg-background-secondary", isFocused && !isSelected ? "border-l-2 border-l-text-dim" : "" ) if (viewMode === "compact") { return (
onSelect(entryIdentifier)} className={rowClassName} >
{firstHighlight.entryTitle ?? "untitled"} {highlights.length} highlight{highlights.length !== 1 && "s"} {firstHighlight.feedTitle && ( {firstHighlight.feedTitle} )}
) } if (viewMode === "comfortable") { return (
onSelect(entryIdentifier)} className={rowClassName} >
{firstHighlight.entryTitle ?? "untitled"}
{firstHighlight.feedTitle && ( {firstHighlight.feedTitle} )} {highlights.length} highlight{highlights.length !== 1 && "s"} · {firstHighlight.highlightedText}
) } return (
onSelect(entryIdentifier)} className={classNames(rowClassName, "py-3")} >
{firstHighlight.entryTitle ?? "untitled"} {firstHighlight.feedTitle && ( {firstHighlight.feedTitle} )}
{highlights.map((highlight) => ( ))}
) })} {hasNextPage && (
{isFetchingNextPage ? ( loading more ... ) : (   )}
)}
) } export function HighlightsContent() { const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage, } = useAllHighlights() const selectedEntryIdentifier = useUserInterfaceStore( (state) => state.selectedEntryIdentifier ) const setSelectedEntryIdentifier = useUserInterfaceStore( (state) => state.setSelectedEntryIdentifier ) const focusedEntryIdentifier = useUserInterfaceStore( (state) => state.focusedEntryIdentifier ) const setNavigableEntryIdentifiers = useUserInterfaceStore( (state) => state.setNavigableEntryIdentifiers ) const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) const isMobile = useIsMobile() const lastElementReference = useCallback( (node: HTMLDivElement | null) => { if (!node || !hasNextPage || isFetchingNextPage) return const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) { fetchNextPage() } }, { threshold: 0.1 } ) observer.observe(node) return () => observer.disconnect() }, [hasNextPage, isFetchingNextPage, fetchNextPage] ) const allHighlights = data?.pages.flat() ?? [] const groupedByEntry = groupHighlightsByEntry(allHighlights) const entryIdentifiers = Array.from(groupedByEntry.keys()) useEffect(() => { setSelectedEntryIdentifier(null) setNavigableEntryIdentifiers([]) }, [setSelectedEntryIdentifier, setNavigableEntryIdentifiers]) useEffect(() => { setNavigableEntryIdentifiers(entryIdentifiers) // eslint-disable-next-line react-hooks/exhaustive-deps }, [entryIdentifiers.length, setNavigableEntryIdentifiers]) if (isLoading) { return (
loading ...
) } return (
{isMobile && selectedEntryIdentifier && ( )}

highlights

{allHighlights.length} highlight{allHighlights.length !== 1 && "s"}
{isMobile ? ( selectedEntryIdentifier ? (
) : (
) ) : (
{selectedEntryIdentifier && ( <>
)}
)}
) }