diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/reader/highlights/_components/highlights-content.tsx | |
| download | asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip | |
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker.
Includes three subscription tiers (free/pro/developer), API key auth,
read-only REST API, webhook push notifications, Stripe billing with
proration, and PWA support.
Diffstat (limited to 'apps/web/app/reader/highlights/_components/highlights-content.tsx')
| -rw-r--r-- | apps/web/app/reader/highlights/_components/highlights-content.tsx | 452 |
1 files changed, 452 insertions, 0 deletions
diff --git a/apps/web/app/reader/highlights/_components/highlights-content.tsx b/apps/web/app/reader/highlights/_components/highlights-content.tsx new file mode 100644 index 0000000..4034210 --- /dev/null +++ b/apps/web/app/reader/highlights/_components/highlights-content.tsx @@ -0,0 +1,452 @@ +"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<string, HighlightWithEntryContext[]> { + const grouped = new Map<string, HighlightWithEntryContext[]>() + + 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 ( + <div className="border-l-2 border-text-dim pl-3"> + <p className="text-text-secondary"> + {highlight.highlightedText} + </p> + {isEditingNote ? ( + <div className="mt-1 flex items-center gap-2"> + <input + type="text" + value={editedNote} + onChange={(event) => 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 + /> + <button + onClick={handleSaveNote} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditingNote(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : highlight.note ? ( + <p className="mt-1 text-text-dim"> + {highlight.note} + </p> + ) : null} + <div className="mt-1 flex items-center gap-2 text-text-dim"> + <span> + {formatDistanceToNow( + new Date(highlight.createdAt), + { addSuffix: true } + )} + </span> + <button + type="button" + onClick={() => { + setEditedNote(highlight.note ?? "") + setIsEditingNote(true) + }} + className="text-text-secondary transition-colors hover:text-text-primary" + > + {highlight.note ? "edit note" : "add note"} + </button> + {showRemoveConfirm ? ( + <div className="flex items-center gap-1"> + <span>remove?</span> + <button + type="button" + onClick={() => { + deleteHighlight.mutate({ + highlightIdentifier: highlight.identifier, + entryIdentifier, + }) + setShowRemoveConfirm(false) + }} + className="text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + type="button" + onClick={() => setShowRemoveConfirm(false)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + type="button" + onClick={() => setShowRemoveConfirm(true)} + className="text-text-secondary transition-colors hover:text-status-error" + > + remove + </button> + )} + </div> + </div> + ) +} + +function HighlightsList({ + groupedByEntry, + entryIdentifiers, + selectedEntryIdentifier, + focusedEntryIdentifier, + viewMode, + onSelect, + lastElementReference, + hasNextPage, + isFetchingNextPage, +}: { + groupedByEntry: Map<string, HighlightWithEntryContext[]> + 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<HTMLDivElement>(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 ( + <div className="flex h-full items-center justify-center text-text-dim"> + <div className="text-center"> + <p>no highlights yet</p> + <p className="mt-1 text-xs"> + select text in an entry and click “highlight” to get started + </p> + </div> + </div> + ) + } + + return ( + <div ref={listReference} className="h-full overflow-auto"> + {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 ( + <div + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={rowClassName} + > + <div className="flex items-center gap-2 py-2.5"> + <span className="min-w-0 flex-1 truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + <span className="shrink-0 text-text-dim"> + {highlights.length} highlight{highlights.length !== 1 && "s"} + </span> + {firstHighlight.feedTitle && ( + <span className="shrink-0 text-text-dim"> + {firstHighlight.feedTitle} + </span> + )} + </div> + </div> + ) + } + + if (viewMode === "comfortable") { + return ( + <div + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={rowClassName} + > + <div className="py-2.5"> + <span className="block truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + <div className="mt-0.5 flex items-center gap-2 text-text-dim"> + {firstHighlight.feedTitle && ( + <span>{firstHighlight.feedTitle}</span> + )} + <span> + {highlights.length} highlight{highlights.length !== 1 && "s"} + </span> + <span>·</span> + <span className="truncate"> + {firstHighlight.highlightedText} + </span> + </div> + </div> + </div> + ) + } + + return ( + <div + key={entryIdentifier} + data-highlight-group-item + onClick={() => onSelect(entryIdentifier)} + className={classNames(rowClassName, "py-3")} + > + <div className="mb-2 flex items-center gap-2"> + <span className="truncate text-text-primary"> + {firstHighlight.entryTitle ?? "untitled"} + </span> + {firstHighlight.feedTitle && ( + <span className="shrink-0 text-text-dim"> + {firstHighlight.feedTitle} + </span> + )} + </div> + <div className="space-y-2"> + {highlights.map((highlight) => ( + <HighlightItem + key={highlight.identifier} + highlight={highlight} + entryIdentifier={entryIdentifier} + /> + ))} + </div> + </div> + ) + })} + {hasNextPage && ( + <div ref={lastElementReference} className="py-4 text-center"> + {isFetchingNextPage ? ( + <span className="text-text-dim">loading more ...</span> + ) : ( + <span className="text-text-dim"> </span> + )} + </div> + )} + </div> + ) +} + +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([]) + }, []) + + useEffect(() => { + setNavigableEntryIdentifiers(entryIdentifiers) + }, [entryIdentifiers.length, setNavigableEntryIdentifiers]) + + if (isLoading) { + return ( + <div className="flex h-full items-center justify-center text-text-dim"> + loading ... + </div> + ) + } + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center justify-between border-b border-border px-4 py-3"> + <div className="flex items-center gap-3"> + {isMobile && selectedEntryIdentifier && ( + <button + type="button" + onClick={() => setSelectedEntryIdentifier(null)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + ← back + </button> + )} + <h1 className="text-text-primary">highlights</h1> + </div> + <span className="text-text-dim">{allHighlights.length} highlight{allHighlights.length !== 1 && "s"}</span> + </header> + <ErrorBoundary> + {isMobile ? ( + selectedEntryIdentifier ? ( + <div className="flex-1 overflow-hidden"> + <ErrorBoundary> + <EntryDetailPanel entryIdentifier={selectedEntryIdentifier} /> + </ErrorBoundary> + </div> + ) : ( + <div className="flex-1 overflow-hidden"> + <HighlightsList + groupedByEntry={groupedByEntry} + entryIdentifiers={entryIdentifiers} + selectedEntryIdentifier={null} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + lastElementReference={lastElementReference} + hasNextPage={hasNextPage ?? false} + isFetchingNextPage={isFetchingNextPage} + /> + </div> + ) + ) : ( + <Group orientation="horizontal" className="flex-1"> + <Panel defaultSize={selectedEntryIdentifier ? 40 : 100} minSize={25}> + <div data-panel-zone="entryList" className={classNames( + "h-full", + focusedPanel === "entryList" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <HighlightsList + groupedByEntry={groupedByEntry} + entryIdentifiers={entryIdentifiers} + selectedEntryIdentifier={selectedEntryIdentifier} + focusedEntryIdentifier={focusedEntryIdentifier} + viewMode="expanded" + onSelect={setSelectedEntryIdentifier} + lastElementReference={lastElementReference} + hasNextPage={hasNextPage ?? false} + isFetchingNextPage={isFetchingNextPage} + /> + </div> + </Panel> + {selectedEntryIdentifier && ( + <> + <Separator className="w-px bg-border transition-colors hover:bg-text-dim" /> + <Panel defaultSize={60} minSize={30}> + <div data-panel-zone="detailPanel" className={classNames( + "h-full", + focusedPanel === "detailPanel" ? "border-t-2 border-t-text-dim" : "border-t-2 border-t-transparent" + )}> + <ErrorBoundary> + <EntryDetailPanel entryIdentifier={selectedEntryIdentifier} /> + </ErrorBoundary> + </div> + </Panel> + </> + )} + </Group> + )} + </ErrorBoundary> + </div> + ) +} |