summaryrefslogtreecommitdiff
path: root/apps/web/app/reader/highlights/_components/highlights-content.tsx
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 01:42:57 -0800
committerFuwn <[email protected]>2026-02-07 01:42:57 -0800
commit5c5b1993edd890a80870ee05607ac5f088191d4e (patch)
treea721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/reader/highlights/_components/highlights-content.tsx
downloadasa.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.tsx452
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 &ldquo;highlight&rdquo; 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>&middot;</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">&nbsp;</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"
+ >
+ &larr; 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>
+ )
+}