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;
|
||||
}
|
||||
|
||||
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 */
|
||||
@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;
|
||||
|
||||
@ -1,11 +1,31 @@
|
||||
<script lang="ts">
|
||||
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 { TOOL_REQUIREMENTS } from '$lib/utils/plebsaber-utils';
|
||||
|
||||
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: {
|
||||
playlists: PlaylistWithStats[];
|
||||
page: number;
|
||||
@ -33,11 +53,102 @@
|
||||
$: hasNext = totalPages != null ? page < totalPages : playlists.length > 0 && playlists.length === pageSize;
|
||||
$: prevHref = hasPrev ? `?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;
|
||||
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 {
|
||||
if (id === undefined || id === null) return playlistBaseUrl;
|
||||
@ -53,7 +164,7 @@
|
||||
<section class="py-8">
|
||||
<h1 class="font-display text-3xl sm:text-4xl">Map Pack Discovery</h1>
|
||||
<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>
|
||||
|
||||
<HasToolAccess player={data?.player ?? null} requirement={requirement} adminRank={data?.adminRank ?? null} adminPlayer={data?.adminPlayer ?? null}>
|
||||
@ -75,39 +186,78 @@
|
||||
{/if}
|
||||
</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)}
|
||||
<article class="card-surface overflow-hidden">
|
||||
<a class="block" href={playlistLink(playlist.playlistId)} target="_blank" rel="noopener noreferrer">
|
||||
<figure class="playlist-cover">
|
||||
<img
|
||||
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}
|
||||
<article class="playlist-row card-surface">
|
||||
<button class="row-header" type="button" on:click={() => togglePlaylist(playlist.playlistId)}>
|
||||
<div class="row-cover">
|
||||
<img src={playlist.playlistImage} alt={`Playlist cover for ${playlist.name}`} loading="lazy" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-muted">
|
||||
<div class="row-main">
|
||||
<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>
|
||||
by
|
||||
{#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}
|
||||
Unknown curator
|
||||
{/if}
|
||||
</span>
|
||||
{#if formatScore(playlist.stats?.avgScore)}
|
||||
<span class="font-medium text-white">{formatScore(playlist.stats?.avgScore)}</span>
|
||||
</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 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>
|
||||
</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>
|
||||
{/each}
|
||||
</div>
|
||||
@ -126,30 +276,154 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.playlist-cover {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.8), rgba(30, 58, 138, 0.4));
|
||||
display: block;
|
||||
.playlist-row {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-cover img {
|
||||
.row-header {
|
||||
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%;
|
||||
object-fit: cover;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
font-size: 1.1rem;
|
||||
.row-main {
|
||||
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;
|
||||
}
|
||||
|
||||
.row-title a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.playlist-title:hover {
|
||||
.row-title a:hover {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -180,14 +454,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
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