From fbba09f5b6d88c80b100c594e0af2c60ba555b2c Mon Sep 17 00:00:00 2001 From: pleb Date: Sun, 2 Nov 2025 16:20:41 -0800 Subject: [PATCH] Paginate maps --- src/lib/components/MapCard.svelte | 268 ++++++++++++++---- .../tools/map-pack-discovery/+page.svelte | 180 ++++++++---- .../map-pack-discovery/playlist/+server.ts | 20 +- 3 files changed, 358 insertions(+), 110 deletions(-) diff --git a/src/lib/components/MapCard.svelte b/src/lib/components/MapCard.svelte index 865277a..86b906e 100644 --- a/src/lib/components/MapCard.svelte +++ b/src/lib/components/MapCard.svelte @@ -3,70 +3,226 @@ import MapActionButtons from './MapActionButtons.svelte'; import SongPlayer from './SongPlayer.svelte'; - // Song metadata - export let hash: string; - export let coverURL: string | undefined = undefined; - export let songName: string | undefined = undefined; - export let mapper: string | undefined = undefined; - export let stars: number | undefined = undefined; - export let timeset: number | undefined = undefined; +// Song metadata +export let hash: string; +export let coverURL: string | undefined = undefined; +export let songName: string | undefined = undefined; +export let mapper: string | undefined = undefined; +export let stars: number | undefined = undefined; +export let timeset: number | undefined = undefined; - // Difficulty info - export let diffName: string; - export let modeName: string = 'Standard'; +// Difficulty info +export let diffName: string; +export let modeName: string = 'Standard'; - // BeatLeader/BeatSaver links - export let leaderboardId: string | undefined = undefined; - export let beatsaverKey: string | undefined = undefined; +// BeatLeader/BeatSaver links +export let leaderboardId: string | undefined = undefined; +export let beatsaverKey: string | undefined = undefined; + +// Layout tweaks +export let compact = false; +export let showActions = true; +export let showPublished = true; -
- {#if coverURL} - {songName - {:else} -
☁️
- {/if} -
-
-
- {songName ?? hash} +
+
+ {#if coverURL} + {songName + {:else} +
☁️
+ {/if}
- {#if mapper} -
- - {mapper} - {#if stars !== undefined} - ★ {stars.toFixed(2)} +
+
+ {songName ?? hash} +
+ {#if mapper} +
+ + {mapper} + {#if stars !== undefined} + ★ {stars.toFixed(2)} + {/if} + + {#if showPublished && timeset !== undefined} + {new Date(timeset * 1000).toLocaleDateString()} {/if} - - {#if timeset !== undefined} - {new Date(timeset * 1000).toLocaleDateString()} - {/if} -
- {/if} - -
- -
- -
-
+
+ {/if} - +
+ +
+ +
+
-
- + + + {#if showActions} +
+ +
+ {/if}
+ \ No newline at end of file diff --git a/src/routes/tools/map-pack-discovery/+page.svelte b/src/routes/tools/map-pack-discovery/+page.svelte index e1c1f28..4c67422 100644 --- a/src/routes/tools/map-pack-discovery/+page.svelte +++ b/src/routes/tools/map-pack-discovery/+page.svelte @@ -24,8 +24,12 @@ loading: boolean; maps: PlaylistSongCard[]; error: string | null; + total: number | null; + offset: number; }; + const SONGS_PER_PAGE = 12; + export let data: { playlists: PlaylistWithStats[]; page: number; @@ -59,33 +63,43 @@ let playlistState: Record = {}; function togglePlaylist(id: number) { const currentlyExpanded = Boolean(expanded[id]); expanded = { ...expanded, [id]: !currentlyExpanded }; - if (!currentlyExpanded && !playlistState[id]) { - loadPlaylistMaps(id); + if (!currentlyExpanded) { + const state = playlistState[id]; + const offset = state ? state.offset : 0; + loadPlaylistMaps(id, offset); } } - async function loadPlaylistMaps(id: number) { + async function loadPlaylistMaps(id: number, offset = 0) { playlistState = { ...playlistState, - [id]: { loading: true, maps: [], error: null } + [id]: { loading: true, maps: playlistState[id]?.maps ?? [], error: null, total: playlistState[id]?.total ?? null, offset } }; try { - const res = await fetch(`/tools/map-pack-discovery/playlist?id=${encodeURIComponent(String(id))}`); + const res = await fetch(`/tools/map-pack-discovery/playlist?id=${encodeURIComponent(String(id))}&offset=${offset}`); if (!res.ok) throw new Error(`Failed to load playlist ${id}: ${res.status}`); - const payload = (await res.json()) as { maps?: MapDetailWithOrder[] | null }; + const payload = (await res.json()) as { maps?: MapDetailWithOrder[] | null; totalCount?: number | null; offset: number; pageSize?: number }; const maps = Array.isArray(payload?.maps) ? payload.maps : []; const cards = maps .map((entry) => mapEntryToCard(entry)) - .filter((card): card is PlaylistSongCard => card !== null) - .slice(0, 10); + .filter((card): card is PlaylistSongCard => card !== null); playlistState = { ...playlistState, - [id]: { loading: false, maps: cards, error: null } + [id]: { + loading: false, + maps: cards, + error: null, + total: + typeof payload?.totalCount === 'number' + ? payload.totalCount + : playlistState[id]?.total ?? (offset + cards.length), + offset: payload?.offset ?? offset + } }; } catch (err) { playlistState = { ...playlistState, - [id]: { loading: false, maps: [], error: err instanceof Error ? err.message : 'Failed to load playlist songs' } + [id]: { loading: false, maps: [], error: err instanceof Error ? err.message : 'Failed to load playlist songs', total: null, offset } }; } } @@ -115,25 +129,31 @@ function togglePlaylist(id: number) { } function mapEntryToCard(entry: MapDetailWithOrder): PlaylistSongCard | null { - const map = entry.map; - if (!map) return null; - const version = getPublishedVersion(map); - if (!version) return null; - const diff = getPrimaryDifficulty(version); - const published = map.lastPublishedAt ?? map.createdAt; - const hash = version.hash ?? map.id; + const rawMap = (entry as unknown as { map?: MapDetail }).map ?? (entry as unknown as MapDetail); + if (!rawMap) return null; + + const versions = rawMap.versions ?? []; + const version = getPublishedVersion(rawMap) ?? versions[0] ?? null; + const diff = version ? getPrimaryDifficulty(version) : null; + const published = rawMap.lastPublishedAt ?? rawMap.createdAt ?? rawMap.updatedAt ?? version?.createdAt ?? null; + const hash = version?.hash ?? rawMap.id ?? rawMap.hash ?? null; if (!hash) return null; + const beatsaverKey = version?.key ?? rawMap.id ?? undefined; + const coverURL = version?.coverURL ?? rawMap.coverURL ?? undefined; + const diffName = diff?.difficulty ?? 'Unknown'; + const modeName = diff?.characteristic ?? 'Standard'; + return { - id: map.id ?? hash, + id: rawMap.id ?? hash, hash, - coverURL: version.coverURL, - songName: map.name, - mapper: map.uploader?.name ?? undefined, + coverURL, + songName: rawMap.name, + mapper: rawMap.uploader?.name ?? undefined, timeset: toEpochSeconds(published), - diffName: diff?.difficulty ?? 'Unknown', - modeName: diff?.characteristic ?? 'Standard', - beatsaverKey: version.key ?? map.id, + diffName, + modeName, + beatsaverKey, publishedLabel: formatDate(published) }; } @@ -165,6 +185,7 @@ function scorePercent(avgScore: number | undefined | null): number | null {

Map Pack Discovery

See Featured Packs on Beast Saber for curated map packs. Otherwise, this page aims to provide a discovery tool for finding any and all map packs. + The heuristic this tool uses to find map packs is to show playlists on beatsaver where more than 3 maps link back to the playlist.

@@ -180,9 +201,9 @@ function scorePercent(avgScore: number | undefined | null): number | null { {#if playlists.length > 0}
- Page {page}{#if totalPages} of {totalPages}{/if} + Page {page} {#if totalPages}of {totalPages}{/if} {#if total != null} - • {total.toLocaleString()} total playlists + ({total.toLocaleString()} total playlists) {/if}
@@ -200,6 +221,19 @@ function scorePercent(avgScore: number | undefined | null): number | null { {playlist.stats?.totalMaps ?? 0} maps
+ {#if scorePercent(playlist.stats?.avgScore) !== null} +
50 ? 'true' : 'false'} + > +
+
+
+
+ {/if}
by @@ -212,14 +246,6 @@ function scorePercent(avgScore: number | undefined | null): number | null { {/if}
- {#if scorePercent(playlist.stats?.avgScore) !== null} -
- Score {formatScore(playlist.stats?.avgScore)} -
-
-
-
- {/if}
+ {#if playlistState[playlist.playlistId]?.total} +
+ + + {#if playlistState[playlist.playlistId].total === 0} + No songs + {:else} + Showing {playlistState[playlist.playlistId].offset + 1} + - + {Math.min( + playlistState[playlist.playlistId].offset + playlistState[playlist.playlistId].maps.length, + playlistState[playlist.playlistId].total ?? 0 + )} + of {playlistState[playlist.playlistId].total} + {/if} + + +
+ {/if} {:else}
No songs found.
{/if} @@ -362,12 +422,8 @@ function scorePercent(avgScore: number | undefined | null): number | null { display: flex; flex-direction: column; gap: 0.4rem; - max-width: 280px; - } - - .score-label { - font-size: 0.85rem; - color: rgba(148, 163, 184, 0.9); + max-width: 220px; + --score-default: rgba(148, 163, 184, 0.35); } .score-bar { @@ -384,10 +440,18 @@ function scorePercent(avgScore: number | undefined | null): number | null { inset: 0; width: 0; border-radius: inherit; - background: linear-gradient(90deg, var(--color-neon), var(--color-neon-fuchsia)); + background: var(--score-gradient, var(--score-default)); transition: width 0.3s ease; } + .row-score[data-gradient='true'] .score-bar-fill { + --score-gradient: linear-gradient(90deg, var(--color-neon-fuchsia), var(--color-neon)); + } + + .row-score[data-gradient='false'] .score-bar-fill { + --score-gradient: rgba(148, 163, 184, 0.35); + } + .row-arrow { transition: transform 0.2s ease; color: rgba(148, 163, 184, 0.8); @@ -412,16 +476,30 @@ function scorePercent(avgScore: number | undefined | null): number | null { color: #f87171; } + .row-pagination { + margin-top: 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + } + .songs-grid { display: grid; gap: 1rem; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .song-extra { - margin-top: 0.5rem; - font-size: 0.85rem; - color: rgba(148, 163, 184, 0.85); + @media (min-width: 960px) { + .songs-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + + @media (min-width: 1600px) { + .songs-grid { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } } .pager-btn { diff --git a/src/routes/tools/map-pack-discovery/playlist/+server.ts b/src/routes/tools/map-pack-discovery/playlist/+server.ts index 0b67f27..052c6a1 100644 --- a/src/routes/tools/map-pack-discovery/playlist/+server.ts +++ b/src/routes/tools/map-pack-discovery/playlist/+server.ts @@ -1,19 +1,33 @@ import { createBeatSaverAPI, type MapDetailWithOrder, type PlaylistPage } from '$lib/server/beatsaver'; import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; -export const GET = async ({ url, fetch }) => { +const PAGE_SIZE = 12; + +export const GET: RequestHandler = async ({ url, fetch }) => { const idParam = url.searchParams.get('id'); if (!idParam) { throw error(400, 'Missing playlist id'); } + const offsetParam = Number.parseInt(url.searchParams.get('offset') ?? '0', 10); + const offset = Number.isFinite(offsetParam) && offsetParam >= 0 ? offsetParam : 0; const api = createBeatSaverAPI(fetch); try { const detail = (await api.getPlaylistDetail(idParam, { page: 0, useCache: false })) as PlaylistPage; const maps = Array.isArray(detail?.maps) ? detail.maps : []; - const topTen: MapDetailWithOrder[] = maps.slice(0, 10); - return json({ playlist: detail?.playlist ?? null, maps: topTen }); + const totalCount = detail?.playlist?.stats?.totalMaps ?? maps.length; + const slice = maps.slice(offset, offset + PAGE_SIZE) as MapDetailWithOrder[]; + + return json({ + playlist: detail?.playlist ?? null, + maps: slice, + offset, + page: Math.floor(offset / PAGE_SIZE), + pageSize: PAGE_SIZE, + totalCount + }); } catch (err) { console.error('Failed to load playlist detail', idParam, err); throw error(502, 'Failed to load playlist detail');