Rename playlist discovery page and include the playlist description in the layout
This commit is contained in:
parent
2cae4ad3f6
commit
2f37126617
@ -5,6 +5,7 @@ import * as nodeOs from 'node:os';
|
||||
const BASE_URL = 'https://api.beatsaver.com';
|
||||
// Many API calls are undocumented and only work on the web API, e.g. searchPlaylists
|
||||
const WEB_API_BASE_URL = 'https://beatsaver.com/api';
|
||||
const CACHE_ROOT = '.data/beatsaver-cache';
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
@ -530,17 +531,8 @@ export class BeatSaverAPI {
|
||||
}
|
||||
|
||||
private determineCacheDir(): string {
|
||||
// Prefer ~/.cache/saberlist/beatsaver, fallback to CWD .cache
|
||||
const os = this.osModule();
|
||||
const path = this.pathModule();
|
||||
const home = os.homedir?.();
|
||||
const homeCache = home ? path.join(home, '.cache') : null;
|
||||
if (homeCache) {
|
||||
const saberlist = path.join(homeCache, 'saberlist');
|
||||
const beatsaver = path.join(saberlist, 'beatsaver');
|
||||
return beatsaver;
|
||||
}
|
||||
return this.pathJoin(process.cwd(), '.cache');
|
||||
return this.pathJoin(process.cwd(), CACHE_ROOT);
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
@ -585,7 +577,12 @@ export class BeatSaverAPI {
|
||||
}
|
||||
|
||||
export function createBeatSaverAPI(fetchFn: typeof fetch, options: BeatSaverApiOptions = {}): BeatSaverAPI {
|
||||
return new BeatSaverAPI(fetchFn, options);
|
||||
const mergedOptions: BeatSaverApiOptions = {
|
||||
...options,
|
||||
cacheExpiryDays: options.cacheExpiryDays ?? 90
|
||||
};
|
||||
|
||||
return new BeatSaverAPI(fetchFn, mergedOptions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -106,7 +106,7 @@ export const TOOL_REQUIREMENTS = {
|
||||
'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'player-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'player-playlist-gaps': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'map-pack-discovery': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
||||
'playlist-discovery': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
||||
} as const satisfies Record<string, ToolRequirement>;
|
||||
|
||||
export type ToolKey = keyof typeof TOOL_REQUIREMENTS;
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
{ name: 'Compare Play Histories', href: '/tools/compare-histories', desc: 'Find songs A played that B has not' },
|
||||
{ name: 'Player Playlist Gaps', href: '/tools/player-playlist-gaps', desc: 'Upload a playlist and find songs a player has not played' },
|
||||
{ name: 'Player Head-to-Head', href: '/tools/player-headtohead', desc: 'Compare two players on the same map/difficulty' },
|
||||
{ name: 'Map Pack Discovery', href: '/tools/map-pack-discovery', desc: 'Browse curated BeatSaver playlists with mapper context' }
|
||||
{ name: 'Playlist Discovery', href: '/tools/playlist-discovery', desc: 'Browse verified BeatSaver playlists sorted by recency' }
|
||||
] as tool}
|
||||
<a href={tool.href} class="card-surface p-5 block">
|
||||
<div class="font-semibold">{tool.name}</div>
|
||||
|
||||
@ -22,10 +22,20 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => {
|
||||
const pageIndex = requestedPage - 1;
|
||||
|
||||
const api = createBeatSaverAPI(fetch);
|
||||
const MIN_MAPS = 3;
|
||||
|
||||
try {
|
||||
const response = (await api.searchPlaylists({ page: pageIndex })) as PlaylistSearchResponse<PlaylistFull>;
|
||||
const docs = Array.isArray(response?.docs) ? response.docs : [];
|
||||
const response = (await api.searchPlaylists({
|
||||
page: pageIndex,
|
||||
sortOrder: 'Latest',
|
||||
verified: true,
|
||||
includeEmpty: false
|
||||
})) as PlaylistSearchResponse<PlaylistFull>;
|
||||
const docsRaw = Array.isArray(response?.docs) ? response.docs : [];
|
||||
const docs = docsRaw.filter((playlist) => {
|
||||
const totalMaps = playlist?.stats?.totalMaps ?? 0;
|
||||
return Number.isFinite(totalMaps) && totalMaps >= MIN_MAPS;
|
||||
});
|
||||
const info = (response?.info && typeof response.info === 'object' ? (response.info as SearchInfoShape) : null) ?? null;
|
||||
|
||||
const infoPage = typeof info?.page === 'number' ? info.page : pageIndex;
|
||||
@ -44,7 +44,7 @@
|
||||
adminPlayer: BeatLeaderPlayerProfile | null;
|
||||
};
|
||||
|
||||
const requirement = TOOL_REQUIREMENTS['map-pack-discovery'];
|
||||
const requirement = TOOL_REQUIREMENTS['playlist-discovery'];
|
||||
|
||||
const playlistBaseUrl = 'https://beatsaver.com/playlists/';
|
||||
const profileBaseUrl = 'https://beatsaver.com/profile/';
|
||||
@ -82,7 +82,7 @@ function togglePlaylist(id: number) {
|
||||
[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))}&offset=${offset}`);
|
||||
const res = await fetch(`/tools/playlist-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; totalCount?: number | null; offset: number; pageSize?: number };
|
||||
const maps = Array.isArray(payload?.maps) ? payload.maps : [];
|
||||
@ -178,13 +178,25 @@ function togglePlaylist(id: number) {
|
||||
if (id === undefined || id === null) return profileBaseUrl;
|
||||
return `${profileBaseUrl}${id}#playlists`;
|
||||
}
|
||||
|
||||
function sanitizeDescription(value: string | null | undefined): string {
|
||||
if (!value) return '';
|
||||
return value.replace(/[^a-zA-Z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function truncateTitle(value: string | null | undefined, maxLength = 20): string {
|
||||
if (!value) return '';
|
||||
if (value.length <= maxLength) return value;
|
||||
const sliced = value.slice(0, maxLength - 1).trimEnd();
|
||||
return `${sliced}…`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="py-8">
|
||||
<h1 class="font-display text-3xl sm:text-4xl">Map Pack Discovery</h1>
|
||||
<h1 class="font-display text-3xl sm:text-4xl">Playlist 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.
|
||||
See <strong><a href="https://bsaber.com/playlists" target="_blank" rel="noreferrer">Featured Packs</a> on Beast Saber</strong> for curated map packs. You can also view most packs by searching BeatSaver for <a href="https://beatsaver.com/playlists?q=picks&order=Latest" target="_blank" rel="noreferrer">picks</a> or <a href="https://beatsaver.com/playlists?q=pack&verified=true&order=Latest" target="_blank" rel="noreferrer">packs</a>.
|
||||
Otherwise this page is a UI/UX experiment that shows playlists by verified mappers, and provides a reactive interface for previewing map ratings and listening to song previews.
|
||||
</p>
|
||||
|
||||
<HasToolAccess player={data?.player ?? null} requirement={requirement} adminRank={data?.adminRank ?? null} adminPlayer={data?.adminPlayer ?? null}>
|
||||
@ -208,6 +220,8 @@ function togglePlaylist(id: number) {
|
||||
|
||||
<div class="mt-6 playlists-grid">
|
||||
{#each playlists as playlist (playlist.playlistId)}
|
||||
{@const displayName = truncateTitle(playlist.name)}
|
||||
{@const description = sanitizeDescription(playlist.description ?? '')}
|
||||
<article class:expanded={Boolean(expanded[playlist.playlistId])} class="playlist-tile card-surface">
|
||||
<button class="tile-header" type="button" on:click={() => togglePlaylist(playlist.playlistId)}>
|
||||
<div class="tile-cover">
|
||||
@ -215,8 +229,14 @@ function togglePlaylist(id: number) {
|
||||
</div>
|
||||
<div class="tile-main">
|
||||
<div class="tile-title">
|
||||
<a href={playlistLink(playlist.playlistId)} target="_blank" rel="noopener noreferrer" on:click|stopPropagation>
|
||||
{playlist.name}
|
||||
<a
|
||||
href={playlistLink(playlist.playlistId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
on:click|stopPropagation
|
||||
title={playlist.name}
|
||||
>
|
||||
{displayName}
|
||||
</a>
|
||||
<span class="map-count">{playlist.stats?.totalMaps ?? 0} maps</span>
|
||||
</div>
|
||||
@ -247,12 +267,53 @@ function togglePlaylist(id: number) {
|
||||
|
||||
{#if expanded[playlist.playlistId]}
|
||||
{@const state = playlistState[playlist.playlistId]}
|
||||
{@const offset = state?.offset ?? 0}
|
||||
{@const total = typeof state?.total === 'number' ? state.total : null}
|
||||
{@const currentCount = state?.maps?.length ?? 0}
|
||||
{@const atFirstPage = offset <= 0}
|
||||
{@const atLastPage = total !== null
|
||||
? (offset + currentCount) >= total
|
||||
: currentCount < SONGS_PER_PAGE}
|
||||
<div class="tile-body">
|
||||
{#if description}
|
||||
<div class="tile-description">{description}</div>
|
||||
{/if}
|
||||
{#if state?.loading}
|
||||
<div class="tile-status">Loading songs…</div>
|
||||
{:else if state?.error}
|
||||
<div class="tile-status error">{state.error}</div>
|
||||
{:else if state?.maps?.length}
|
||||
{#if state.total && state.total > SONGS_PER_PAGE}
|
||||
<div class="tile-pagination">
|
||||
<button
|
||||
class="pager-btn"
|
||||
on:click={() => loadPlaylistMaps(playlist.playlistId, Math.max(0, offset - SONGS_PER_PAGE))}
|
||||
disabled={state.loading || atFirstPage}
|
||||
>
|
||||
Prev {SONGS_PER_PAGE}
|
||||
</button>
|
||||
<span class="tile-status">
|
||||
{#if state.total === 0}
|
||||
No songs
|
||||
{:else}
|
||||
Showing {(state.offset ?? 0) + 1}
|
||||
-
|
||||
{Math.min(
|
||||
(state.offset ?? 0) + state.maps.length,
|
||||
state.total ?? 0
|
||||
)}
|
||||
of {state.total}
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
class="pager-btn"
|
||||
on:click={() => loadPlaylistMaps(playlist.playlistId, offset + SONGS_PER_PAGE)}
|
||||
disabled={state.loading || atLastPage}
|
||||
>
|
||||
Next {SONGS_PER_PAGE}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="songs-grid">
|
||||
{#each state.maps as card (card.id)}
|
||||
<MapCard
|
||||
@ -279,40 +340,6 @@ function togglePlaylist(id: number) {
|
||||
</MapCard>
|
||||
{/each}
|
||||
</div>
|
||||
{#if state.total && state.total > SONGS_PER_PAGE}
|
||||
<div class="tile-pagination">
|
||||
<button
|
||||
class="pager-btn"
|
||||
on:click={() => loadPlaylistMaps(playlist.playlistId, Math.max(0, (state.offset ?? 0) - SONGS_PER_PAGE))}
|
||||
disabled={(state.offset ?? 0) === 0 || state.loading}
|
||||
>
|
||||
Prev {SONGS_PER_PAGE}
|
||||
</button>
|
||||
<span class="tile-status">
|
||||
{#if state.total === 0}
|
||||
No songs
|
||||
{:else}
|
||||
Showing {(state.offset ?? 0) + 1}
|
||||
-
|
||||
{Math.min(
|
||||
(state.offset ?? 0) + state.maps.length,
|
||||
state.total ?? 0
|
||||
)}
|
||||
of {state.total}
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
class="pager-btn"
|
||||
on:click={() => loadPlaylistMaps(playlist.playlistId, (state.offset ?? 0) + SONGS_PER_PAGE)}
|
||||
disabled={
|
||||
state.loading ||
|
||||
((state.offset ?? 0) + state.maps.length) >= (state.total ?? 0)
|
||||
}
|
||||
>
|
||||
Next {SONGS_PER_PAGE}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="tile-status">No songs found.</div>
|
||||
{/if}
|
||||
@ -457,6 +484,16 @@ function togglePlaylist(id: number) {
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.tile-description {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: rgba(226, 232, 240, 0.85);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 0.65rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.tile-status {
|
||||
color: rgba(148, 163, 184, 0.95);
|
||||
font-size: 0.9rem;
|
||||
@ -485,7 +522,7 @@ function togglePlaylist(id: number) {
|
||||
}
|
||||
|
||||
.tile-pagination {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
@ -533,6 +570,15 @@ function togglePlaylist(id: number) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pager-btn:disabled,
|
||||
.pager-btn:disabled:hover {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.card-surface {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border: 1px solid rgba(148, 163, 184, 0.08);
|
||||
@ -14,7 +14,7 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
|
||||
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 detail = (await api.getPlaylistDetail(idParam, { page: 0 })) as PlaylistPage;
|
||||
const maps = Array.isArray(detail?.maps) ? detail.maps : [];
|
||||
|
||||
const totalCount = detail?.playlist?.stats?.totalMaps ?? maps.length;
|
||||
Loading…
x
Reference in New Issue
Block a user