added infinite scrolling to frontend and displaying vote information
This commit is contained in:
@@ -1,8 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { searchMapNames } from '@/lib/queries';
|
import { searchMapNames } from '@/lib/queries';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
|
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
|
||||||
const maps = await searchMapNames(q);
|
const offset = Math.max(0, Number(req.nextUrl.searchParams.get('offset')) || 0);
|
||||||
return NextResponse.json({ maps });
|
|
||||||
|
const maps = await searchMapNames(q, PAGE_SIZE, offset);
|
||||||
|
const hasMore = maps.length === PAGE_SIZE;
|
||||||
|
|
||||||
|
return NextResponse.json({ maps, hasMore });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ interface PlayerRow {
|
|||||||
last_connected: string | null;
|
last_connected: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
const TOTAL_MINUTES_SUBQUERY = `
|
const TOTAL_MINUTES_SUBQUERY = `
|
||||||
(SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes)
|
(SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes)
|
||||||
FROM playtime_display_sessions s
|
FROM playtime_display_sessions s
|
||||||
@@ -28,28 +30,31 @@ const LAST_CONNECTED_SUBQUERY = `
|
|||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
|
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
|
||||||
const sort = req.nextUrl.searchParams.get('sort') ?? 'recent';
|
const sort = req.nextUrl.searchParams.get('sort') ?? 'recent';
|
||||||
|
const offset = Math.max(0, Number(req.nextUrl.searchParams.get('offset')) || 0);
|
||||||
|
|
||||||
|
// Tie-break on steamid too — without a fully deterministic ORDER BY,
|
||||||
|
// paging with LIMIT/OFFSET can show duplicates or skip rows whenever two
|
||||||
|
// players tie on the primary sort key (e.g. same last_seen timestamp).
|
||||||
const orderBy =
|
const orderBy =
|
||||||
sort === 'playtime'
|
sort === 'playtime'
|
||||||
? 'total_minutes IS NULL, total_minutes DESC'
|
? 'total_minutes IS NULL, total_minutes DESC, p.steamid'
|
||||||
: sort === 'name'
|
: sort === 'name'
|
||||||
? 'p.current_name'
|
? 'p.current_name, p.steamid'
|
||||||
: 'p.last_seen DESC';
|
: 'p.last_seen DESC, p.steamid';
|
||||||
|
|
||||||
let rows: PlayerRow[];
|
let rows: PlayerRow[];
|
||||||
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
// No search term: just list players, ordered per the chosen sort.
|
|
||||||
rows = await query<PlayerRow>(
|
rows = await query<PlayerRow>(
|
||||||
`SELECT p.steamid, p.current_name, p.current_country, NULL AS matched_name,
|
`SELECT p.steamid, p.current_name, p.current_country, NULL AS matched_name,
|
||||||
${TOTAL_MINUTES_SUBQUERY},
|
${TOTAL_MINUTES_SUBQUERY},
|
||||||
${LAST_CONNECTED_SUBQUERY}
|
${LAST_CONNECTED_SUBQUERY}
|
||||||
FROM playtime_display_players p
|
FROM playtime_display_players p
|
||||||
ORDER BY ${orderBy}
|
ORDER BY ${orderBy}
|
||||||
LIMIT 50`,
|
LIMIT ? OFFSET ?`,
|
||||||
|
[PAGE_SIZE, offset],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Search matches steamid directly, or current/previous names.
|
|
||||||
const like = `%${q}%`;
|
const like = `%${q}%`;
|
||||||
rows = await query<PlayerRow>(
|
rows = await query<PlayerRow>(
|
||||||
`SELECT p.steamid, p.current_name, p.current_country, h.player_name AS matched_name,
|
`SELECT p.steamid, p.current_name, p.current_country, h.player_name AS matched_name,
|
||||||
@@ -66,11 +71,15 @@ export async function GET(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
GROUP BY p.steamid
|
GROUP BY p.steamid
|
||||||
ORDER BY ${orderBy}
|
ORDER BY ${orderBy}
|
||||||
LIMIT 50`,
|
LIMIT ? OFFSET ?`,
|
||||||
[like, like, like, like],
|
[like, like, like, like, PAGE_SIZE, offset],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const players = await attachAvatars(rows);
|
const players = await attachAvatars(rows);
|
||||||
return NextResponse.json({ players });
|
// A full page came back, so there might be more; a short page means we've
|
||||||
|
// hit the end. Simple and correct without needing a separate COUNT query.
|
||||||
|
const hasMore = rows.length === PAGE_SIZE;
|
||||||
|
|
||||||
|
return NextResponse.json({ players, hasMore });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,49 @@ export default async function MapPeriodDetailPage({
|
|||||||
const [players, votes] = await Promise.all([attachAvatars(playersRaw), attachAvatars(votesRaw)]);
|
const [players, votes] = await Promise.all([attachAvatars(playersRaw), attachAvatars(votesRaw)]);
|
||||||
|
|
||||||
// Group vote rows by their vote event (a map can theoretically have more
|
// Group vote rows by their vote event (a map can theoretically have more
|
||||||
// than one vote called during its runtime).
|
// than one vote called during its runtime), and tally each map's total
|
||||||
const voteEvents = new Map<number, { vote_time: string; result: string | null; choices: typeof votes }>();
|
// (weighted) votes within that event, descending. "Total votes cast" is
|
||||||
|
// computed from the same weighted sum as the tally below it, so the two
|
||||||
|
// numbers always agree — counting a weighted vote as 1 instead of its
|
||||||
|
// actual weight is exactly what caused them to disagree before.
|
||||||
|
interface VoteTallyRow {
|
||||||
|
map: string;
|
||||||
|
weightedVotes: number;
|
||||||
|
voterCount: number;
|
||||||
|
}
|
||||||
|
const voteEvents = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
vote_time: string;
|
||||||
|
result: string | null;
|
||||||
|
choices: typeof votes;
|
||||||
|
tally: VoteTallyRow[];
|
||||||
|
totalWeightedVotes: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
for (const v of votes) {
|
for (const v of votes) {
|
||||||
if (!voteEvents.has(v.vote_id)) {
|
if (!voteEvents.has(v.vote_id)) {
|
||||||
voteEvents.set(v.vote_id, { vote_time: v.vote_time, result: v.result, choices: [] });
|
voteEvents.set(v.vote_id, {
|
||||||
|
vote_time: v.vote_time,
|
||||||
|
result: v.result,
|
||||||
|
choices: [],
|
||||||
|
tally: [],
|
||||||
|
totalWeightedVotes: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
voteEvents.get(v.vote_id)!.choices.push(v);
|
voteEvents.get(v.vote_id)!.choices.push(v);
|
||||||
}
|
}
|
||||||
|
for (const event of voteEvents.values()) {
|
||||||
|
const byMap = new Map<string, VoteTallyRow>();
|
||||||
|
for (const c of event.choices) {
|
||||||
|
const row = byMap.get(c.vote_choice) ?? { map: c.vote_choice, weightedVotes: 0, voterCount: 0 };
|
||||||
|
row.weightedVotes += c.vote_weight;
|
||||||
|
row.voterCount += 1;
|
||||||
|
byMap.set(c.vote_choice, row);
|
||||||
|
}
|
||||||
|
event.tally = [...byMap.values()].sort((a, b) => b.weightedVotes - a.weightedVotes);
|
||||||
|
event.totalWeightedVotes = event.tally.reduce((sum, row) => sum + row.weightedVotes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -104,7 +139,7 @@ export default async function MapPeriodDetailPage({
|
|||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="panel p-6">
|
<div className="panel p-6">
|
||||||
<div className="stat-label mb-3">
|
<div className="stat-label mb-3">
|
||||||
Players present ({players.length})
|
Connected during this session ({players.length})
|
||||||
</div>
|
</div>
|
||||||
{players.length === 0 ? (
|
{players.length === 0 ? (
|
||||||
<div className="text-sm text-ink-muted">No recorded players during this period.</div>
|
<div className="text-sm text-ink-muted">No recorded players during this period.</div>
|
||||||
@@ -150,9 +185,39 @@ export default async function MapPeriodDetailPage({
|
|||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-xs text-ink-faint mono">
|
<div className="text-xs text-ink-faint mono">
|
||||||
{new Date(event.vote_time.replace(' ', 'T')).toLocaleString()}
|
{new Date(event.vote_time.replace(' ', 'T')).toLocaleString()}
|
||||||
|
{' · '}
|
||||||
|
{event.totalWeightedVotes} vote{event.totalWeightedVotes === 1 ? '' : 's'} cast
|
||||||
|
{event.totalWeightedVotes !== event.choices.length && (
|
||||||
|
<span className="text-ink-faint"> ({event.choices.length} voter{event.choices.length === 1 ? '' : 's'})</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-accent">Result: {event.result ?? '—'}</div>
|
<div className="text-sm text-accent">Result: {event.result ?? '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Per-map tally, highest first */}
|
||||||
|
<div className="space-y-1 mb-4">
|
||||||
|
{event.tally.map((t) => {
|
||||||
|
const pct = event.totalWeightedVotes > 0 ? (t.weightedVotes / event.tally[0].weightedVotes) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={t.map} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-ink w-40 truncate shrink-0">{t.map}</span>
|
||||||
|
<div className="flex-1 h-3 bg-base rounded overflow-hidden border border-base-border">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-sm"
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(pct, 3)}%`,
|
||||||
|
background: 'linear-gradient(90deg, #2A9C5C, #5EE596)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-ink-muted mono w-20 text-right shrink-0">
|
||||||
|
{t.weightedVotes} vote{t.weightedVotes === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="row-divide">
|
<div className="row-divide">
|
||||||
{event.choices.map((c, i) => (
|
{event.choices.map((c, i) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { formatMinutes } from '@/lib/steam';
|
import { formatMinutes } from '@/lib/steam';
|
||||||
|
|
||||||
@@ -15,19 +15,59 @@ export default function MapSearch() {
|
|||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [maps, setMaps] = useState<MapSummary[]>([]);
|
const [maps, setMaps] = useState<MapSummary[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const loadingMoreRef = useRef(false);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
async function fetchPage(searchQ: string, offset: number) {
|
||||||
|
const params = new URLSearchParams({ q: searchQ, offset: String(offset) });
|
||||||
|
const res = await fetch(`/api/maps?${params.toString()}`);
|
||||||
|
return res.json() as Promise<{ maps: MapSummary[]; hasMore: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle = setTimeout(async () => {
|
const handle = setTimeout(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await fetch(`/api/maps?q=${encodeURIComponent(q)}`);
|
offsetRef.current = 0;
|
||||||
const data = await res.json();
|
const data = await fetchPage(q, 0);
|
||||||
setMaps(data.maps);
|
setMaps(data.maps);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
offsetRef.current = data.maps.length;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
return () => clearTimeout(handle);
|
return () => clearTimeout(handle);
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loadingMoreRef.current || !hasMore || loading) return;
|
||||||
|
loadingMoreRef.current = true;
|
||||||
|
setLoadingMore(true);
|
||||||
|
const data = await fetchPage(q, offsetRef.current);
|
||||||
|
setMaps((prev) => [...prev, ...data.maps]);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
offsetRef.current += data.maps.length;
|
||||||
|
setLoadingMore(false);
|
||||||
|
loadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) loadMore();
|
||||||
|
},
|
||||||
|
{ rootMargin: '400px' },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasMore, loading, q]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<input
|
<input
|
||||||
@@ -71,6 +111,16 @@ export default function MapSearch() {
|
|||||||
</Link>
|
</Link>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loading && maps.length > 0 && (
|
||||||
|
<div ref={sentinelRef} className="p-4 text-center">
|
||||||
|
{loadingMore ? (
|
||||||
|
<span className="text-xs text-ink-faint font-mono">loading more…</span>
|
||||||
|
) : !hasMore ? (
|
||||||
|
<span className="text-xs text-ink-faint">— end of list —</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { countryCodeToFlag, formatMinutes } from '@/lib/steam';
|
import { countryCodeToFlag, formatMinutes } from '@/lib/steam';
|
||||||
import { formatRelativeTime } from '@/lib/dates';
|
import { formatRelativeTime } from '@/lib/dates';
|
||||||
@@ -20,20 +20,62 @@ export default function PlayerSearch() {
|
|||||||
const [sort, setSort] = useState<'recent' | 'name' | 'playtime'>('recent');
|
const [sort, setSort] = useState<'recent' | 'name' | 'playtime'>('recent');
|
||||||
const [players, setPlayers] = useState<Player[]>([]);
|
const [players, setPlayers] = useState<Player[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const loadingMoreRef = useRef(false); // guards against the observer firing twice for one fetch
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
async function fetchPage(searchQ: string, searchSort: string, offset: number) {
|
||||||
|
const params = new URLSearchParams({ q: searchQ, sort: searchSort, offset: String(offset) });
|
||||||
|
const res = await fetch(`/api/players?${params.toString()}`);
|
||||||
|
return res.json() as Promise<{ players: Player[]; hasMore: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search or sort changed: reset and load page 1 fresh, replacing the list.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle = setTimeout(async () => {
|
const handle = setTimeout(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const params = new URLSearchParams({ q, sort });
|
offsetRef.current = 0;
|
||||||
const res = await fetch(`/api/players?${params.toString()}`);
|
const data = await fetchPage(q, sort, 0);
|
||||||
const data = await res.json();
|
|
||||||
setPlayers(data.players);
|
setPlayers(data.players);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
offsetRef.current = data.players.length;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, 250); // debounce so we're not hitting the DB on every keystroke
|
}, 250); // debounce so we're not hitting the DB on every keystroke
|
||||||
|
|
||||||
return () => clearTimeout(handle);
|
return () => clearTimeout(handle);
|
||||||
}, [q, sort]);
|
}, [q, sort]);
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loadingMoreRef.current || !hasMore || loading) return;
|
||||||
|
loadingMoreRef.current = true;
|
||||||
|
setLoadingMore(true);
|
||||||
|
const data = await fetchPage(q, sort, offsetRef.current);
|
||||||
|
setPlayers((prev) => [...prev, ...data.players]);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
offsetRef.current += data.players.length;
|
||||||
|
setLoadingMore(false);
|
||||||
|
loadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the next page once the sentinel at the bottom of the list scrolls
|
||||||
|
// into view, rather than waiting for an explicit "load more" click.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) loadMore();
|
||||||
|
},
|
||||||
|
{ rootMargin: '400px' }, // start fetching a bit before it's actually visible
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasMore, loading, q, sort]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -94,6 +136,17 @@ export default function PlayerSearch() {
|
|||||||
</Link>
|
</Link>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sentinel — fetches the next page once this scrolls into view */}
|
||||||
|
{!loading && players.length > 0 && (
|
||||||
|
<div ref={sentinelRef} className="p-4 text-center">
|
||||||
|
{loadingMore ? (
|
||||||
|
<span className="text-xs text-ink-faint font-mono">loading more…</span>
|
||||||
|
) : !hasMore ? (
|
||||||
|
<span className="text-xs text-ink-faint">— end of list —</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export interface MapVoteRow {
|
|||||||
vote_weight: number;
|
vote_weight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchMapNames(q: string, limit = 50): Promise<MapSummary[]> {
|
export async function searchMapNames(q: string, limit = 50, offset = 0): Promise<MapSummary[]> {
|
||||||
const like = `%${q}%`;
|
const like = `%${q}%`;
|
||||||
return query<MapSummary>(
|
return query<MapSummary>(
|
||||||
`SELECT m.map_name, COUNT(*) AS times_played, MAX(m.map_start_dt) AS last_played,
|
`SELECT m.map_name, COUNT(*) AS times_played, MAX(m.map_start_dt) AS last_played,
|
||||||
@@ -249,8 +249,8 @@ export async function searchMapNames(q: string, limit = 50): Promise<MapSummary[
|
|||||||
AND ${REAL_MAP_PERIOD}
|
AND ${REAL_MAP_PERIOD}
|
||||||
GROUP BY m.map_name
|
GROUP BY m.map_name
|
||||||
ORDER BY last_played DESC
|
ORDER BY last_played DESC
|
||||||
LIMIT ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
[like, limit],
|
[like, limit, offset],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `onIntersect` when the returned sentinel ref becomes visible near
|
||||||
|
* the bottom of the viewport — the standard "load more as you scroll"
|
||||||
|
* pattern, via IntersectionObserver rather than a scroll listener (cheaper,
|
||||||
|
* no manual throttling needed).
|
||||||
|
*/
|
||||||
|
export function useInfiniteScroll(onIntersect: () => void, enabled: boolean) {
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) onIntersect();
|
||||||
|
},
|
||||||
|
{ rootMargin: '400px' }, // start loading a bit before the sentinel is actually on screen
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [enabled, onIntersect]);
|
||||||
|
|
||||||
|
return sentinelRef;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user