Paginate maps

This commit is contained in:
pleb 2025-11-02 16:20:41 -08:00
parent fd02e4cf00
commit fbba09f5b6
3 changed files with 358 additions and 110 deletions

View File

@ -3,63 +3,65 @@
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;
</script>
<div class="aspect-square bg-black/30">
<div class:compact-wrapper={compact} class="card-wrapper">
<div class:compact-cover={compact} class="cover">
{#if coverURL}
<img
src={coverURL}
alt={songName ?? hash}
loading="lazy"
class="h-full w-full object-cover"
/>
<img src={coverURL} alt={songName ?? hash} loading="lazy" />
{:else}
<div class="h-full w-full flex items-center justify-center text-2xl">☁️</div>
<div class="placeholder">☁️</div>
{/if}
</div>
<div class="p-3">
<div class="font-semibold truncate" title={songName ?? hash}>
</div>
<div class:compact-body={compact} class="body">
<div class="title" title={songName ?? hash}>
{songName ?? hash}
</div>
{#if mapper}
<div class="mt-0.5 text-xs text-muted truncate flex items-center justify-between">
<span>
<div class="mapper-row">
<span class="mapper">
{mapper}
{#if stars !== undefined}
<span class="ml-3" title="BeatLeader star rating">{stars.toFixed(2)}</span>
<span class="stars" title="BeatLeader star rating">{stars.toFixed(2)}</span>
{/if}
</span>
{#if timeset !== undefined}
<span class="text-[11px] ml-2">{new Date(timeset * 1000).toLocaleDateString()}</span>
{#if showPublished && timeset !== undefined}
<span class="date">{new Date(timeset * 1000).toLocaleDateString()}</span>
{/if}
</div>
{/if}
<div class="mt-2 flex items-center gap-2">
<div class:compact-row={compact} class="meta">
<DifficultyLabel {diffName} {modeName} />
<div class="flex-1">
<div class="player">
<SongPlayer {hash} preferBeatLeader={true} />
</div>
</div>
<slot name="content" />
<div class="mt-3">
{#if showActions}
<div class="actions">
<MapActionButtons
{hash}
{leaderboardId}
@ -68,5 +70,159 @@
{beatsaverKey}
/>
</div>
{/if}
</div>
</div>
<style>
.card-wrapper {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.card-wrapper.compact-wrapper {
display: grid;
grid-template-columns: auto 1fr;
align-items: stretch;
column-gap: 0.6rem;
}
.cover {
position: relative;
width: 100%;
padding-top: 100%;
background: rgba(0, 0, 0, 0.3);
border-radius: 0.75rem;
overflow: hidden;
}
.cover img,
.cover .placeholder {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover .placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.cover.compact-cover {
width: 104px;
min-width: 104px;
max-width: 104px;
padding-top: 104px;
border-radius: 0.5rem;
}
.body {
padding: 0.75rem 0.75rem 1rem;
display: flex;
flex-direction: column;
flex: 1;
}
.body.compact-body {
padding: 0.25rem 0.2rem 0.5rem;
gap: 0.4rem;
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.body.compact-body .title {
font-size: 0.95rem;
}
.mapper-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: rgba(148, 163, 184, 0.9);
}
.mapper-row .mapper {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mapper-row .stars {
margin-left: 0.4rem;
}
.mapper-row .date {
font-size: 0.7rem;
white-space: nowrap;
}
.meta {
margin-top: 0.6rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.meta.compact-row {
margin-top: 0.25rem;
gap: 0.35rem;
}
.player {
flex: 1;
}
.actions {
margin-top: auto;
}
.body.compact-body .actions {
margin-top: 0.3rem;
}
@media (max-width: 959px) {
.card-wrapper.compact-wrapper {
grid-template-columns: 1fr;
row-gap: 0.35rem;
}
.cover.compact-cover {
margin: 0 auto;
width: 100%;
max-width: 240px;
padding-top: 100%;
}
}
@media (min-width: 960px) {
.meta.compact-row {
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
}
.meta.compact-row .player {
width: 100%;
}
}
:global(.difficulty-label) {
white-space: nowrap;
}
</style>

View File

@ -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<number, PlaylistState> = {};
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 {
<h1 class="font-display text-3xl sm:text-4xl">Map Pack Discovery</h1>
<p class="mt-2 text-muted max-w-2xl">
See <strong><a href="https://bsaber.com/playlists" target="_blank" rel="noreferrer">Featured Packs</a> on Beast Saber</strong> 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.
</p>
<HasToolAccess player={data?.player ?? null} requirement={requirement} adminRank={data?.adminRank ?? null} adminPlayer={data?.adminPlayer ?? null}>
@ -180,9 +201,9 @@ function scorePercent(avgScore: number | undefined | null): number | null {
{#if playlists.length > 0}
<div class="mt-6 flex flex-wrap items-center gap-3 text-sm text-muted">
<span>Page {page}{#if totalPages} of {totalPages}{/if}</span>
<span>Page {page} {#if totalPages}of {totalPages}{/if}</span>
{#if total != null}
<span>{total.toLocaleString()} total playlists</span>
<span>({total.toLocaleString()} total playlists)</span>
{/if}
</div>
@ -200,6 +221,19 @@ function scorePercent(avgScore: number | undefined | null): number | null {
</a>
<span class="map-count">{playlist.stats?.totalMaps ?? 0} maps</span>
</div>
{#if scorePercent(playlist.stats?.avgScore) !== null}
<div
class="row-score"
data-gradient={scorePercent(playlist.stats?.avgScore)! > 50 ? 'true' : 'false'}
>
<div class="score-bar">
<div
class="score-bar-fill"
style={`width: ${scorePercent(playlist.stats?.avgScore) ?? 0}%`}
></div>
</div>
</div>
{/if}
<div class="row-sub">
<span>
by
@ -212,14 +246,6 @@ function scorePercent(avgScore: number | undefined | null): number | null {
{/if}
</span>
</div>
{#if scorePercent(playlist.stats?.avgScore) !== null}
<div class="row-score">
<span class="score-label">Score {formatScore(playlist.stats?.avgScore)}</span>
<div class="score-bar">
<div class="score-bar-fill" style={`width: ${scorePercent(playlist.stats?.avgScore) ?? 0}%`}></div>
</div>
</div>
{/if}
</div>
<div class:arrow-expanded={Boolean(expanded[playlist.playlistId])} class="row-arrow" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" role="presentation">
@ -246,13 +272,47 @@ function scorePercent(avgScore: number | undefined | null): number | null {
diffName={card.diffName}
modeName={card.modeName}
beatsaverKey={card.beatsaverKey}
>
<svelte:fragment slot="content">
<div class="song-extra">Published {card.publishedLabel}</div>
</svelte:fragment>
</MapCard>
compact={true}
showActions={false}
showPublished={false}
/>
{/each}
</div>
{#if playlistState[playlist.playlistId]?.total}
<div class="row-pagination">
<button
class="pager-btn"
on:click={() => loadPlaylistMaps(playlist.playlistId, Math.max(0, playlistState[playlist.playlistId].offset - SONGS_PER_PAGE))}
disabled={playlistState[playlist.playlistId].offset === 0 || playlistState[playlist.playlistId].loading}
>
Prev {SONGS_PER_PAGE}
</button>
<span class="row-status">
{#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}
</span>
<button
class="pager-btn"
on:click={() => loadPlaylistMaps(playlist.playlistId, playlistState[playlist.playlistId].offset + SONGS_PER_PAGE)}
disabled={
playlistState[playlist.playlistId].loading ||
(playlistState[playlist.playlistId].offset + playlistState[playlist.playlistId].maps.length) >=
(playlistState[playlist.playlistId].total ?? 0)
}
>
Next {SONGS_PER_PAGE}
</button>
</div>
{/if}
{:else}
<div class="row-status">No songs found.</div>
{/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 {

View File

@ -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');