mirror of
https://github.com/hykilpikonna/AquaDX.git
synced 2026-03-21 17:34:28 -05:00
feat: ✨ Infinite scrolling on rankings page (#209)
This commit is contained in:
parent
fcdfeb25ea
commit
b487a756b2
Binary file not shown.
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>> =>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
96
AquaNet/src/pages/Ranking/Cap.svelte
Normal file
96
AquaNet/src/pages/Ranking/Cap.svelte
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user