637 lines
23 KiB
Svelte
637 lines
23 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import SongPlayer from '$lib/components/SongPlayer.svelte';
|
|
|
|
type BeatLeaderScore = {
|
|
timeset?: string | number;
|
|
leaderboard?: {
|
|
// BeatLeader tends to expose a short id for the leaderboard route
|
|
id?: string | number | null;
|
|
leaderboardId?: string | number | null;
|
|
song?: { hash?: string | null };
|
|
difficulty?: { value?: number | string | null; modeName?: string | null };
|
|
};
|
|
};
|
|
|
|
type BeatLeaderScoresResponse = {
|
|
data?: BeatLeaderScore[];
|
|
metadata?: { page?: number; itemsPerPage?: number; total?: number };
|
|
};
|
|
|
|
type Difficulty = {
|
|
name: string;
|
|
characteristic: string;
|
|
};
|
|
|
|
type SongItem = {
|
|
hash: string;
|
|
difficulties: Difficulty[];
|
|
timeset: number;
|
|
leaderboardId?: string;
|
|
};
|
|
|
|
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
|
|
|
|
let playerA = '';
|
|
let playerB = '';
|
|
let loading = false;
|
|
let errorMsg: string | null = null;
|
|
let results: SongItem[] = [];
|
|
let loadingMeta = false;
|
|
|
|
// Sorting and pagination state
|
|
let sortBy: 'date' | 'difficulty' = 'date';
|
|
let sortDir: 'asc' | 'desc' = 'desc';
|
|
let page = 1;
|
|
let pageSize: number | string = 24;
|
|
$: pageSizeNum = Number(pageSize) || 24;
|
|
|
|
// Configurable lookback windows (months)
|
|
let monthsA: number | string = 6; // default 6 months
|
|
let monthsB: number | string = 24; // default 24 months
|
|
|
|
// Derived lists
|
|
$: sortedResults = [...results].sort((a, b) => {
|
|
let cmp = 0;
|
|
if (sortBy === 'date') {
|
|
cmp = a.timeset - b.timeset;
|
|
} else {
|
|
const an = a.difficulties[0]?.name ?? '';
|
|
const bn = b.difficulties[0]?.name ?? '';
|
|
cmp = an.localeCompare(bn);
|
|
}
|
|
return sortDir === 'asc' ? cmp : -cmp;
|
|
});
|
|
$: totalPages = Math.max(1, Math.ceil(sortedResults.length / pageSizeNum));
|
|
$: page = Math.min(page, totalPages);
|
|
$: pageItems = sortedResults.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum);
|
|
|
|
type MapMeta = {
|
|
songName?: string;
|
|
key?: string;
|
|
coverURL?: string;
|
|
mapper?: string;
|
|
};
|
|
let metaByHash: Record<string, MapMeta> = {};
|
|
|
|
type StarInfo = {
|
|
stars?: number;
|
|
accRating?: number;
|
|
passRating?: number;
|
|
techRating?: number;
|
|
status?: number;
|
|
};
|
|
// Keyed by `${hash}|${difficultyName}|${modeName}` for precise lookup
|
|
let starsByKey: Record<string, StarInfo> = {};
|
|
let loadingStars = false;
|
|
|
|
// Toast notification state
|
|
let toastMessage = '';
|
|
let showToast = false;
|
|
let toastTimeout: number | null = null;
|
|
|
|
// Button feedback state - track which buttons are currently "lit up"
|
|
let litButtons = new Set<string>();
|
|
|
|
function showToastMessage(message: string) {
|
|
// Clear any existing toast
|
|
if (toastTimeout) {
|
|
clearTimeout(toastTimeout);
|
|
}
|
|
|
|
toastMessage = message;
|
|
showToast = true;
|
|
|
|
// Auto-hide toast after 3 seconds
|
|
toastTimeout = setTimeout(() => {
|
|
showToast = false;
|
|
toastMessage = '';
|
|
}, 3000);
|
|
}
|
|
|
|
function lightUpButton(buttonId: string) {
|
|
litButtons.add(buttonId);
|
|
// Reassign here too to trigger reactivity immediately
|
|
litButtons = litButtons;
|
|
// Remove the lighting effect after 1 second
|
|
setTimeout(() => {
|
|
litButtons.delete(buttonId);
|
|
litButtons = litButtons; // Trigger reactivity
|
|
}, 1000);
|
|
}
|
|
|
|
async function copyBsrCommand(key: string, hash: string) {
|
|
try {
|
|
const bsrCommand = `!bsr ${key}`;
|
|
await navigator.clipboard.writeText(bsrCommand);
|
|
|
|
// Show success feedback with the actual command
|
|
showToastMessage(`Copied "${bsrCommand}" to clipboard`);
|
|
lightUpButton(`bsr-${hash}`);
|
|
} catch (err) {
|
|
console.error('Failed to copy to clipboard:', err);
|
|
showToastMessage('Failed to copy to clipboard');
|
|
}
|
|
}
|
|
|
|
async function fetchBeatSaverMeta(hash: string): Promise<MapMeta | null> {
|
|
try {
|
|
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`);
|
|
if (!res.ok) throw new Error(String(res.status));
|
|
const data: any = await res.json();
|
|
const cover = data?.versions?.[0]?.coverURL ?? `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg`;
|
|
return {
|
|
songName: data?.metadata?.songName ?? data?.name ?? undefined,
|
|
key: data?.id ?? undefined,
|
|
coverURL: cover,
|
|
mapper: data?.uploader?.name ?? undefined
|
|
};
|
|
} catch {
|
|
// Fallback to CDN cover only
|
|
return { coverURL: `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg` };
|
|
}
|
|
}
|
|
|
|
async function loadMetaForResults(items: SongItem[]): Promise<void> {
|
|
const needed = Array.from(new Set(items.map((i) => i.hash))).filter((h) => !metaByHash[h]);
|
|
if (needed.length === 0) return;
|
|
loadingMeta = true;
|
|
for (const h of needed) {
|
|
const meta = await fetchBeatSaverMeta(h);
|
|
if (meta) metaByHash = { ...metaByHash, [h]: meta };
|
|
}
|
|
loadingMeta = false;
|
|
}
|
|
|
|
async function fetchBeatLeaderStarsByHash(hash: string): Promise<void> {
|
|
try {
|
|
const res = await fetch(`/api/beatleader?path=/leaderboards/hash/${encodeURIComponent(hash)}`);
|
|
if (!res.ok) return;
|
|
const data: any = await res.json();
|
|
const leaderboards: any[] = Array.isArray(data?.leaderboards) ? data.leaderboards : Array.isArray(data) ? data : [];
|
|
for (const lb of leaderboards) {
|
|
const diffName: string | undefined = lb?.difficulty?.difficultyName ?? lb?.difficulty?.name ?? undefined;
|
|
const modeName: string | undefined = lb?.difficulty?.modeName ?? lb?.modeName ?? 'Standard';
|
|
if (!diffName || !modeName) continue;
|
|
const normalized = normalizeDifficultyName(diffName);
|
|
const key = `${hash}|${normalized}|${modeName}`;
|
|
const info: StarInfo = {
|
|
stars: lb?.difficulty?.stars ?? lb?.stars,
|
|
accRating: lb?.difficulty?.accRating,
|
|
passRating: lb?.difficulty?.passRating,
|
|
techRating: lb?.difficulty?.techRating,
|
|
status: lb?.difficulty?.status
|
|
};
|
|
starsByKey = { ...starsByKey, [key]: info };
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
async function loadStarsForResults(items: SongItem[]): Promise<void> {
|
|
const neededHashes = Array.from(new Set(items.map((i) => i.hash)));
|
|
if (neededHashes.length === 0) return;
|
|
loadingStars = true;
|
|
for (const h of neededHashes) {
|
|
await fetchBeatLeaderStarsByHash(h);
|
|
}
|
|
loadingStars = false;
|
|
}
|
|
|
|
|
|
|
|
function difficultyToColor(name: string | undefined): string {
|
|
const n = (name ?? 'ExpertPlus').toLowerCase();
|
|
if (n === 'easy') return 'MediumSeaGreen';
|
|
if (n === 'normal') return '#59b0f4';
|
|
if (n === 'hard') return 'tomato';
|
|
if (n === 'expert') return '#bf2a42';
|
|
if (n === 'expertplus' || n === 'expert+' || n === 'ex+' ) return '#8f48db';
|
|
return '#8f48db';
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeDifficultyName(value: number | string | null | undefined): string {
|
|
if (value === null || value === undefined) return 'ExpertPlus';
|
|
if (typeof value === 'string') {
|
|
const v = value.toLowerCase();
|
|
if (v.includes('expertplus') || v === 'expertplus' || v === 'ex+' || v.includes('ex+')) return 'ExpertPlus';
|
|
if (v.includes('expert')) return 'Expert';
|
|
if (v.includes('hard')) return 'Hard';
|
|
if (v.includes('normal')) return 'Normal';
|
|
if (v.includes('easy')) return 'Easy';
|
|
return value;
|
|
}
|
|
switch (value) {
|
|
case 1:
|
|
return 'Easy';
|
|
case 3:
|
|
return 'Normal';
|
|
case 5:
|
|
return 'Hard';
|
|
case 7:
|
|
return 'Expert';
|
|
case 9:
|
|
return 'ExpertPlus';
|
|
default:
|
|
return 'ExpertPlus';
|
|
}
|
|
}
|
|
|
|
function parseTimeset(ts: string | number | undefined): number {
|
|
if (ts === undefined) return 0;
|
|
if (typeof ts === 'number') return ts;
|
|
const n = Number(ts);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
function getCutoffEpochFromMonths(months: number | string): number {
|
|
const m = Number(months) || 0;
|
|
const seconds = Math.max(0, m) * 30 * 24 * 60 * 60; // approx 30 days per month
|
|
return Math.floor(Date.now() / 1000) - seconds;
|
|
}
|
|
|
|
async function fetchAllRecentScores(playerId: string, cutoffEpoch: number, maxPages = 15): Promise<BeatLeaderScore[]> {
|
|
const qs = new URLSearchParams({ diff: 'ExpertPlus', cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
|
|
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`);
|
|
const data = (await res.json()) as BeatLeaderScoresResponse;
|
|
return data.data ?? [];
|
|
}
|
|
|
|
|
|
|
|
|
|
function incrementPlaylistCount(): number {
|
|
try {
|
|
const raw = localStorage.getItem('playlist_counts');
|
|
const obj = raw ? (JSON.parse(raw) as Record<string, number>) : {};
|
|
const key = 'beatleader_compare_players';
|
|
const next = (obj[key] ?? 0) + 1;
|
|
obj[key] = next;
|
|
localStorage.setItem('playlist_counts', JSON.stringify(obj));
|
|
return next;
|
|
} catch {
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
function toPlaylistJson(songs: SongItem[]): unknown {
|
|
const count = incrementPlaylistCount();
|
|
const playlistTitle = `beatleader_compare_players-${String(count).padStart(2, '0')}`;
|
|
return {
|
|
playlistTitle,
|
|
playlistAuthor: 'SaberList Tool',
|
|
songs: songs.map((s) => ({
|
|
hash: s.hash,
|
|
difficulties: s.difficulties,
|
|
})),
|
|
description: `A's recent songs not played by B. Generated ${new Date().toISOString()}`,
|
|
allowDuplicates: false,
|
|
customData: {}
|
|
};
|
|
}
|
|
|
|
function downloadPlaylist(): void {
|
|
const payload = toPlaylistJson(results);
|
|
const title = (payload as any).playlistTitle ?? 'playlist';
|
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${title}.bplist`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
async function onCompare(ev: SubmitEvent) {
|
|
ev.preventDefault();
|
|
errorMsg = null;
|
|
results = [];
|
|
const a = playerA.trim();
|
|
const b = playerB.trim();
|
|
if (!a || !b) {
|
|
errorMsg = 'Please enter both Player A and Player B IDs.';
|
|
return;
|
|
}
|
|
|
|
loading = true;
|
|
try {
|
|
const cutoff = getCutoffEpochFromMonths(monthsA);
|
|
const cutoffB = getCutoffEpochFromMonths(monthsB);
|
|
const [aScores, bScores] = await Promise.all([
|
|
fetchAllRecentScores(a, cutoff),
|
|
fetchAllRecentScores(b, cutoffB, 100)
|
|
]);
|
|
|
|
const bLeaderboardIds = new Set<string>();
|
|
const bExpertPlusKeys = new Set<string>(); // `${hashLower}|ExpertPlus|${modeName}`
|
|
for (const s of bScores) {
|
|
const rawHash = s.leaderboard?.song?.hash ?? undefined;
|
|
const hashLower = rawHash ? String(rawHash).toLowerCase() : undefined;
|
|
const lbIdRaw = (s.leaderboard as any)?.id ?? (s.leaderboard as any)?.leaderboardId;
|
|
const lbId = lbIdRaw != null ? String(lbIdRaw) : undefined;
|
|
const bDiffValue = s.leaderboard?.difficulty?.value ?? undefined;
|
|
const bModeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
|
|
const bDiffName = normalizeDifficultyName(bDiffValue);
|
|
if (bDiffName !== 'ExpertPlus') continue; // ignore non-ExpertPlus for B
|
|
if (lbId) bLeaderboardIds.add(lbId);
|
|
if (hashLower) bExpertPlusKeys.add(`${hashLower}|ExpertPlus|${bModeName}`);
|
|
}
|
|
|
|
|
|
const runSeen = new Set<string>(); // avoid duplicates within this run
|
|
|
|
const candidates: SongItem[] = [];
|
|
for (const entry of aScores) {
|
|
const t = parseTimeset(entry.timeset);
|
|
if (!t || t < cutoff) continue;
|
|
|
|
const rawHash = entry.leaderboard?.song?.hash ?? undefined;
|
|
const diffValue = entry.leaderboard?.difficulty?.value ?? undefined;
|
|
const modeName = entry.leaderboard?.difficulty?.modeName ?? 'Standard';
|
|
const leaderboardIdRaw = (entry.leaderboard as any)?.id ?? (entry.leaderboard as any)?.leaderboardId;
|
|
const leaderboardId = leaderboardIdRaw != null ? String(leaderboardIdRaw) : undefined;
|
|
if (!rawHash) continue;
|
|
const hashLower = String(rawHash).toLowerCase();
|
|
const diffName = normalizeDifficultyName(diffValue);
|
|
if (diffName !== 'ExpertPlus') continue; // Only compare ExpertPlus for A
|
|
// B has played ExpertPlus of same map+mode if matching leaderboard or key exists
|
|
if ((leaderboardId && bLeaderboardIds.has(leaderboardId)) || bExpertPlusKeys.has(`${hashLower}|${diffName}|${modeName}`)) continue;
|
|
|
|
|
|
const key = `${rawHash}|${diffName}|${modeName}`;
|
|
if (runSeen.has(key)) continue;
|
|
runSeen.add(key);
|
|
|
|
candidates.push({
|
|
hash: rawHash,
|
|
difficulties: [{ name: diffName, characteristic: modeName ?? 'Standard' }],
|
|
timeset: t,
|
|
leaderboardId
|
|
});
|
|
}
|
|
|
|
candidates.sort((x, y) => y.timeset - x.timeset);
|
|
const limited = candidates; // return all; pagination handled client-side
|
|
|
|
|
|
|
|
results = limited;
|
|
page = 1;
|
|
// Load BeatSaver metadata (covers, titles) for tiles
|
|
loadMetaForResults(limited);
|
|
// Load BeatLeader star ratings per hash/diff
|
|
loadStarsForResults(limited);
|
|
} catch (err) {
|
|
errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
// Try prefill from URL params if present
|
|
const sp = new URLSearchParams(location.search);
|
|
playerA = sp.get('a') ?? '';
|
|
playerB = sp.get('b') ?? '';
|
|
});
|
|
</script>
|
|
|
|
<section class="py-8">
|
|
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Compare Players</h1>
|
|
<p class="mt-2 text-muted">Maps Player A has played that Player B hasn't — configurable lookback.</p>
|
|
|
|
|
|
<form class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 items-end" on:submit|preventDefault={onCompare}>
|
|
<div>
|
|
<label class="block text-sm text-muted">Player A ID (source)
|
|
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerA} placeholder="7656119... or BL ID" required />
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-muted">Player B ID (target)
|
|
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerB} placeholder="7656119... or BL ID" required />
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-muted">Player A lookback (months)
|
|
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="number" min="0" max="120" bind:value={monthsA} />
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm text-muted">Player B lookback (months)
|
|
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="number" min="0" max="120" bind:value={monthsB} />
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<button class="btn-neon" disabled={loading}>
|
|
{#if loading}
|
|
Loading...
|
|
{:else}
|
|
Compare
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{#if errorMsg}
|
|
<div class="mt-4 text-danger">{errorMsg}</div>
|
|
{/if}
|
|
|
|
{#if results.length > 0}
|
|
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="flex items-center gap-3 text-sm text-muted">
|
|
<span>{results.length} songs</span>
|
|
<span>·</span>
|
|
<label class="flex items-center gap-2">Sort
|
|
<select class="rounded-md border border-white/10 bg-transparent px-2 py-1 text-sm" bind:value={sortBy}>
|
|
<option value="date">Date</option>
|
|
<option value="difficulty">Difficulty</option>
|
|
</select>
|
|
</label>
|
|
<label class="flex items-center gap-2">Dir
|
|
<select class="rounded-md border border-white/10 bg-transparent px-2 py-1 text-sm" bind:value={sortDir}>
|
|
<option value="desc">Desc</option>
|
|
<option value="asc">Asc</option>
|
|
</select>
|
|
</label>
|
|
<label class="flex items-center gap-2">Page size
|
|
<select class="rounded-md border border-white/10 bg-transparent px-2 py-1 text-sm" bind:value={pageSize}>
|
|
<option value={12}>12</option>
|
|
<option value={24}>24</option>
|
|
<option value={36}>36</option>
|
|
<option value={48}>48</option>
|
|
</select>
|
|
</label>
|
|
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<button class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={downloadPlaylist}>Download .bplist</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if loadingMeta}
|
|
<div class="mt-2 text-xs text-muted">Loading covers…</div>
|
|
{/if}
|
|
{#if loadingStars}
|
|
<div class="mt-2 text-xs text-muted">Loading star ratings…</div>
|
|
{/if}
|
|
|
|
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
{#each pageItems as item}
|
|
<article class="card-surface overflow-hidden">
|
|
<div class="aspect-square bg-black/30">
|
|
{#if metaByHash[item.hash]?.coverURL}
|
|
<img
|
|
src={metaByHash[item.hash].coverURL}
|
|
alt={metaByHash[item.hash]?.songName ?? item.hash}
|
|
loading="lazy"
|
|
class="h-full w-full object-cover"
|
|
/>
|
|
{:else}
|
|
<div class="h-full w-full flex items-center justify-center text-xs text-muted">No cover</div>
|
|
{/if}
|
|
</div>
|
|
<div class="p-3">
|
|
<div class="font-semibold truncate" title={metaByHash[item.hash]?.songName ?? item.hash}>
|
|
{metaByHash[item.hash]?.songName ?? item.hash}
|
|
</div>
|
|
{#if metaByHash[item.hash]?.mapper}
|
|
<div class="mt-0.5 text-xs text-muted truncate">{metaByHash[item.hash]?.mapper}</div>
|
|
{/if}
|
|
<div class="mt-2 flex items-center justify-between text-[11px]">
|
|
<span class="rounded bg-white/10 px-2 py-0.5">
|
|
{item.difficulties[0]?.characteristic ?? 'Standard'} ·
|
|
<span class="rounded px-1 ml-1" style="background-color: {difficultyToColor(item.difficulties[0]?.name)}; color: #fff;">
|
|
{item.difficulties[0]?.name}
|
|
</span>
|
|
</span>
|
|
<span class="text-muted">{new Date(item.timeset * 1000).toLocaleDateString()}</span>
|
|
</div>
|
|
{#if starsByKey[`${item.hash}|${item.difficulties[0]?.name ?? 'ExpertPlus'}|${item.difficulties[0]?.characteristic ?? 'Standard'}`]?.stars}
|
|
<div class="mt-1 text-xs">
|
|
{#key `${item.hash}|${item.difficulties[0]?.name}|${item.difficulties[0]?.characteristic}`}
|
|
<span title="BeatLeader star rating">★ {starsByKey[`${item.hash}|${item.difficulties[0]?.name ?? 'ExpertPlus'}|${item.difficulties[0]?.characteristic ?? 'Standard'}`]?.stars?.toFixed(2)}</span>
|
|
{/key}
|
|
</div>
|
|
{/if}
|
|
<div class="mt-3 flex items-center gap-2">
|
|
<div class="w-1/2 flex flex-wrap gap-2">
|
|
<a
|
|
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
|
|
href={item.leaderboardId
|
|
? `https://beatleader.com/leaderboard/global/${item.leaderboardId}`
|
|
: `https://beatleader.com/leaderboard/global/${item.hash}?diff=${encodeURIComponent(item.difficulties[0]?.name ?? 'ExpertPlus')}&mode=${encodeURIComponent(item.difficulties[0]?.characteristic ?? 'Standard')}`}
|
|
target="_blank"
|
|
rel="noopener"
|
|
title="Open in BeatLeader"
|
|
>BL</a
|
|
>
|
|
<a
|
|
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
|
|
href={metaByHash[item.hash]?.key ? `https://beatsaver.com/maps/${metaByHash[item.hash]?.key}` : `https://beatsaver.com/search/hash/${item.hash}`}
|
|
target="_blank"
|
|
rel="noopener"
|
|
title="Open in BeatSaver"
|
|
>BSR</a
|
|
>
|
|
<button
|
|
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20 disabled:opacity-50"
|
|
class:lit-up={litButtons.has(`bsr-${item.hash}`)}
|
|
on:click={() => { const key = metaByHash[item.hash]?.key; if (key) copyBsrCommand(key, item.hash); }}
|
|
disabled={!metaByHash[item.hash]?.key}
|
|
title="Copy !bsr"
|
|
>Copy !bsr</button>
|
|
</div>
|
|
<div class="w-1/2">
|
|
<SongPlayer hash={item.hash} preferBeatLeader={true} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if totalPages > 1}
|
|
<div class="mt-6 flex items-center justify-center gap-2">
|
|
<button class="rounded-md border border-white/10 px-3 py-1 text-sm disabled:opacity-50" on:click={() => (page = Math.max(1, page - 1))} disabled={page === 1}>
|
|
Prev
|
|
</button>
|
|
<span class="text-sm text-muted">Page {page} / {totalPages}</span>
|
|
<button class="rounded-md border border-white/10 px-3 py-1 text-sm disabled:opacity-50" on:click={() => (page = Math.min(totalPages, page + 1))} disabled={page === totalPages}>
|
|
Next
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Toast Notification -->
|
|
{#if showToast}
|
|
<div class="toast-notification" role="status" aria-live="polite">
|
|
{toastMessage}
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.text-danger { color: #dc2626; }
|
|
|
|
/* Toast notification styles */
|
|
.toast-notification {
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: #10b981;
|
|
color: white;
|
|
padding: 12px 16px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
z-index: 1000;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
animation: slideIn 0.3s ease-out;
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateX(100%);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
/* Button lighting effect */
|
|
.lit-up {
|
|
background: linear-gradient(45deg, #10b981, #059669);
|
|
border-color: #10b981;
|
|
color: white;
|
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.5);
|
|
animation: pulse 0.6s ease-out;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0% {
|
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
|
}
|
|
70% {
|
|
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
|
|
}
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
|
}
|
|
}
|
|
</style>
|
|
|
|
|