Show maps for each playlist, basic functionality
This commit is contained in:
parent
0b1146de71
commit
fd02e4cf00
12
src/app.css
12
src/app.css
@ -29,6 +29,18 @@ body {
|
|||||||
@apply bg-bg text-white/90 antialiased;
|
@apply bg-bg text-white/90 antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p a {
|
||||||
|
color: var(--color-neon);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: rgba(34, 211, 238, 0.4);
|
||||||
|
transition: color 0.2s ease, text-decoration-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
p a:hover {
|
||||||
|
color: color-mix(in srgb, var(--color-neon) 80%, white 20%);
|
||||||
|
text-decoration-color: rgba(34, 211, 238, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
/* Utilities */
|
/* Utilities */
|
||||||
@utility btn-neon {
|
@utility btn-neon {
|
||||||
@apply inline-flex items-center gap-2 rounded-md border border-neon/60 px-4 py-2 text-neon transition hover:border-neon hover:text-white focus:outline-none focus:ring-2 focus:ring-neon/50;
|
@apply inline-flex items-center gap-2 rounded-md border border-neon/60 px-4 py-2 text-neon transition hover:border-neon hover:text-white focus:outline-none focus:ring-2 focus:ring-neon/50;
|
||||||
|
|||||||
@ -1,11 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import HasToolAccess from '$lib/components/HasToolAccess.svelte';
|
import HasToolAccess from '$lib/components/HasToolAccess.svelte';
|
||||||
import type { PlaylistFull } from '$lib/server/beatsaver';
|
import MapCard from '$lib/components/MapCard.svelte';
|
||||||
|
import type { MapDetailWithOrder, MapDetail, MapVersion, MapDifficulty, PlaylistFull } from '$lib/server/beatsaver';
|
||||||
import type { BeatLeaderPlayerProfile } from '$lib/utils/plebsaber-utils';
|
import type { BeatLeaderPlayerProfile } from '$lib/utils/plebsaber-utils';
|
||||||
import { TOOL_REQUIREMENTS } from '$lib/utils/plebsaber-utils';
|
import { TOOL_REQUIREMENTS } from '$lib/utils/plebsaber-utils';
|
||||||
|
|
||||||
type PlaylistWithStats = PlaylistFull & { stats?: PlaylistFull['stats'] };
|
type PlaylistWithStats = PlaylistFull & { stats?: PlaylistFull['stats'] };
|
||||||
|
|
||||||
|
type PlaylistSongCard = {
|
||||||
|
id: string;
|
||||||
|
hash: string;
|
||||||
|
coverURL?: string;
|
||||||
|
songName: string;
|
||||||
|
mapper?: string;
|
||||||
|
timeset?: number;
|
||||||
|
diffName: string;
|
||||||
|
modeName: string;
|
||||||
|
beatsaverKey?: string;
|
||||||
|
publishedLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlaylistState = {
|
||||||
|
loading: boolean;
|
||||||
|
maps: PlaylistSongCard[];
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export let data: {
|
export let data: {
|
||||||
playlists: PlaylistWithStats[];
|
playlists: PlaylistWithStats[];
|
||||||
page: number;
|
page: number;
|
||||||
@ -33,11 +53,102 @@
|
|||||||
$: hasNext = totalPages != null ? page < totalPages : playlists.length > 0 && playlists.length === pageSize;
|
$: hasNext = totalPages != null ? page < totalPages : playlists.length > 0 && playlists.length === pageSize;
|
||||||
$: prevHref = hasPrev ? `?page=${page - 1}` : null;
|
$: prevHref = hasPrev ? `?page=${page - 1}` : null;
|
||||||
$: nextHref = hasNext ? `?page=${page + 1}` : null;
|
$: nextHref = hasNext ? `?page=${page + 1}` : null;
|
||||||
|
let expanded: Record<number, boolean> = {};
|
||||||
|
let playlistState: Record<number, PlaylistState> = {};
|
||||||
|
|
||||||
function formatScore(avgScore: number | undefined | null): string | null {
|
function togglePlaylist(id: number) {
|
||||||
|
const currentlyExpanded = Boolean(expanded[id]);
|
||||||
|
expanded = { ...expanded, [id]: !currentlyExpanded };
|
||||||
|
if (!currentlyExpanded && !playlistState[id]) {
|
||||||
|
loadPlaylistMaps(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlaylistMaps(id: number) {
|
||||||
|
playlistState = {
|
||||||
|
...playlistState,
|
||||||
|
[id]: { loading: true, maps: [], error: null }
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/tools/map-pack-discovery/playlist?id=${encodeURIComponent(String(id))}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load playlist ${id}: ${res.status}`);
|
||||||
|
const payload = (await res.json()) as { maps?: MapDetailWithOrder[] | null };
|
||||||
|
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);
|
||||||
|
playlistState = {
|
||||||
|
...playlistState,
|
||||||
|
[id]: { loading: false, maps: cards, error: null }
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
playlistState = {
|
||||||
|
...playlistState,
|
||||||
|
[id]: { loading: false, maps: [], error: err instanceof Error ? err.message : 'Failed to load playlist songs' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublishedVersion(map: MapDetail | undefined | null) {
|
||||||
|
const versions = map?.versions ?? [];
|
||||||
|
return versions.find((v) => v.state === 'Published') ?? versions[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryDifficulty(version: MapVersion | undefined | null): MapDifficulty | null {
|
||||||
|
const diffs = version?.diffs ?? [];
|
||||||
|
return diffs.length > 0 ? diffs[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEpochSeconds(value: string | undefined | null): number | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const ts = Date.parse(value);
|
||||||
|
if (Number.isNaN(ts)) return undefined;
|
||||||
|
return Math.floor(ts / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | undefined | null): string {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (!hash) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: map.id ?? hash,
|
||||||
|
hash,
|
||||||
|
coverURL: version.coverURL,
|
||||||
|
songName: map.name,
|
||||||
|
mapper: map.uploader?.name ?? undefined,
|
||||||
|
timeset: toEpochSeconds(published),
|
||||||
|
diffName: diff?.difficulty ?? 'Unknown',
|
||||||
|
modeName: diff?.characteristic ?? 'Standard',
|
||||||
|
beatsaverKey: version.key ?? map.id,
|
||||||
|
publishedLabel: formatDate(published)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScore(avgScore: number | undefined | null): string | null {
|
||||||
if (typeof avgScore !== 'number' || !Number.isFinite(avgScore)) return null;
|
if (typeof avgScore !== 'number' || !Number.isFinite(avgScore)) return null;
|
||||||
return `${(avgScore * 100).toFixed(1)}%`;
|
return `${(avgScore * 100).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scorePercent(avgScore: number | undefined | null): number | null {
|
||||||
|
if (typeof avgScore !== 'number' || !Number.isFinite(avgScore)) return null;
|
||||||
|
const pct = avgScore * 100;
|
||||||
|
if (!Number.isFinite(pct)) return null;
|
||||||
|
return Math.min(100, Math.max(0, pct));
|
||||||
|
}
|
||||||
|
|
||||||
function playlistLink(id: number | string | undefined): string {
|
function playlistLink(id: number | string | undefined): string {
|
||||||
if (id === undefined || id === null) return playlistBaseUrl;
|
if (id === undefined || id === null) return playlistBaseUrl;
|
||||||
@ -53,7 +164,7 @@
|
|||||||
<section class="py-8">
|
<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">Map Pack Discovery</h1>
|
||||||
<p class="mt-2 text-muted max-w-2xl">
|
<p class="mt-2 text-muted max-w-2xl">
|
||||||
Browse curated BeatSaver playlists with quick access to pack covers, curators, and their overall rating.
|
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.
|
||||||
</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}>
|
||||||
@ -75,39 +186,78 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
<div class="mt-6 space-y-3">
|
||||||
{#each playlists as playlist (playlist.playlistId)}
|
{#each playlists as playlist (playlist.playlistId)}
|
||||||
<article class="card-surface overflow-hidden">
|
<article class="playlist-row card-surface">
|
||||||
<a class="block" href={playlistLink(playlist.playlistId)} target="_blank" rel="noopener noreferrer">
|
<button class="row-header" type="button" on:click={() => togglePlaylist(playlist.playlistId)}>
|
||||||
<figure class="playlist-cover">
|
<div class="row-cover">
|
||||||
<img
|
<img src={playlist.playlistImage} alt={`Playlist cover for ${playlist.name}`} loading="lazy" />
|
||||||
src={playlist.playlistImage}
|
|
||||||
alt={`Playlist cover for ${playlist.name}`}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</figure>
|
|
||||||
</a>
|
|
||||||
<div class="p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<a class="playlist-title" href={playlistLink(playlist.playlistId)} target="_blank" rel="noopener noreferrer">{playlist.name}</a>
|
|
||||||
{#if playlist.description}
|
|
||||||
<p class="mt-1 line-clamp-3 text-sm text-muted">{playlist.description}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row-main">
|
||||||
<div class="flex items-center justify-between text-sm text-muted">
|
<div class="row-title">
|
||||||
|
<a href={playlistLink(playlist.playlistId)} target="_blank" rel="noopener noreferrer" on:click|stopPropagation>
|
||||||
|
{playlist.name}
|
||||||
|
</a>
|
||||||
|
<span class="map-count">{playlist.stats?.totalMaps ?? 0} maps</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-sub">
|
||||||
<span>
|
<span>
|
||||||
|
by
|
||||||
{#if playlist.owner?.name}
|
{#if playlist.owner?.name}
|
||||||
<a class="hover:text-white transition-colors" href={profileLink(playlist.owner.id)} target="_blank" rel="noopener noreferrer">{playlist.owner.name}</a>
|
<a href={profileLink(playlist.owner.id)} target="_blank" rel="noopener noreferrer" on:click|stopPropagation>
|
||||||
|
{playlist.owner.name}
|
||||||
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
Unknown curator
|
Unknown curator
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if formatScore(playlist.stats?.avgScore)}
|
</div>
|
||||||
<span class="font-medium text-white">{formatScore(playlist.stats?.avgScore)}</span>
|
{#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}
|
{/if}
|
||||||
</div>
|
</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">
|
||||||
|
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded[playlist.playlistId]}
|
||||||
|
<div class="row-body">
|
||||||
|
{#if playlistState[playlist.playlistId]?.loading}
|
||||||
|
<div class="row-status">Loading songs…</div>
|
||||||
|
{:else if playlistState[playlist.playlistId]?.error}
|
||||||
|
<div class="row-status error">{playlistState[playlist.playlistId]?.error}</div>
|
||||||
|
{:else if playlistState[playlist.playlistId]?.maps?.length}
|
||||||
|
<div class="songs-grid">
|
||||||
|
{#each playlistState[playlist.playlistId].maps as card (card.id)}
|
||||||
|
<MapCard
|
||||||
|
hash={card.hash}
|
||||||
|
coverURL={card.coverURL}
|
||||||
|
songName={card.songName}
|
||||||
|
mapper={card.mapper}
|
||||||
|
timeset={card.timeset}
|
||||||
|
diffName={card.diffName}
|
||||||
|
modeName={card.modeName}
|
||||||
|
beatsaverKey={card.beatsaverKey}
|
||||||
|
>
|
||||||
|
<svelte:fragment slot="content">
|
||||||
|
<div class="song-extra">Published {card.publishedLabel}</div>
|
||||||
|
</svelte:fragment>
|
||||||
|
</MapCard>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="row-status">No songs found.</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@ -126,30 +276,154 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.playlist-cover {
|
.playlist-row {
|
||||||
aspect-ratio: 16 / 9;
|
padding: 0;
|
||||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.8), rgba(30, 58, 138, 0.4));
|
overflow: hidden;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-cover img {
|
.row-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-cover {
|
||||||
|
height: 3em;
|
||||||
|
width: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-cover img {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
width: auto;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-title {
|
.row-main {
|
||||||
font-size: 1.1rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-title a {
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-title:hover {
|
.row-title a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-count {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(148, 163, 184, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-sub {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(148, 163, 184, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-sub a {
|
||||||
|
color: var(--color-neon);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-sub a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: color-mix(in srgb, var(--color-neon) 80%, white 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-score {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(148, 163, 184, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-bar {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(34, 211, 238, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--color-neon), var(--color-neon-fuchsia));
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-arrow {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: rgba(148, 163, 184, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-arrow.arrow-expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
color: var(--color-neon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-body {
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.08);
|
||||||
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-status {
|
||||||
|
color: rgba(148, 163, 184, 0.95);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-status.error {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.songs-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-extra {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(148, 163, 184, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
.pager-btn {
|
.pager-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -180,14 +454,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-clamp-3 {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
line-clamp: 3;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-danger {
|
.text-danger {
|
||||||
color: #f87171;
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/routes/tools/map-pack-discovery/playlist/+server.ts
Normal file
23
src/routes/tools/map-pack-discovery/playlist/+server.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { createBeatSaverAPI, type MapDetailWithOrder, type PlaylistPage } from '$lib/server/beatsaver';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET = async ({ url, fetch }) => {
|
||||||
|
const idParam = url.searchParams.get('id');
|
||||||
|
if (!idParam) {
|
||||||
|
throw error(400, 'Missing playlist id');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load playlist detail', idParam, err);
|
||||||
|
throw error(502, 'Failed to load playlist detail');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user