From d211c39ac0a44fb20f89c3ae9a8fa4a07397a1cd Mon Sep 17 00:00:00 2001 From: pitoi Date: Fri, 22 May 2026 16:05:04 +0000 Subject: [PATCH] Generated with Hive: Implement infinite scroll pagination for My Content Added tab --- src/components/layout/my-content-panel.tsx | 56 +++++++++++++++++++--- src/lib/__tests__/my-content-page.test.tsx | 6 +-- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/components/layout/my-content-panel.tsx b/src/components/layout/my-content-panel.tsx index f0328d7..d6b8b0c 100644 --- a/src/components/layout/my-content-panel.tsx +++ b/src/components/layout/my-content-panel.tsx @@ -27,6 +27,7 @@ import type { CreatorInsightsResponse, NodeInsight } from "@/lib/creator-insight import type { GraphNode } from "@/lib/graph-api" const POLL_INTERVAL_MS = 5000 +const PAGE_SIZE = 50 function sameContent(a: GraphNode[], b: GraphNode[]): boolean { if (a === b) return true @@ -59,6 +60,10 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) { const [deletingId, setDeletingId] = useState(null) const [deleteError, setDeleteError] = useState(null) + const [hasMore, setHasMore] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const sentinelRef = useRef(null) + const [activeTab, setActiveTab] = useState<'added' | 'purchased'>('added') const [purchasedNodes, setPurchasedNodes] = useState([]) const [purchasedLoading, setPurchasedLoading] = useState(false) @@ -70,17 +75,17 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) { const [isSocketConnected, setIsSocketConnected] = useState(false) const pollTimerRef = useRef | null>(null) - const fetchFromApi = useCallback(async (): Promise => { + const fetchFromApi = useCallback(async (limit: number, skip: number): Promise => { // Sphinx path: identity is derived from the auto-attached sig+msg by the // api wrapper; boltwall verifies and stamps X-Caller-Pubkey downstream. // The client never sends pubkey — that prevents enumerating other users. if (pubKey) { - return api.get(`/v2/content?sort_by=date&limit=100`) + return api.get(`/v2/content?sort_by=date&limit=${limit}&skip=${skip}`) } // L402 path — api wrapper auto-attaches Authorization header const l402 = await getL402() if (l402) { - return api.get(`/v2/content?sort_by=date&limit=100`) + return api.get(`/v2/content?sort_by=date&limit=${limit}&skip=${skip}`) } // No identity — return empty payload; panel renders empty state return { nodes: [], totalCount: 0, totalProcessing: 0 } @@ -101,15 +106,19 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) { const run = async () => { setLoading(true) + setHasMore(true) try { if (mocksEnabled) { if (cancelled) return setNodes(MOCK_CONTENT.nodes as GraphNode[]) setTotalProcessing(MOCK_CONTENT.totalProcessing) + setHasMore(false) } else { - const res = await fetchFromApi() + const res = await fetchFromApi(PAGE_SIZE, 0) if (cancelled) return applyResponse(res) + const next = res?.nodes ?? [] + setHasMore(next.length === PAGE_SIZE) } } catch { if (!cancelled) setNodes([]) @@ -125,6 +134,34 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) { } }, [mocksEnabled, fetchFromApi, applyResponse, myContentRefreshKey]) + const loadMore = useCallback(async () => { + if (loadingMore || !hasMore) return + setLoadingMore(true) + try { + const res = await fetchFromApi(PAGE_SIZE, nodes.length) + if (res) { + const next = res.nodes ?? [] + setNodes((prev) => [...prev, ...next]) + setHasMore(next.length === PAGE_SIZE) + } + } catch { + // leave existing state + } finally { + setLoadingMore(false) + } + }, [loadingMore, hasMore, nodes.length, fetchFromApi]) + + useEffect(() => { + const el = sentinelRef.current + if (!el || !hasMore || loadingMore) return + const observer = new IntersectionObserver( + ([entry]) => { if (entry.isIntersecting) loadMore() }, + { threshold: 0.1 } + ) + observer.observe(el) + return () => observer.disconnect() + }, [hasMore, loadingMore, loadMore]) + // Fetch purchased nodes when tab switches to 'purchased' useEffect(() => { if (activeTab !== 'purchased') return @@ -228,7 +265,7 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) { pollTimerRef.current = setInterval(async () => { try { - applyResponse(await fetchFromApi()) + applyResponse(await fetchFromApi(Math.max(nodes.length, PAGE_SIZE), 0)) } catch { // Leave existing state; next tick will retry. } @@ -240,7 +277,7 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) { pollTimerRef.current = null } } - }, [hasInProgress, mocksEnabled, hasIdentity, isSocketConnected, fetchFromApi, applyResponse]) + }, [hasInProgress, mocksEnabled, hasIdentity, isSocketConnected, fetchFromApi, applyResponse, nodes.length]) return (
@@ -259,7 +296,7 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) { {!loading && activeTab === 'added' && (

- {nodes.length} item{nodes.length !== 1 ? "s" : ""} + {nodes.length}{hasMore ? '+' : ''} item{nodes.length !== 1 ? "s" : ""}

)}
@@ -419,6 +456,11 @@ export function MyContentPanel({ onClose }: { onClose: () => void }) { ) })} + {hasMore && ( +
+ {loadingMore && } +
+ )} )} diff --git a/src/lib/__tests__/my-content-page.test.tsx b/src/lib/__tests__/my-content-page.test.tsx index 7179e19..6231129 100644 --- a/src/lib/__tests__/my-content-page.test.tsx +++ b/src/lib/__tests__/my-content-page.test.tsx @@ -187,7 +187,7 @@ describe("MyContentPanel", () => { }) expect(mockApiGet).toHaveBeenCalledWith( - "/v2/content?sort_by=date&limit=100" + "/v2/content?sort_by=date&limit=50&skip=0" ) }) @@ -491,7 +491,7 @@ describe("MyContentPanel – L402 identity paths", () => { }) expect(mockApiGet).toHaveBeenCalledWith( - "/v2/content?sort_by=date&limit=100" + "/v2/content?sort_by=date&limit=50&skip=0" ) // Must NOT include a pubkey param — boltwall derives identity from sig expect(mockApiGet).not.toHaveBeenCalledWith( @@ -510,7 +510,7 @@ describe("MyContentPanel – L402 identity paths", () => { }) expect(mockApiGet).toHaveBeenCalledWith( - "/v2/content?sort_by=date&limit=100" + "/v2/content?sort_by=date&limit=50&skip=0" ) // Must NOT include a pubkey param expect(mockApiGet).not.toHaveBeenCalledWith(