Paginate maps
This commit is contained in:
parent
fd02e4cf00
commit
fbba09f5b6
@ -3,70 +3,226 @@
|
|||||||
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">
|
||||||
{#if coverURL}
|
<div class:compact-cover={compact} class="cover">
|
||||||
<img
|
{#if coverURL}
|
||||||
src={coverURL}
|
<img src={coverURL} alt={songName ?? hash} loading="lazy" />
|
||||||
alt={songName ?? hash}
|
{:else}
|
||||||
loading="lazy"
|
<div class="placeholder">☁️</div>
|
||||||
class="h-full w-full object-cover"
|
{/if}
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="h-full w-full flex items-center justify-center text-2xl">☁️</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="p-3">
|
|
||||||
<div class="font-semibold truncate" title={songName ?? hash}>
|
|
||||||
{songName ?? hash}
|
|
||||||
</div>
|
</div>
|
||||||
{#if mapper}
|
<div class:compact-body={compact} class="body">
|
||||||
<div class="mt-0.5 text-xs text-muted truncate flex items-center justify-between">
|
<div class="title" title={songName ?? hash}>
|
||||||
<span>
|
{songName ?? hash}
|
||||||
{mapper}
|
</div>
|
||||||
{#if stars !== undefined}
|
{#if mapper}
|
||||||
<span class="ml-3" title="BeatLeader star rating">★ {stars.toFixed(2)}</span>
|
<div class="mapper-row">
|
||||||
|
<span class="mapper">
|
||||||
|
{mapper}
|
||||||
|
{#if stars !== undefined}
|
||||||
|
<span class="stars" title="BeatLeader star rating">★ {stars.toFixed(2)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if showPublished && timeset !== undefined}
|
||||||
|
<span class="date">{new Date(timeset * 1000).toLocaleDateString()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</div>
|
||||||
{#if timeset !== undefined}
|
{/if}
|
||||||
<span class="text-[11px] ml-2">{new Date(timeset * 1000).toLocaleDateString()}</span>
|
|
||||||
{/if}
|
<div class:compact-row={compact} class="meta">
|
||||||
|
<DifficultyLabel {diffName} {modeName} />
|
||||||
|
<div class="player">
|
||||||
|
<SongPlayer {hash} preferBeatLeader={true} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-2 flex items-center gap-2">
|
<slot name="content" />
|
||||||
<DifficultyLabel {diffName} {modeName} />
|
|
||||||
<div class="flex-1">
|
|
||||||
<SongPlayer {hash} preferBeatLeader={true} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<slot name="content" />
|
{#if showActions}
|
||||||
|
<div class="actions">
|
||||||
<div class="mt-3">
|
<MapActionButtons
|
||||||
<MapActionButtons
|
{hash}
|
||||||
{hash}
|
{leaderboardId}
|
||||||
{leaderboardId}
|
{diffName}
|
||||||
{diffName}
|
{modeName}
|
||||||
{modeName}
|
{beatsaverKey}
|
||||||
{beatsaverKey}
|
/>
|
||||||
/>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user