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;
-
-
- {songName ?? hash}
+
+
+ {#if coverURL}
+
+ {: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}
@@ -246,13 +272,47 @@ function scorePercent(avgScore: number | undefined | null): number | null {
diffName={card.diffName}
modeName={card.modeName}
beatsaverKey={card.beatsaverKey}
- >
-
-
-
-
+ compact={true}
+ showActions={false}
+ showPublished={false}
+ />
{/each}
+ {#if playlistState[playlist.playlistId]?.total}
+
+ {/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');