feat: Infinite scrolling on rankings page (#209)

This commit is contained in:
May 2026-03-04 13:41:36 -08:00 committed by GitHub
parent fcdfeb25ea
commit b487a756b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 117 additions and 44 deletions

Binary file not shown.

View File

@ -69,6 +69,7 @@ export const EN_REF_LEADERBOARD = {
'Leaderboard.Accuracy': 'Accuracy',
'Leaderboard.FC': 'FC',
'Leaderboard.AP': 'AP',
'Leaderboard.Loading': "Loading..."
}
export const EN_REF_GENERAL = {

View File

@ -81,6 +81,7 @@ const zhLeaderboard: typeof EN_REF_LEADERBOARD = {
'Leaderboard.Accuracy': '准确率',
'Leaderboard.FC': 'FC',
'Leaderboard.AP': 'AP',
'Leaderboard.Loading': '请等一下。'
}
const zhGeneral: typeof EN_REF_GENERAL = {

View File

@ -227,8 +227,8 @@ export const GAME = {
post(`/api/v2/game/mai2/my-photo`, { }),
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
post(`/api/v2/game/${game}/user-summary`, { username }),
ranking: (game: GameName): Promise<GenericRanking[]> =>
post(`/api/v2/game/${game}/ranking`, { }),
ranking: (game: GameName, page?: number): Promise<GenericRanking[]> =>
post(`/api/v2/game/${game}/ranking`, typeof page === "number" ? { page } : {}),
changeName: (game: GameName, newName: string): Promise<{ newName: string }> =>
post(`/api/v2/game/${game}/change-name`, { newName }),
export: (game: GameName): Promise<Record<string, any>> =>

View File

@ -9,53 +9,32 @@
import { t } from "../libs/i18n";
import UserCard from "../components/UserCard.svelte";
import Tooltip from "../components/Tooltip.svelte";
import Pagination from "../components/Pagination.svelte";
import Cap from "./Ranking/Cap.svelte";
export let game: GameName = 'mai2';
title(`Ranking`);
let d: { users: GenericRanking[] };
let loadedPages: GenericRanking[][];
let error: string | null;
let page = 1
const perPage = 50
let totalPages = 1
function handleUpdatePage(event: CustomEvent<number>) {
page = event.detail;
const url = new URL(window.location.toString())
url.searchParams.set('page', page.toString())
history.pushState({}, '', url.toString())
window.scrollTo(0, 0)
}
let earliestPage = 0
//const perPage = 50
onMount(() => {
const url = new URL(window.location.toString())
const pageParam = url.searchParams.get('page')
if (pageParam) {
page = parseInt(pageParam, 10) || 1
}
window.addEventListener('popstate', () => {
const url = new URL(window.location.toString())
const pageParam = url.searchParams.get('page')
page = parseInt(pageParam, 10) || 1
window.scrollTo(0, 0)
})
if (pageParam)
earliestPage = parseInt(pageParam, 10) || 0
Promise.all([GAME.ranking(game, earliestPage)])
.then(([users]) => {
loadedPages = [ users ]
})
.catch((e) => error = e.message);
})
Promise.all([GAME.ranking(game)])
.then(([users]) => {
d = { users }
totalPages = Math.ceil(users.length / perPage)
})
.catch((e) => error = e.message);
let hoveringUser = "";
let hoverLoading = false;
$: paginatedUsers = d ? d.users.slice((page - 1) * perPage, page * perPage) : []
</script>
<main class="content leaderboard">
@ -68,13 +47,9 @@
</nav>
</div>
{#if d}
{#if page > 1}
<Pagination {page} {totalPages} on:updatePage={handleUpdatePage} />
{/if}
{#if loadedPages}
<div class="leaderboard-container">
<div class="lb-user" on:mouseenter={() => hoveringUser = paginatedUsers[0]?.username} role="heading" aria-level="2">
<div class="lb-user" on:mouseenter={() => hoveringUser = ""} role="heading" aria-level="2">
<span class="rank">{t("Leaderboard.Rank")}</span>
<span class="name"></span>
<span class="rating">{t("Leaderboard.Rating")}</span>
@ -82,7 +57,8 @@
<span class="fc">{t("Leaderboard.FC")}</span>
<span class="ap">{t("Leaderboard.AP")}</span>
</div>
{#each paginatedUsers as user, i (user.rank)}
<Cap bind:loadedPages bind:earliestPage {game} addOffset={-1} />
{#each loadedPages.flat() as user, i (user.rank)}
<div class="lb-user" class:alternate={i % 2 === 1} role="listitem"
on:mouseover={() => hoveringUser = user.username} on:focus={() => {}}>
@ -104,16 +80,15 @@
<span class="ap">{user.allPerfect}</span>
</div>
{/each}
<Cap bind:loadedPages bind:earliestPage {game} addOffset={1} />
</div>
<Pagination {page} {totalPages} on:updatePage={handleUpdatePage} />
<Tooltip triggeredBy=".name" loading={hoverLoading}>
<UserCard username={hoveringUser} {game} setLoading={l => hoverLoading = l} />
</Tooltip>
{/if}
<StatusOverlays error={error} loading={!d} />
<StatusOverlays error={error} loading={!loadedPages} />
</main>
<style lang="sass">

View File

@ -0,0 +1,96 @@
<script lang="ts">
/* Most of this has been backported from AquaNet2.
import { browser } from "$app/environment"
import { page } from "$app/state"
import type { RankedUser } from "$lib/api/game"
import { LEADERBOARD_PAGE_LENGTH } from "$lib/app/formatting"
import LL from "$lib/i18n/i18n-svelte"
*/
import { onMount, tick } from "svelte"
import type { GenericRanking } from "../../libs/generalTypes";
import type { GameName } from "../../libs/scoring";
import { t } from "../../libs/i18n";
import { GAME } from "../../libs/sdk";
const LEADERBOARD_PAGE_LENGTH = 100
interface Props {
loadedPages: GenericRanking[][],
/**
* @description Earliest page to load
*/
earliestPage: number,
addOffset: -1 | 1,
game: GameName
}
let { loadedPages = $bindable([]), earliestPage = $bindable(), addOffset, game } = $props()
const pageMayBeAvailable = $derived(
(earliestPage + addOffset >= 0) // If we go to this page, is it geq 0?
&& (
addOffset < 0 // If our goal is to go backwards, skip this step.
|| loadedPages[loadedPages.length-1].length === LEADERBOARD_PAGE_LENGTH // Is this page long enough to warrant trying?
)
)
const nextPage = $derived(
addOffset > 0
? earliestPage+loadedPages.length-1+addOffset
: earliestPage+addOffset
)
const isEnd = $derived(loadedPages[loadedPages.length-1].length !== LEADERBOARD_PAGE_LENGTH && addOffset > 0);
const mainElement = document.body // !!! UPDATE
let cap: HTMLParagraphElement | undefined = $state()
let loading = false
async function loadNew(entries: IntersectionObserverEntry[]) {
if (entries.some(e => !e.isIntersecting) || loading) return
loading = true;
// fetch a new page
let newPage = await GAME.ranking(game, nextPage)
// add the new page to loadedPages
loadedPages.splice(((addOffset + 1) / 2) * loadedPages.length, 0, newPage)
loadedPages = loadedPages // smoking that Svelte Legacy Mode pack
// update offset if need be
if (addOffset < 0) {
earliestPage += addOffset
// try not to jump up if loading backward
let lastDocumentSz = mainElement!.scrollHeight
let lastScrollY = window.scrollY
tick().then(() => {
window.scrollTo({ behavior: "instant", top: lastScrollY + mainElement!.scrollHeight - lastDocumentSz})
loading = false;
})
} else loading = false;
}
onMount(() => {
// rootMargin is 500px so we have a better infinite scroll type effect
const observer = new IntersectionObserver(loadNew, {rootMargin: "500px 0px 500px 0px"})
$effect(() => {
if (cap)
observer.observe(cap)
})
return () => observer.disconnect()
})
</script>
{#if pageMayBeAvailable}
<div class="cap">
<p bind:this={cap}>{t("Leaderboard.Loading")}</p>
</div>
{/if}
<style lang="scss">
.cap {
padding: .5em;
font-weight: 500;
text-align: center;
}
</style>