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 {