[+] Add pagination to maimai photo gallery (#244)

This commit is contained in:
Azalea
2026-09-16 09:35:07 +08:00
committed by GitHub
parent 11c5617abc
commit 97743f55ef
4 changed files with 149 additions and 32 deletions
+3 -2
View File
@@ -14,7 +14,8 @@
} }
function updatePage(newPage: number) { function updatePage(newPage: number) {
if (newPage > 0 && newPage <= totalPages) dispatch('updatePage', newPage) const p = Math.floor(Number(newPage))
if (!isNaN(p) && p > 0 && p <= totalPages) dispatch('updatePage', p)
} }
function startEditing() { function startEditing() {
@@ -37,7 +38,7 @@
<button on:click={() => updatePage(page - 1)} disabled={page <= 1}>Previous</button> <button on:click={() => updatePage(page - 1)} disabled={page <= 1}>Previous</button>
{#if editing} {#if editing}
<input bind:value={inputPage} on:blur={finishEditing} on:keydown={handleKeydown} min="1" max={totalPages} use:focus/> <input type="number" bind:value={inputPage} on:blur={finishEditing} on:keydown={handleKeydown} min="1" max={totalPages} use:focus/>
{:else} {:else}
<span on:click={startEditing} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && startEditing()}> <span on:click={startEditing} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && startEditing()}>
Page {page} of {totalPages} Page {page} of {totalPages}
+13 -2
View File
@@ -16,6 +16,14 @@ import type { GameName } from './scoring'
export type ExportGameName = GameName | 'diva' export type ExportGameName = GameName | 'diva'
export interface PhotoPage {
photos: string[]
page: number
pageSize: number
total: number
totalPages: number
}
interface ExtReqInit extends RequestInit { interface ExtReqInit extends RequestInit {
params?: { [index: string]: string } params?: { [index: string]: string }
json?: any json?: any
@@ -28,7 +36,8 @@ interface ExtReqInit extends RequestInit {
* @param callback Callback for modification * @param callback Callback for modification
*/ */
export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) => URL | void): RequestInfo | URL { export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) => URL | void): RequestInfo | URL {
let u = new URL((input instanceof Request) ? input.url : input) const base = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
let u = new URL((input instanceof Request) ? input.url : input, base)
const result = callback(u) const result = callback(u)
if (result) u = result if (result) u = result
if (input instanceof Request) { if (input instanceof Request) {
@@ -232,7 +241,9 @@ export const CARD = {
export const GAME = { export const GAME = {
trend: (username: string, game: GameName): Promise<TrendEntry[]> => trend: (username: string, game: GameName): Promise<TrendEntry[]> =>
post(`/api/v2/game/${game}/trend`, { username }), post(`/api/v2/game/${game}/trend`, { username }),
photos: (): Promise<string[]> => photos: (page: number = 1, size: number = 12): Promise<PhotoPage> =>
post(`/api/v2/game/mai2/my-photo`, { page, size }),
allPhotos: (): Promise<string[]> =>
post(`/api/v2/game/mai2/my-photo`, { }), post(`/api/v2/game/mai2/my-photo`, { }),
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> => userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
post(`/api/v2/game/${game}/user-summary`, { username }), post(`/api/v2/game/${game}/user-summary`, { username }),
+100 -18
View File
@@ -1,11 +1,80 @@
<script lang="ts"> <script lang="ts">
import {GAME} from "../libs/sdk"; import { onMount } from "svelte";
import {AQUA_HOST} from "../libs/config"; import { GAME } from "../libs/sdk";
import type { PhotoPage } from "../libs/sdk";
import { AQUA_HOST } from "../libs/config";
import Loading from "../components/ui/Loading.svelte"; import Loading from "../components/ui/Loading.svelte";
import Error from "../components/ui/Error.svelte"; import Error from "../components/ui/Error.svelte";
import Pagination from "../components/Pagination.svelte";
import { t } from "../libs/i18n"; import { t } from "../libs/i18n";
const token = localStorage.getItem("token") let page = 1;
const pageSize = 12;
let photoData: PhotoPage | null = null;
let loading = true;
let error: any = null;
// Monotonic token so a slow response can never overwrite a newer one
let requestSeq = 0;
function readPageFromUrl(): number {
const raw = new URL(window.location.toString()).searchParams.get("page");
return raw ? parseInt(raw, 10) || 1 : 1;
}
/**
* Keep the url in sync with the page the server actually served, since
* out-of-range pages are clamped server-side.
*/
function syncUrl(effectivePage: number) {
const url = new URL(window.location.toString());
const current = url.searchParams.get("page");
const target = effectivePage > 1 ? effectivePage.toString() : null;
if (current === target) return;
if (target === null) url.searchParams.delete("page");
else url.searchParams.set("page", target);
history.replaceState({}, "", url.toString());
}
async function loadPhotos(targetPage: number) {
const seq = ++requestSeq;
// Apply the target immediately so the pager and further clicks act on it
// instead of on the page that is still being fetched
page = targetPage;
loading = true;
error = null;
try {
const data = await GAME.photos(targetPage, pageSize);
if (seq !== requestSeq) return;
photoData = data;
page = data.page;
syncUrl(data.page);
} catch (e) {
if (seq !== requestSeq) return;
error = e;
} finally {
if (seq === requestSeq) loading = false;
}
}
function handleUpdatePage(event: CustomEvent<number>) {
const newPage = event.detail;
if (newPage === page) return;
const url = new URL(window.location.toString());
url.searchParams.set("page", newPage.toString());
history.pushState({}, "", url.toString());
loadPhotos(newPage);
window.scrollTo({ top: 0, behavior: "smooth" });
}
onMount(() => {
loadPhotos(readPageFromUrl());
const onPopState = () => loadPhotos(readPageFromUrl());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
});
</script> </script>
<main class="content"> <main class="content">
@@ -13,22 +82,31 @@
<h2>{t("maiphoto.title")}</h2> <h2>{t("maiphoto.title")}</h2>
</div> </div>
{#await GAME.photos()} {#if loading && !photoData}
<Loading/> <Loading/>
{:then photos} {:else if error}
{#if photos.length === 0}
<blockquote class="info">{t('maiphoto.none')}</blockquote>
{/if}
<div class="pictures">
{#each photos as photo}
<div class="photo-container">
<img class="rounded-2xl" src="{AQUA_HOST}/api/v2/game/mai2/my-photo/{photo}" alt="Mai Photo" />
</div>
{/each}
</div>
{:catch error}
<Error {error}/> <Error {error}/>
{/await} {:else if photoData}
{#if photoData.total === 0}
<blockquote class="info">{t('maiphoto.none')}</blockquote>
{:else}
{#if photoData.totalPages > 1}
<Pagination {page} totalPages={photoData.totalPages} on:updatePage={handleUpdatePage} />
{/if}
<div class="pictures" class:loading>
{#each photoData.photos as photo (photo)}
<div class="photo-container">
<img class="rounded-2xl" loading="lazy" src="{AQUA_HOST}/api/v2/game/mai2/my-photo/{photo}" alt="Mai Memorial Photo" />
</div>
{/each}
</div>
{#if photoData.totalPages > 1}
<Pagination {page} totalPages={photoData.totalPages} on:updatePage={handleUpdatePage} />
{/if}
{/if}
{/if}
</main> </main>
<style lang="sass"> <style lang="sass">
@@ -40,6 +118,10 @@
justify-content: center justify-content: center
row-gap: 1rem row-gap: 1rem
gap: 1rem gap: 1rem
transition: opacity 0.2s ease-in-out
&.loading
opacity: 0.5
.photo-container .photo-container
flex: 1 1 300px flex: 1 1 300px
@@ -19,6 +19,7 @@ import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import kotlin.text.Charsets.UTF_8 import kotlin.text.Charsets.UTF_8
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
import kotlin.reflect.KMutableProperty1 import kotlin.reflect.KMutableProperty1
@@ -210,7 +211,7 @@ class Maimai2(
} }
val photoDir = UploadUserPhotoHandler.uploadDir.toFile().canonicalFile val photoDir = UploadUserPhotoHandler.uploadDir.toFile().canonicalFile
val photoHashMap: MutableMap<String, String> = emptyMap<String, String>().toMutableMap() val photoHashMap = ConcurrentHashMap<String, String>()
// creating a ton of SHA256 hashes every launch *probably* isn't ideal but it's better than exposing token AND extid... // creating a ton of SHA256 hashes every launch *probably* isn't ideal but it's better than exposing token AND extid...
@@ -231,19 +232,41 @@ class Maimai2(
} }
@API("my-photo") @API("my-photo")
suspend fun myPhoto(@RP token: Str) = us.jwt.auth(token) { u -> suspend fun myPhoto(
@RP token: Str,
@RP(required = false) page: Int?,
@RP(required = false) size: Int?
): Any = us.jwt.auth(token) { u ->
val find = "${u.ghostCard.extId}-" val find = "${u.ghostCard.extId}-"
photoDir.listFiles() val files = photoDir.listFiles()
?.map { it.name } ?.map { it.name }
?.filter { it.startsWith(find) } ?.filter { it.startsWith(find) }
?.sorted() ?.sortedDescending()
?.map {
// generate hash of photo filename as to not expose details
if (!photoHashMap.containsKey(it))
photoHashMap[it] = myPhotoGetHash(it)
photoHashMap[it]
}
?: emptyList() ?: emptyList()
if (page == null) {
files.map {
photoHashMap.computeIfAbsent(it) { f -> myPhotoGetHash(f) }
}
} else {
val pageSize = (size ?: 12).coerceIn(1, 100)
val total = files.size
val totalPages = if (total == 0) 0 else (total + pageSize - 1) / pageSize
// Clamp to the nearest valid page: out-of-range pages return the closest real page
// instead of an empty grid, and the offset can no longer overflow Int
val pageNum = page.coerceIn(1, maxOf(1, totalPages))
val pagedFiles = files.drop((pageNum - 1) * pageSize).take(pageSize)
val photos = pagedFiles.map {
photoHashMap.computeIfAbsent(it) { f -> myPhotoGetHash(f) }
}
mapOf(
"photos" to photos,
"page" to pageNum,
"pageSize" to pageSize,
"total" to total,
"totalPages" to totalPages
)
}
} }
@API("my-photo/{fileName}", produces = [MediaType.IMAGE_JPEG_VALUE]) @API("my-photo/{fileName}", produces = [MediaType.IMAGE_JPEG_VALUE])