mirror of
https://github.com/MewoLab/AquaDX.git
synced 2026-09-22 14:34:32 +03:00
[+] Add pagination to maimai photo gallery (#244)
This commit is contained in:
@@ -14,7 +14,8 @@
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -37,7 +38,7 @@
|
||||
<button on:click={() => updatePage(page - 1)} disabled={page <= 1}>Previous</button>
|
||||
|
||||
{#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}
|
||||
<span on:click={startEditing} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && startEditing()}>
|
||||
Page {page} of {totalPages}
|
||||
|
||||
+13
-2
@@ -16,6 +16,14 @@ import type { GameName } from './scoring'
|
||||
|
||||
export type ExportGameName = GameName | 'diva'
|
||||
|
||||
export interface PhotoPage {
|
||||
photos: string[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
interface ExtReqInit extends RequestInit {
|
||||
params?: { [index: string]: string }
|
||||
json?: any
|
||||
@@ -28,7 +36,8 @@ interface ExtReqInit extends RequestInit {
|
||||
* @param callback Callback for modification
|
||||
*/
|
||||
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)
|
||||
if (result) u = result
|
||||
if (input instanceof Request) {
|
||||
@@ -232,7 +241,9 @@ export const CARD = {
|
||||
export const GAME = {
|
||||
trend: (username: string, game: GameName): Promise<TrendEntry[]> =>
|
||||
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`, { }),
|
||||
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
|
||||
post(`/api/v2/game/${game}/user-summary`, { username }),
|
||||
|
||||
@@ -1,11 +1,80 @@
|
||||
<script lang="ts">
|
||||
import {GAME} from "../libs/sdk";
|
||||
import {AQUA_HOST} from "../libs/config";
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
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 Error from "../components/ui/Error.svelte";
|
||||
import Pagination from "../components/Pagination.svelte";
|
||||
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>
|
||||
|
||||
<main class="content">
|
||||
@@ -13,22 +82,31 @@
|
||||
<h2>{t("maiphoto.title")}</h2>
|
||||
</div>
|
||||
|
||||
{#await GAME.photos()}
|
||||
{#if loading && !photoData}
|
||||
<Loading/>
|
||||
{:then photos}
|
||||
{#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}
|
||||
{:else if 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>
|
||||
|
||||
<style lang="sass">
|
||||
@@ -40,6 +118,10 @@
|
||||
justify-content: center
|
||||
row-gap: 1rem
|
||||
gap: 1rem
|
||||
transition: opacity 0.2s ease-in-out
|
||||
|
||||
&.loading
|
||||
opacity: 0.5
|
||||
|
||||
.photo-container
|
||||
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.RestController
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.text.Charsets.UTF_8
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
import kotlin.reflect.KMutableProperty1
|
||||
@@ -210,7 +211,7 @@ class Maimai2(
|
||||
}
|
||||
|
||||
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...
|
||||
|
||||
@@ -231,19 +232,41 @@ class Maimai2(
|
||||
}
|
||||
|
||||
@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}-"
|
||||
photoDir.listFiles()
|
||||
val files = photoDir.listFiles()
|
||||
?.map { it.name }
|
||||
?.filter { it.startsWith(find) }
|
||||
?.sorted()
|
||||
?.map {
|
||||
// generate hash of photo filename as to not expose details
|
||||
if (!photoHashMap.containsKey(it))
|
||||
photoHashMap[it] = myPhotoGetHash(it)
|
||||
photoHashMap[it]
|
||||
}
|
||||
?.sortedDescending()
|
||||
?: 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])
|
||||
|
||||
Reference in New Issue
Block a user