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 MapActionButtons from './MapActionButtons.svelte';
import SongPlayer from './SongPlayer.svelte'; import SongPlayer from './SongPlayer.svelte';
// Song metadata // Song metadata
export let hash: string; export let hash: string;
export let coverURL: string | undefined = undefined; export let coverURL: string | undefined = undefined;
export let songName: string | undefined = undefined; export let songName: string | undefined = undefined;
export let mapper: string | undefined = undefined; export let mapper: string | undefined = undefined;
export let stars: number | undefined = undefined; export let stars: number | undefined = undefined;
export let timeset: number | undefined = undefined; export let timeset: number | undefined = undefined;
// Difficulty info // Difficulty info
export let diffName: string; export let diffName: string;
export let modeName: string = 'Standard'; export let modeName: string = 'Standard';
// BeatLeader/BeatSaver links // BeatLeader/BeatSaver links
export let leaderboardId: string | undefined = undefined; export let leaderboardId: string | undefined = undefined;
export let beatsaverKey: string | undefined = undefined; export let beatsaverKey: string | undefined = undefined;
// Layout tweaks
export let compact = false;
export let showActions = true;
export let showPublished = true;
</script> </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} {#if coverURL}
<img <img src={coverURL} alt={songName ?? hash} loading="lazy" />
src={coverURL}
alt={songName ?? hash}
loading="lazy"
class="h-full w-full object-cover"
/>
{:else} {:else}
<div class="h-full w-full flex items-center justify-center text-2xl">☁️</div> <div class="placeholder">☁️</div>
{/if} {/if}
</div> </div>
<div class="p-3"> <div class:compact-body={compact} class="body">
<div class="font-semibold truncate" title={songName ?? hash}> <div class="title" title={songName ?? hash}>
{songName ?? hash} {songName ?? hash}
</div> </div>
{#if mapper} {#if mapper}
<div class="mt-0.5 text-xs text-muted truncate flex items-center justify-between"> <div class="mapper-row">
<span> <span class="mapper">
{mapper} {mapper}
{#if stars !== undefined} {#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} {/if}
</span> </span>
{#if timeset !== undefined} {#if showPublished && timeset !== undefined}
<span class="text-[11px] ml-2">{new Date(timeset * 1000).toLocaleDateString()}</span> <span class="date">{new Date(timeset * 1000).toLocaleDateString()}</span>
{/if} {/if}
</div> </div>
{/if} {/if}
<div class="mt-2 flex items-center gap-2"> <div class:compact-row={compact} class="meta">
<DifficultyLabel {diffName} {modeName} /> <DifficultyLabel {diffName} {modeName} />
<div class="flex-1"> <div class="player">
<SongPlayer {hash} preferBeatLeader={true} /> <SongPlayer {hash} preferBeatLeader={true} />
</div> </div>
</div> </div>
<slot name="content" /> <slot name="content" />
<div class="mt-3"> {#if showActions}
<div class="actions">
<MapActionButtons <MapActionButtons
{hash} {hash}
{leaderboardId} {leaderboardId}
@ -68,5 +70,159 @@
{beatsaverKey} {beatsaverKey}
/> />
</div> </div>
{/if}
</div>
</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; loading: boolean;
maps: PlaylistSongCard[]; maps: PlaylistSongCard[];
error: string | null; error: string | null;
total: number | null;
offset: number;
}; };
const SONGS_PER_PAGE = 12;
export let data: { export let data: {
playlists: PlaylistWithStats[]; playlists: PlaylistWithStats[];
page: number; page: number;
@ -59,33 +63,43 @@ let playlistState: Record<number, PlaylistState> = {};
function togglePlaylist(id: number) { function togglePlaylist(id: number) {
const currentlyExpanded = Boolean(expanded[id]); const currentlyExpanded = Boolean(expanded[id]);
expanded = { ...expanded, [id]: !currentlyExpanded }; expanded = { ...expanded, [id]: !currentlyExpanded };
if (!currentlyExpanded && !playlistState[id]) { if (!currentlyExpanded) {
loadPlaylistMaps(id); 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 = {
...playlistState, ...playlistState,
[id]: { loading: true, maps: [], error: null } [id]: { loading: true, maps: playlistState[id]?.maps ?? [], error: null, total: playlistState[id]?.total ?? null, offset }
}; };
try { 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}`); 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 maps = Array.isArray(payload?.maps) ? payload.maps : [];
const cards = maps const cards = maps
.map((entry) => mapEntryToCard(entry)) .map((entry) => mapEntryToCard(entry))
.filter((card): card is PlaylistSongCard => card !== null) .filter((card): card is PlaylistSongCard => card !== null);
.slice(0, 10);
playlistState = { playlistState = {
...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) { } catch (err) {
playlistState = { playlistState = {
...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 { function mapEntryToCard(entry: MapDetailWithOrder): PlaylistSongCard | null {
const map = entry.map; const rawMap = (entry as unknown as { map?: MapDetail }).map ?? (entry as unknown as MapDetail);
if (!map) return null; if (!rawMap) return null;
const version = getPublishedVersion(map);
if (!version) return null; const versions = rawMap.versions ?? [];
const diff = getPrimaryDifficulty(version); const version = getPublishedVersion(rawMap) ?? versions[0] ?? null;
const published = map.lastPublishedAt ?? map.createdAt; const diff = version ? getPrimaryDifficulty(version) : null;
const hash = version.hash ?? map.id; const published = rawMap.lastPublishedAt ?? rawMap.createdAt ?? rawMap.updatedAt ?? version?.createdAt ?? null;
const hash = version?.hash ?? rawMap.id ?? rawMap.hash ?? null;
if (!hash) return 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 { return {
id: map.id ?? hash, id: rawMap.id ?? hash,
hash, hash,
coverURL: version.coverURL, coverURL,
songName: map.name, songName: rawMap.name,
mapper: map.uploader?.name ?? undefined, mapper: rawMap.uploader?.name ?? undefined,
timeset: toEpochSeconds(published), timeset: toEpochSeconds(published),
diffName: diff?.difficulty ?? 'Unknown', diffName,
modeName: diff?.characteristic ?? 'Standard', modeName,
beatsaverKey: version.key ?? map.id, beatsaverKey,
publishedLabel: formatDate(published) 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> <h1 class="font-display text-3xl sm:text-4xl">Map Pack Discovery</h1>
<p class="mt-2 text-muted max-w-2xl"> <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. 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> </p>
<HasToolAccess player={data?.player ?? null} requirement={requirement} adminRank={data?.adminRank ?? null} adminPlayer={data?.adminPlayer ?? null}> <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} {#if playlists.length > 0}
<div class="mt-6 flex flex-wrap items-center gap-3 text-sm text-muted"> <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} {#if total != null}
<span>{total.toLocaleString()} total playlists</span> <span>({total.toLocaleString()} total playlists)</span>
{/if} {/if}
</div> </div>
@ -200,6 +221,19 @@ function scorePercent(avgScore: number | undefined | null): number | null {
</a> </a>
<span class="map-count">{playlist.stats?.totalMaps ?? 0} maps</span> <span class="map-count">{playlist.stats?.totalMaps ?? 0} maps</span>
</div> </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"> <div class="row-sub">
<span> <span>
by by
@ -212,14 +246,6 @@ function scorePercent(avgScore: number | undefined | null): number | null {
{/if} {/if}
</span> </span>
</div> </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>
<div class:arrow-expanded={Boolean(expanded[playlist.playlistId])} class="row-arrow" aria-hidden="true"> <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"> <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} diffName={card.diffName}
modeName={card.modeName} modeName={card.modeName}
beatsaverKey={card.beatsaverKey} beatsaverKey={card.beatsaverKey}
> compact={true}
<svelte:fragment slot="content"> showActions={false}
<div class="song-extra">Published {card.publishedLabel}</div> showPublished={false}
</svelte:fragment> />
</MapCard>
{/each} {/each}
</div> </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} {:else}
<div class="row-status">No songs found.</div> <div class="row-status">No songs found.</div>
{/if} {/if}
@ -362,12 +422,8 @@ function scorePercent(avgScore: number | undefined | null): number | null {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
max-width: 280px; max-width: 220px;
} --score-default: rgba(148, 163, 184, 0.35);
.score-label {
font-size: 0.85rem;
color: rgba(148, 163, 184, 0.9);
} }
.score-bar { .score-bar {
@ -384,10 +440,18 @@ function scorePercent(avgScore: number | undefined | null): number | null {
inset: 0; inset: 0;
width: 0; width: 0;
border-radius: inherit; 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; 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 { .row-arrow {
transition: transform 0.2s ease; transition: transform 0.2s ease;
color: rgba(148, 163, 184, 0.8); color: rgba(148, 163, 184, 0.8);
@ -412,16 +476,30 @@ function scorePercent(avgScore: number | undefined | null): number | null {
color: #f87171; color: #f87171;
} }
.row-pagination {
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.songs-grid { .songs-grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.song-extra { @media (min-width: 960px) {
margin-top: 0.5rem; .songs-grid {
font-size: 0.85rem; grid-template-columns: repeat(4, minmax(0, 1fr));
color: rgba(148, 163, 184, 0.85); }
}
@media (min-width: 1600px) {
.songs-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
} }
.pager-btn { .pager-btn {

View File

@ -1,19 +1,33 @@
import { createBeatSaverAPI, type MapDetailWithOrder, type PlaylistPage } from '$lib/server/beatsaver'; import { createBeatSaverAPI, type MapDetailWithOrder, type PlaylistPage } from '$lib/server/beatsaver';
import { error, json } from '@sveltejs/kit'; 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'); const idParam = url.searchParams.get('id');
if (!idParam) { if (!idParam) {
throw error(400, 'Missing playlist id'); 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); const api = createBeatSaverAPI(fetch);
try { try {
const detail = (await api.getPlaylistDetail(idParam, { page: 0, useCache: false })) as PlaylistPage; const detail = (await api.getPlaylistDetail(idParam, { page: 0, useCache: false })) as PlaylistPage;
const maps = Array.isArray(detail?.maps) ? detail.maps : []; 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) { } catch (err) {
console.error('Failed to load playlist detail', idParam, err); console.error('Failed to load playlist detail', idParam, err);
throw error(502, 'Failed to load playlist detail'); throw error(502, 'Failed to load playlist detail');