From 4b1d1b1b6e40a2915c98b75b300bc51a104ea147 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 30 Jun 2026 20:05:17 +0200 Subject: [PATCH] feat: infinite scroll for folder view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace page buttons with IntersectionObserver sentinel β€” scrolling near the bottom automatically fetches and appends the next page of results. Shows a loading indicator while fetching and a total count footer when all emails are loaded. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/FolderView.jsx | 112 +++++++++++++++++++----------- frontend/src/styles.css | 6 +- 2 files changed, 73 insertions(+), 45 deletions(-) diff --git a/frontend/src/pages/FolderView.jsx b/frontend/src/pages/FolderView.jsx index 6ab6b4b..bfb072e 100644 --- a/frontend/src/pages/FolderView.jsx +++ b/frontend/src/pages/FolderView.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { SearchApi } from '../api/client.js'; @@ -50,73 +50,101 @@ export default function FolderView() { const { slug } = useParams(); const meta = FOLDER_META[slug] ?? { icon: 'πŸ“', label: slug }; - const [result, setResult] = useState(null); + const [emails, setEmails] = useState([]); + const [totalCount, setTotalCount] = useState(null); const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const load = useCallback((p) => { - setLoading(true); + // Reset when the folder changes + useEffect(() => { + setEmails([]); + setTotalCount(null); + setPage(1); + setHasMore(true); setError(null); + }, [slug]); + + // Fetch a page and append results + const fetchPage = useCallback((p) => { + setLoading(true); SearchApi.folder(slug, p, PAGE_SIZE) - .then((r) => { setResult(r); setPage(p); }) + .then((r) => { + setEmails((prev) => p === 1 ? r.items : [...prev, ...r.items]); + setTotalCount(r.totalCount); + setHasMore(p < r.totalPages); + setPage(p); + }) .catch(() => setError('Failed to load emails.')) .finally(() => setLoading(false)); }, [slug]); - useEffect(() => { load(1); }, [load]); + // Initial load + useEffect(() => { fetchPage(1); }, [fetchPage]); - const emails = result?.items ?? []; - const totalPages = result?.totalPages ?? 1; + // Sentinel div observed to trigger next page + const sentinelRef = useRef(null); + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !loading) { + fetchPage(page + 1); + } + }, + { rootMargin: '200px' } + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasMore, loading, page, fetchPage]); return (

{meta.icon} {meta.label}

- {result && ( - {result.totalCount.toLocaleString()} email{result.totalCount !== 1 ? 's' : ''} + {totalCount !== null && ( + {totalCount.toLocaleString()} email{totalCount !== 1 ? 's' : ''} )}
- {loading &&
Loading…
} {error &&
{error}
} {!loading && !error && emails.length === 0 && (
No emails in this folder.
)} - {!loading && emails.length > 0 && ( - <> - - - {emails.map((e) => ( - - - - - - - - ))} - -
{e.isUnread && } - {e.senderDisplayName || e.senderAddress} - - {e.subject || '(no subject)'} - {e.snippet && β€” {e.snippet}} - - {e.hasAttachments && πŸ“Ž} - {e.sizeEstimateBytes > 1048576 && {fmtSize(e.sizeEstimateBytes)}} - {fmtDate(e.sentAtUtc)}
+ {emails.length > 0 && ( + + + {emails.map((e) => ( + + + + + + + + ))} + +
{e.isUnread && } + {e.senderDisplayName || e.senderAddress} + + {e.subject || '(no subject)'} + {e.snippet && β€” {e.snippet}} + + {e.hasAttachments && πŸ“Ž} + {e.sizeEstimateBytes > 1048576 && {fmtSize(e.sizeEstimateBytes)}} + {fmtDate(e.sentAtUtc)}
+ )} - {totalPages > 1 && ( -
- - Page {page} of {totalPages} - -
- )} - + {/* Sentinel β€” triggers next page load when scrolled into view */} +
+ + {loading &&
Loading…
} + {!hasMore && emails.length > 0 && ( +
β€” {emails.length.toLocaleString()} emails β€”
)}
); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4983c98..a03a03a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -225,9 +225,9 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va .el-size { font-size: 11px; color: var(--muted); } .el-date { width: 70px; padding: 10px 0 10px 8px; text-align: right; color: var(--muted); white-space: nowrap; font-size: 12px; } -.fv-pagination { display: flex; align-items: center; gap: 14px; margin-top: 16px; font-size: 13px; color: var(--muted); } -.fv-pagination button { background: var(--panel); border: 1px solid #2c3550; color: var(--text); padding: 6px 12px; } -.fv-pagination button:disabled { opacity: 0.35; cursor: default; } +.fv-sentinel { height: 1px; } +.fv-loading-more { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; } +.fv-end { color: var(--muted); font-size: 12px; padding: 20px 0 8px; text-align: center; } /* ── Sync splash ── */ .splash {