summaryrefslogtreecommitdiff
path: root/apps/web/app/reader/_components/entry-list.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/_components/entry-list.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/_components/entry-list.tsx')
-rw-r--r--apps/web/app/reader/_components/entry-list.tsx217
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>
+ )
+}