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/_components/entry-list.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/_components/entry-list.tsx')
| -rw-r--r-- | apps/web/app/reader/_components/entry-list.tsx | 217 |
1 files changed, 217 insertions, 0 deletions
diff --git a/apps/web/app/reader/_components/entry-list.tsx b/apps/web/app/reader/_components/entry-list.tsx new file mode 100644 index 0000000..6d4bcf3 --- /dev/null +++ b/apps/web/app/reader/_components/entry-list.tsx @@ -0,0 +1,217 @@ +"use client" + +import { useRef, useEffect } from "react" +import { useVirtualizer } from "@tanstack/react-virtual" +import { useTimeline } from "@/lib/queries/use-timeline" +import { useSavedEntries } from "@/lib/queries/use-saved-entries" +import { useCustomFeedTimeline } from "@/lib/queries/use-custom-feed-timeline" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { EntryListItem } from "./entry-list-item" + +interface EntryListProperties { + feedFilter: "all" | "saved" + folderIdentifier?: string | null + feedIdentifier?: string | null + customFeedIdentifier?: string | null +} + +function useEntryData( + feedFilter: "all" | "saved", + folderIdentifier?: string | null, + feedIdentifier?: string | null, + customFeedIdentifier?: string | null +) { + const timelineQuery = useTimeline( + feedFilter === "all" && !customFeedIdentifier ? folderIdentifier : undefined, + feedFilter === "all" && !customFeedIdentifier ? feedIdentifier : undefined, + false + ) + const savedQuery = useSavedEntries() + const customFeedQuery = useCustomFeedTimeline( + feedFilter === "all" ? (customFeedIdentifier ?? null) : null + ) + + if (feedFilter === "saved") { + return savedQuery + } + + if (customFeedIdentifier) { + return customFeedQuery + } + + return timelineQuery +} + +export function EntryList({ + feedFilter, + folderIdentifier, + feedIdentifier, + customFeedIdentifier, +}: EntryListProperties) { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useEntryData(feedFilter, folderIdentifier, feedIdentifier, customFeedIdentifier) + + const entryListViewMode = useUserInterfaceStore( + (state) => state.entryListViewMode + ) + const selectedEntryIdentifier = useUserInterfaceStore( + (state) => state.selectedEntryIdentifier + ) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + const focusedEntryIdentifier = useUserInterfaceStore( + (state) => state.focusedEntryIdentifier + ) + const setFocusedEntryIdentifier = useUserInterfaceStore( + (state) => state.setFocusedEntryIdentifier + ) + + const setNavigableEntryIdentifiers = useUserInterfaceStore( + (state) => state.setNavigableEntryIdentifiers + ) + + const allEntries = data?.pages.flatMap((page) => page) ?? [] + const scrollContainerReference = useRef<HTMLDivElement>(null) + + const firstEntryIdentifier = allEntries[0]?.entryIdentifier + const lastEntryIdentifier = allEntries[allEntries.length - 1]?.entryIdentifier + + useEffect(() => { + setNavigableEntryIdentifiers( + allEntries.map((entry) => entry.entryIdentifier) + ) + }, [firstEntryIdentifier, lastEntryIdentifier, allEntries.length, setNavigableEntryIdentifiers]) + + function getEstimatedItemSize() { + switch (entryListViewMode) { + case "compact": + return 40 + case "comfortable": + return 60 + case "expanded": + return 108 + } + } + + const virtualizer = useVirtualizer({ + count: hasNextPage ? allEntries.length + 1 : allEntries.length, + getScrollElement: () => scrollContainerReference.current, + estimateSize: getEstimatedItemSize, + overscan: 10, + }) + + const virtualItems = virtualizer.getVirtualItems() + + useEffect(() => { + const lastItem = virtualItems[virtualItems.length - 1] + + if (!lastItem) return + + if ( + lastItem.index >= allEntries.length - 1 && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage() + } + }, [ + virtualItems, + allEntries.length, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + ]) + + const allEntriesReference = useRef(allEntries) + allEntriesReference.current = allEntries + + useEffect(() => { + if (!focusedEntryIdentifier) return + + const focusedIndex = allEntriesReference.current.findIndex( + (entry) => entry.entryIdentifier === focusedEntryIdentifier + ) + + if (focusedIndex !== -1) { + virtualizer.scrollToIndex(focusedIndex, { align: "auto" }) + } + }, [focusedEntryIdentifier]) + + if (isLoading) { + return ( + <div className="space-y-2 p-4"> + {Array.from({ length: 8 }).map((_, skeletonIndex) => ( + <div + key={skeletonIndex} + className="h-10 animate-[skeleton-shimmer_1.5s_ease-in-out_infinite] bg-background-tertiary" + /> + ))} + </div> + ) + } + + if (allEntries.length === 0) { + return ( + <div className="flex h-full items-center justify-center"> + <p className="text-text-tertiary"> + {feedFilter === "saved" + ? "no saved entries yet" + : "no entries yet \u2014 add a feed to get started"} + </p> + </div> + ) + } + + return ( + <div ref={scrollContainerReference} className="h-full overflow-auto"> + <div + style={{ + height: `${virtualizer.getTotalSize()}px`, + width: "100%", + position: "relative", + }} + > + {virtualItems.map((virtualItem) => { + const entry = allEntries[virtualItem.index] + + if (!entry) { + return ( + <div + key="loader" + data-index={virtualItem.index} + ref={virtualizer.measureElement} + className="absolute left-0 top-0 w-full" + style={{ + transform: `translateY(${virtualItem.start}px)`, + }} + > + <p className="p-4 text-center text-text-dim">loading ...</p> + </div> + ) + } + + return ( + <EntryListItem + key={entry.entryIdentifier} + entry={entry} + isSelected={ + entry.entryIdentifier === selectedEntryIdentifier + } + isFocused={ + entry.entryIdentifier === focusedEntryIdentifier + } + viewMode={entryListViewMode} + onSelect={() => { + setFocusedEntryIdentifier(entry.entryIdentifier) + setSelectedEntryIdentifier(entry.entryIdentifier) + }} + measureReference={virtualizer.measureElement} + virtualItem={virtualItem} + /> + ) + })} + </div> + </div> + ) +} |