Add new tool which will be for discovery map packs, currently it just lists playlists
This commit is contained in:
parent
7eb7680e16
commit
0b1146de71
@ -1,4 +1,10 @@
|
|||||||
|
import * as nodeFs from 'node:fs';
|
||||||
|
import * as nodePath from 'node:path';
|
||||||
|
import * as nodeOs from 'node:os';
|
||||||
|
|
||||||
const BASE_URL = 'https://api.beatsaver.com';
|
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';
|
||||||
|
|
||||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||||
|
|
||||||
@ -28,6 +34,183 @@ export interface EnvironmentMapInfo extends CuratedSongInfo {
|
|||||||
environment?: string;
|
environment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DateInput = string | number | Date;
|
||||||
|
|
||||||
|
export interface BeatSaverUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
description?: string | null;
|
||||||
|
hash?: string | null;
|
||||||
|
verifiedMapper?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistStats {
|
||||||
|
totalMaps: number;
|
||||||
|
mapperCount: number;
|
||||||
|
totalDuration: number;
|
||||||
|
minNps: number;
|
||||||
|
maxNps: number;
|
||||||
|
upVotes: number;
|
||||||
|
downVotes: number;
|
||||||
|
avgScore: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistBasic {
|
||||||
|
playlistId: number;
|
||||||
|
playlistImage: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
owner: number;
|
||||||
|
config?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistFull {
|
||||||
|
playlistId: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
playlistImage: string;
|
||||||
|
playlistImage512?: string | null;
|
||||||
|
owner: BeatSaverUser;
|
||||||
|
curator?: BeatSaverUser | null;
|
||||||
|
stats?: PlaylistStats | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
songsChangedAt?: string | null;
|
||||||
|
curatedAt?: string | null;
|
||||||
|
deletedAt?: string | null;
|
||||||
|
downloadURL: string;
|
||||||
|
type: string;
|
||||||
|
config?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapMetadata {
|
||||||
|
bpm: number;
|
||||||
|
duration: number;
|
||||||
|
songName: string;
|
||||||
|
songSubName: string;
|
||||||
|
songAuthorName: string;
|
||||||
|
levelAuthorName: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapStats {
|
||||||
|
plays: number;
|
||||||
|
downloads: number;
|
||||||
|
upvotes: number;
|
||||||
|
downvotes: number;
|
||||||
|
score: number;
|
||||||
|
reviews?: number;
|
||||||
|
sentiment?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapDifficulty {
|
||||||
|
njs: number;
|
||||||
|
offset: number;
|
||||||
|
notes: number;
|
||||||
|
bombs: number;
|
||||||
|
obstacles: number;
|
||||||
|
nps: number;
|
||||||
|
length: number;
|
||||||
|
characteristic: string;
|
||||||
|
difficulty: string;
|
||||||
|
events: number;
|
||||||
|
chroma: boolean;
|
||||||
|
me: boolean;
|
||||||
|
ne: boolean;
|
||||||
|
cinema: boolean;
|
||||||
|
vivify?: boolean;
|
||||||
|
seconds: number;
|
||||||
|
stars?: number;
|
||||||
|
maxScore: number;
|
||||||
|
label?: string | null;
|
||||||
|
blStars?: number | null;
|
||||||
|
environment?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapVersion {
|
||||||
|
hash: string;
|
||||||
|
key?: string | null;
|
||||||
|
state: string;
|
||||||
|
createdAt: string;
|
||||||
|
downloadURL: string;
|
||||||
|
coverURL: string;
|
||||||
|
previewURL: string;
|
||||||
|
diffs?: MapDifficulty[];
|
||||||
|
feedback?: string | null;
|
||||||
|
testplayAt?: string | null;
|
||||||
|
testplays?: unknown;
|
||||||
|
scheduledAt?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
uploader: BeatSaverUser;
|
||||||
|
metadata: MapMetadata;
|
||||||
|
stats: MapStats;
|
||||||
|
versions: MapVersion[];
|
||||||
|
curator?: BeatSaverUser | null;
|
||||||
|
curatedAt?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
lastPublishedAt?: string | null;
|
||||||
|
automapper?: boolean;
|
||||||
|
ranked?: boolean;
|
||||||
|
qualified?: boolean;
|
||||||
|
tags?: unknown;
|
||||||
|
bookmarked?: boolean | null;
|
||||||
|
collaborators?: BeatSaverUser[] | null;
|
||||||
|
declaredAi?: unknown;
|
||||||
|
blRanked?: boolean;
|
||||||
|
blQualified?: boolean;
|
||||||
|
nsfw?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapDetailWithOrder {
|
||||||
|
map: MapDetail;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistPage {
|
||||||
|
playlist: PlaylistFull | null;
|
||||||
|
maps: MapDetailWithOrder[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistSearchResponse<T = PlaylistFull> {
|
||||||
|
docs: T[];
|
||||||
|
info?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistSearchParams {
|
||||||
|
query?: string;
|
||||||
|
page?: number;
|
||||||
|
sortOrder?: string;
|
||||||
|
order?: string;
|
||||||
|
ascending?: boolean;
|
||||||
|
includeEmpty?: boolean;
|
||||||
|
curated?: boolean;
|
||||||
|
verified?: boolean;
|
||||||
|
minNps?: number;
|
||||||
|
maxNps?: number;
|
||||||
|
from?: DateInput;
|
||||||
|
to?: DateInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistDetailOptions {
|
||||||
|
page?: number | null;
|
||||||
|
useCache?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface BeatSaverApiOptions {
|
interface BeatSaverApiOptions {
|
||||||
cacheExpiryDays?: number;
|
cacheExpiryDays?: number;
|
||||||
cacheDir?: string;
|
cacheDir?: string;
|
||||||
@ -198,6 +381,90 @@ export class BeatSaverAPI {
|
|||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchPlaylists(params: PlaylistSearchParams = {}): Promise<PlaylistSearchResponse> {
|
||||||
|
const {
|
||||||
|
query = '',
|
||||||
|
page = 0,
|
||||||
|
sortOrder = 'Relevance',
|
||||||
|
order,
|
||||||
|
ascending,
|
||||||
|
includeEmpty,
|
||||||
|
curated,
|
||||||
|
verified,
|
||||||
|
minNps,
|
||||||
|
maxNps,
|
||||||
|
from,
|
||||||
|
to
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const normalizeDate = (value?: DateInput): string | undefined => {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (typeof value === 'number') return new Date(value).toISOString();
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${WEB_API_BASE_URL}/playlists/search/${page}${buildQuery({
|
||||||
|
q: query,
|
||||||
|
sortOrder,
|
||||||
|
order,
|
||||||
|
ascending,
|
||||||
|
includeEmpty,
|
||||||
|
curated,
|
||||||
|
verified,
|
||||||
|
minNps,
|
||||||
|
maxNps,
|
||||||
|
from: normalizeDate(from),
|
||||||
|
to: normalizeDate(to)
|
||||||
|
})}`;
|
||||||
|
|
||||||
|
const res = await this.request(url);
|
||||||
|
if (!res.ok) throw new Error(`BeatSaver searchPlaylists failed: ${res.status}`);
|
||||||
|
return (await res.json()) as PlaylistSearchResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlaylistDetail(
|
||||||
|
playlistId: number | string,
|
||||||
|
options: PlaylistDetailOptions = {}
|
||||||
|
): Promise<PlaylistPage> {
|
||||||
|
const { page = null, useCache = true } = options;
|
||||||
|
const rawId = String(playlistId);
|
||||||
|
const safeId = encodeURIComponent(rawId);
|
||||||
|
const cacheSafeId = rawId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
const pageSuffix = page === null ? 'detail' : `page_${page}`;
|
||||||
|
const cachePath = this.pathJoin(this.cacheDir, `playlist_${cacheSafeId}_${pageSuffix}.json`);
|
||||||
|
|
||||||
|
if (useCache && (await this.isCacheValid(cachePath))) {
|
||||||
|
const cached = await this.readCache<PlaylistPage>(cachePath);
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
page === null
|
||||||
|
? `${WEB_API_BASE_URL}/playlists/id/${safeId}`
|
||||||
|
: `${WEB_API_BASE_URL}/playlists/id/${safeId}/${page}`;
|
||||||
|
|
||||||
|
const res = await this.request(endpoint);
|
||||||
|
if (!res.ok) throw new Error(`BeatSaver getPlaylistDetail failed: ${res.status}`);
|
||||||
|
const payload = (await res.json()) as PlaylistPage;
|
||||||
|
|
||||||
|
if (useCache) {
|
||||||
|
await this.writeCache(cachePath, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlaylistMaps(
|
||||||
|
playlistId: number | string,
|
||||||
|
page = 0,
|
||||||
|
options: { useCache?: boolean } = {}
|
||||||
|
): Promise<MapDetailWithOrder[]> {
|
||||||
|
const { useCache = true } = options;
|
||||||
|
const detail = await this.getPlaylistDetail(playlistId, { page, useCache });
|
||||||
|
return detail.maps ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
async getMapByHash(mapHash: string): Promise<unknown> {
|
async getMapByHash(mapHash: string): Promise<unknown> {
|
||||||
const url = `${BASE_URL}/maps/hash/${encodeURIComponent(mapHash)}`;
|
const url = `${BASE_URL}/maps/hash/${encodeURIComponent(mapHash)}`;
|
||||||
const res = await this.request(url);
|
const res = await this.request(url);
|
||||||
@ -304,21 +571,16 @@ export class BeatSaverAPI {
|
|||||||
|
|
||||||
// Lazy require Node builtins to keep SSR-friendly import graph
|
// Lazy require Node builtins to keep SSR-friendly import graph
|
||||||
private fsPromises() {
|
private fsPromises() {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
return nodeFs.promises;
|
||||||
const fs = require('fs');
|
|
||||||
return fs.promises as typeof import('fs').promises;
|
|
||||||
}
|
}
|
||||||
private fsModule() {
|
private fsModule() {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
return nodeFs;
|
||||||
return require('fs') as typeof import('fs');
|
|
||||||
}
|
}
|
||||||
private pathModule() {
|
private pathModule() {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
return nodePath;
|
||||||
return require('path') as typeof import('path');
|
|
||||||
}
|
}
|
||||||
private osModule() {
|
private osModule() {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
return nodeOs;
|
||||||
return require('os') as typeof import('os');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -105,7 +105,8 @@ const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = {
|
|||||||
export const TOOL_REQUIREMENTS = {
|
export const TOOL_REQUIREMENTS = {
|
||||||
'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||||
'player-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
'player-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||||
'player-playlist-gaps': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
'player-playlist-gaps': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||||
|
'map-pack-discovery': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
||||||
} as const satisfies Record<string, ToolRequirement>;
|
} as const satisfies Record<string, ToolRequirement>;
|
||||||
|
|
||||||
export type ToolKey = keyof typeof TOOL_REQUIREMENTS;
|
export type ToolKey = keyof typeof TOOL_REQUIREMENTS;
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
{#each [
|
{#each [
|
||||||
{ name: 'Compare Play Histories', href: '/tools/compare-histories', desc: 'Find songs A played that B has not' },
|
{ 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 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: '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' }
|
||||||
] as tool}
|
] as tool}
|
||||||
<a href={tool.href} class="card-surface p-5 block">
|
<a href={tool.href} class="card-surface p-5 block">
|
||||||
<div class="font-semibold">{tool.name}</div>
|
<div class="font-semibold">{tool.name}</div>
|
||||||
|
|||||||
66
src/routes/tools/map-pack-discovery/+page.server.ts
Normal file
66
src/routes/tools/map-pack-discovery/+page.server.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { createBeatSaverAPI, type PlaylistFull, type PlaylistSearchResponse } from '$lib/server/beatsaver';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
function parsePositiveInt(value: string | null, fallback: number): number {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchInfoShape = {
|
||||||
|
page?: number;
|
||||||
|
pages?: number;
|
||||||
|
total?: number;
|
||||||
|
size?: number;
|
||||||
|
itemsPerPage?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url, fetch, parent }) => {
|
||||||
|
const parentData = await parent();
|
||||||
|
const requestedPage = parsePositiveInt(url.searchParams.get('page'), 1);
|
||||||
|
const pageIndex = requestedPage - 1;
|
||||||
|
|
||||||
|
const api = createBeatSaverAPI(fetch);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = (await api.searchPlaylists({ page: pageIndex })) as PlaylistSearchResponse<PlaylistFull>;
|
||||||
|
const docs = Array.isArray(response?.docs) ? response.docs : [];
|
||||||
|
const info = (response?.info && typeof response.info === 'object' ? (response.info as SearchInfoShape) : null) ?? null;
|
||||||
|
|
||||||
|
const infoPage = typeof info?.page === 'number' ? info.page : pageIndex;
|
||||||
|
const currentPage = infoPage + 1;
|
||||||
|
const totalPages = typeof info?.pages === 'number' ? info.pages : null;
|
||||||
|
const total = typeof info?.total === 'number' ? info.total : null;
|
||||||
|
const pageSize = typeof info?.size === 'number'
|
||||||
|
? info.size
|
||||||
|
: typeof info?.itemsPerPage === 'number'
|
||||||
|
? info.itemsPerPage
|
||||||
|
: docs.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parentData,
|
||||||
|
playlists: docs,
|
||||||
|
info,
|
||||||
|
page: currentPage,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
pageSize,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load BeatSaver playlists', err);
|
||||||
|
return {
|
||||||
|
...parentData,
|
||||||
|
playlists: [] as PlaylistFull[],
|
||||||
|
info: null,
|
||||||
|
page: requestedPage,
|
||||||
|
totalPages: null,
|
||||||
|
total: null,
|
||||||
|
pageSize: 0,
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to load playlists'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
204
src/routes/tools/map-pack-discovery/+page.svelte
Normal file
204
src/routes/tools/map-pack-discovery/+page.svelte
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import HasToolAccess from '$lib/components/HasToolAccess.svelte';
|
||||||
|
import type { 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'] };
|
||||||
|
|
||||||
|
export let data: {
|
||||||
|
playlists: PlaylistWithStats[];
|
||||||
|
page: number;
|
||||||
|
totalPages: number | null;
|
||||||
|
total: number | null;
|
||||||
|
pageSize: number;
|
||||||
|
error: string | null;
|
||||||
|
player: BeatLeaderPlayerProfile | null;
|
||||||
|
adminRank: number | null;
|
||||||
|
adminPlayer: BeatLeaderPlayerProfile | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requirement = TOOL_REQUIREMENTS['map-pack-discovery'];
|
||||||
|
|
||||||
|
const playlistBaseUrl = 'https://beatsaver.com/playlists/';
|
||||||
|
const profileBaseUrl = 'https://beatsaver.com/profile/';
|
||||||
|
|
||||||
|
$: playlists = data?.playlists ?? [];
|
||||||
|
$: page = data?.page ?? 1;
|
||||||
|
$: totalPages = data?.totalPages ?? null;
|
||||||
|
$: total = data?.total ?? null;
|
||||||
|
$: pageSize = data?.pageSize ?? 0;
|
||||||
|
|
||||||
|
$: hasPrev = page > 1;
|
||||||
|
$: hasNext = totalPages != null ? page < totalPages : playlists.length > 0 && playlists.length === pageSize;
|
||||||
|
$: prevHref = hasPrev ? `?page=${page - 1}` : null;
|
||||||
|
$: nextHref = hasNext ? `?page=${page + 1}` : null;
|
||||||
|
|
||||||
|
function formatScore(avgScore: number | undefined | null): string | null {
|
||||||
|
if (typeof avgScore !== 'number' || !Number.isFinite(avgScore)) return null;
|
||||||
|
return `${(avgScore * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playlistLink(id: number | string | undefined): string {
|
||||||
|
if (id === undefined || id === null) return playlistBaseUrl;
|
||||||
|
return `${playlistBaseUrl}${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileLink(id: number | string | undefined): string {
|
||||||
|
if (id === undefined || id === null) return profileBaseUrl;
|
||||||
|
return `${profileBaseUrl}${id}#playlists`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<HasToolAccess player={data?.player ?? null} requirement={requirement} adminRank={data?.adminRank ?? null} adminPlayer={data?.adminPlayer ?? null}>
|
||||||
|
{#if data?.error}
|
||||||
|
<div class="mt-6 rounded-md border border-danger/40 bg-danger/10 px-4 py-3 text-danger text-sm">
|
||||||
|
{data.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !data?.error && playlists.length === 0}
|
||||||
|
<div class="mt-6 text-muted">No playlists found for this page.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if playlists.length > 0}
|
||||||
|
<div class="mt-6 flex flex-wrap items-center gap-3 text-sm text-muted">
|
||||||
|
<span>Page {page}{#if totalPages} of {totalPages}{/if}</span>
|
||||||
|
{#if total != null}
|
||||||
|
<span>• {total.toLocaleString()} total playlists</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-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}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-sm text-muted">
|
||||||
|
<span>
|
||||||
|
{#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>
|
||||||
|
{:else}
|
||||||
|
Unknown curator
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if formatScore(playlist.stats?.avgScore)}
|
||||||
|
<span class="font-medium text-white">{formatScore(playlist.stats?.avgScore)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex items-center justify-center gap-3">
|
||||||
|
<a class="pager-btn" href={prevHref} aria-disabled={!hasPrev} data-disabled={!hasPrev}>
|
||||||
|
Prev
|
||||||
|
</a>
|
||||||
|
<span class="text-sm text-muted">Page {page}{#if totalPages} / {totalPages}{/if}</span>
|
||||||
|
<a class="pager-btn" href={nextHref} aria-disabled={!hasNext} data-disabled={!hasNext}>
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</HasToolAccess>
|
||||||
|
</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-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-title:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.4rem 1.1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-btn:hover {
|
||||||
|
border-color: rgba(94, 234, 212, 0.5);
|
||||||
|
box-shadow: 0 0 12px rgba(94, 234, 212, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-btn[data-disabled='true'] {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-surface {
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.08);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-danger\/10 {
|
||||||
|
background-color: rgba(248, 113, 113, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-danger\/40 {
|
||||||
|
border-color: rgba(248, 113, 113, 0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user