Show maps for each playlist, basic functionality

This commit is contained in:
pleb 2025-11-02 15:09:30 -08:00
parent 0b1146de71
commit fd02e4cf00
3 changed files with 350 additions and 49 deletions

View File

@ -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;

View File

@ -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,12 +53,103 @@
$: 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 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 { 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;
return `${playlistBaseUrl}${id}`; return `${playlistBaseUrl}${id}`;
@ -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;
} }

View 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');
}
};