Polish inputs and make re-usable components

This commit is contained in:
pleb 2025-10-29 23:26:40 -07:00
parent 8e86b65a45
commit 0d404a8d84
6 changed files with 461 additions and 280 deletions

View File

@ -9,6 +9,7 @@
export let loading = false; export let loading = false;
export let hasResults = false; export let hasResults = false;
export let oncompare: (() => void) | undefined = undefined; export let oncompare: (() => void) | undefined = undefined;
export let currentPlayer: BeatLeaderPlayerProfile | null = null;
let initialized = false; let initialized = false;
@ -17,13 +18,36 @@
let playerAProfile: BeatLeaderPlayerProfile | null = null; let playerAProfile: BeatLeaderPlayerProfile | null = null;
let playerBProfile: BeatLeaderPlayerProfile | null = null; let playerBProfile: BeatLeaderPlayerProfile | null = null;
// Preview profiles (loaded as user types)
let previewAProfile: BeatLeaderPlayerProfile | null = null;
let previewBProfile: BeatLeaderPlayerProfile | null = null;
let loadingPreviewA = false;
let loadingPreviewB = false;
// Load from URL params on mount // Load from URL params on mount
onMount(() => { onMount(() => {
if (browser) { if (browser) {
const sp = new URLSearchParams(location.search); const sp = new URLSearchParams(location.search);
playerA = sp.get('a') ?? playerA; const urlA = sp.get('a');
playerB = sp.get('b') ?? playerB; const urlB = sp.get('b');
// Prefill playerA with current player if not in URL
if (!urlA && !playerA && currentPlayer?.id) {
playerA = currentPlayer.id;
} else {
playerA = urlA ?? playerA;
}
playerB = urlB ?? playerB;
initialized = true; initialized = true;
// Load initial previews if IDs are already present
if (playerA.trim()) {
void loadPreviewProfile('A', playerA.trim());
}
if (playerB.trim()) {
void loadPreviewProfile('B', playerB.trim());
}
} }
}); });
@ -67,6 +91,88 @@
playerBProfile = pb; playerBProfile = pb;
} }
// Debounced preview loading
let previewDebounceTimerA: ReturnType<typeof setTimeout> | null = null;
let previewDebounceTimerB: ReturnType<typeof setTimeout> | null = null;
async function loadPreviewProfile(player: 'A' | 'B', id: string): Promise<void> {
if (!id || id.trim().length < 3) {
if (player === 'A') previewAProfile = null;
else previewBProfile = null;
return;
}
const trimmed = id.trim();
if (player === 'A') {
loadingPreviewA = true;
} else {
loadingPreviewB = true;
}
try {
const profile = await fetchPlayerProfile(trimmed);
// Only update if this is still the current player ID
const currentId = player === 'A' ? playerA.trim() : playerB.trim();
if (currentId === trimmed) {
if (player === 'A') {
previewAProfile = profile;
} else {
previewBProfile = profile;
}
}
} catch {
// Silently fail - don't show errors for preview loading
const currentId = player === 'A' ? playerA.trim() : playerB.trim();
if (currentId === trimmed) {
if (player === 'A') {
previewAProfile = null;
} else {
previewBProfile = null;
}
}
} finally {
if (player === 'A') {
loadingPreviewA = false;
} else {
loadingPreviewB = false;
}
}
}
// Watch for changes to playerA and debounce preview loading
$: if (initialized && browser) {
const trimmed = playerA.trim();
if (previewDebounceTimerA) clearTimeout(previewDebounceTimerA);
if (trimmed && trimmed.length >= 3) {
previewDebounceTimerA = setTimeout(() => {
void loadPreviewProfile('A', trimmed);
}, 800);
} else {
previewAProfile = null;
}
}
// Watch for changes to playerB and debounce preview loading
$: if (initialized && browser) {
const trimmed = playerB.trim();
if (previewDebounceTimerB) clearTimeout(previewDebounceTimerB);
if (trimmed && trimmed.length >= 3) {
previewDebounceTimerB = setTimeout(() => {
void loadPreviewProfile('B', trimmed);
}, 800);
} else {
previewBProfile = null;
}
}
// Computed: which profile to display (post-compare takes priority over preview)
$: displayProfileA = hasCompared ? playerAProfile : previewAProfile;
$: displayProfileB = hasCompared ? playerBProfile : previewBProfile;
$: showCardA = !!(displayProfileA || (loadingPreviewA && playerA.trim().length >= 3));
$: showCardB = !!(displayProfileB || (loadingPreviewB && playerB.trim().length >= 3));
async function handleSubmit(e: Event) { async function handleSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
hasCompared = true; hasCompared = true;
@ -90,31 +196,33 @@
required required
/> />
</label> </label>
{#if $$slots['player-a-card']}
<slot name="player-a-card" />
{:else if showCardA}
<div class="player-card-wrapper">
{#if displayProfileA}
<PlayerCard
name={displayProfileA.name ?? 'Player A'}
avatar={displayProfileA.avatar ?? null}
country={displayProfileA.country ?? null}
rank={displayProfileA.rank ?? null}
showRank={typeof displayProfileA.rank === 'number'}
width="100%"
avatarSize={56}
techPp={displayProfileA.techPp}
accPp={displayProfileA.accPp}
passPp={displayProfileA.passPp}
playerId={displayProfileA.id ?? null}
gradientId="compare-player-a"
/>
{:else if loadingPreviewA}
<div class="loading-profile">Loading player...</div>
{:else if hasCompared}
<div class="empty-profile">Player A profile not found</div>
{/if}
</div>
{/if}
</div> </div>
{#if $$slots['player-a-card']}
<slot name="player-a-card" />
{:else if hasCompared}
<div class="player-card-wrapper">
{#if playerAProfile}
<PlayerCard
name={playerAProfile.name ?? 'Player A'}
avatar={playerAProfile.avatar ?? null}
country={playerAProfile.country ?? null}
rank={playerAProfile.rank ?? null}
showRank={typeof playerAProfile.rank === 'number'}
width="100%"
avatarSize={56}
techPp={playerAProfile.techPp}
accPp={playerAProfile.accPp}
passPp={playerAProfile.passPp}
playerId={playerAProfile.id ?? null}
gradientId="compare-player-a"
/>
{:else}
<div class="empty-profile">Player A profile not found</div>
{/if}
</div>
{/if}
</div> </div>
<div class="player-column"> <div class="player-column">
@ -127,31 +235,33 @@
required required
/> />
</label> </label>
{#if $$slots['player-b-card']}
<slot name="player-b-card" />
{:else if showCardB}
<div class="player-card-wrapper">
{#if displayProfileB}
<PlayerCard
name={displayProfileB.name ?? 'Player B'}
avatar={displayProfileB.avatar ?? null}
country={displayProfileB.country ?? null}
rank={displayProfileB.rank ?? null}
showRank={typeof displayProfileB.rank === 'number'}
width="100%"
avatarSize={56}
techPp={displayProfileB.techPp}
accPp={displayProfileB.accPp}
passPp={displayProfileB.passPp}
playerId={displayProfileB.id ?? null}
gradientId="compare-player-b"
/>
{:else if loadingPreviewB}
<div class="loading-profile">Loading player...</div>
{:else if hasCompared}
<div class="empty-profile">Player B profile not found</div>
{/if}
</div>
{/if}
</div> </div>
{#if $$slots['player-b-card']}
<slot name="player-b-card" />
{:else if hasCompared}
<div class="player-card-wrapper">
{#if playerBProfile}
<PlayerCard
name={playerBProfile.name ?? 'Player B'}
avatar={playerBProfile.avatar ?? null}
country={playerBProfile.country ?? null}
rank={playerBProfile.rank ?? null}
showRank={typeof playerBProfile.rank === 'number'}
width="100%"
avatarSize={56}
techPp={playerBProfile.techPp}
accPp={playerBProfile.accPp}
passPp={playerBProfile.passPp}
playerId={playerBProfile.id ?? null}
gradientId="compare-player-b"
/>
{:else}
<div class="empty-profile">Player B profile not found</div>
{/if}
</div>
{/if}
</div> </div>
</div> </div>
@ -224,5 +334,23 @@
box-shadow: 0 0 20px rgba(34, 211, 238, 0.35), 0 0 0 2px rgba(34, 211, 238, 0.1); box-shadow: 0 0 20px rgba(34, 211, 238, 0.35), 0 0 0 2px rgba(34, 211, 238, 0.1);
color: rgba(255, 255, 255, 1); color: rgba(255, 255, 255, 1);
} }
.player-card-wrapper {
margin-top: 1rem;
}
.empty-profile {
padding: 2rem;
text-align: center;
color: rgba(148, 163, 184, 0.7);
font-size: 0.9rem;
}
.loading-profile {
padding: 2rem;
text-align: center;
color: rgba(148, 163, 184, 0.7);
font-size: 0.9rem;
}
</style> </style>

View File

@ -104,8 +104,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,
'beatleader-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT, 'player-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
'beatleader-playlist-gap': DEFAULT_PRIVATE_TOOL_REQUIREMENT 'player-playlist-gaps': 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;
@ -320,6 +320,27 @@ export async function fetchAllRecentScoresAllDiffs(
return Array.from(merged.values()); return Array.from(merged.values());
} }
/**
* Fetch all scores for a player (no time limit) by paginating through the BeatLeader API
* Useful for playlist gap analysis where we need to check all historical plays
*/
export async function fetchAllPlayerScores(playerId: string, maxPages = 200): Promise<BeatLeaderScore[]> {
const pageSize = 100;
let page = 1;
const all: BeatLeaderScore[] = [];
while (page <= maxPages) {
const url = `/api/beatleader/player/${encodeURIComponent(playerId)}?scores=1&count=${pageSize}&page=${page}&sortBy=date&order=desc`;
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;
const batch = data.data ?? [];
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
}
// ============================================================================ // ============================================================================
// 4. Data Processing Helpers // 4. Data Processing Helpers
// ============================================================================ // ============================================================================

View File

@ -5,8 +5,8 @@
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#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: 'BeatLeader Playlist Gap', href: '/tools/beatleader-playlist-gap', 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: 'BeatLeader Head-to-Head', href: '/tools/beatleader-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' }
] 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>

View File

@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import MapCard from '$lib/components/MapCard.svelte'; import MapCard from '$lib/components/MapCard.svelte';
import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte'; import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte';
import PlayerCard from '$lib/components/PlayerCard.svelte';
import HasToolAccess from '$lib/components/HasToolAccess.svelte'; import HasToolAccess from '$lib/components/HasToolAccess.svelte';
import { import {
type MapMeta, type MapMeta,
type StarInfo, type StarInfo,
type BeatLeaderScore, type BeatLeaderScore,
type BeatLeaderScoresResponse,
type Difficulty, type Difficulty,
type BeatLeaderPlayerProfile, type BeatLeaderPlayerProfile,
loadMetaForHashes, loadMetaForHashes,
@ -17,7 +15,7 @@
getCutoffEpochFromMonths, getCutoffEpochFromMonths,
toPlaylistJson, toPlaylistJson,
downloadPlaylist, downloadPlaylist,
ONE_YEAR_SECONDS, fetchAllRecentScoresForDiff,
TOOL_REQUIREMENTS TOOL_REQUIREMENTS
} from '$lib/utils/plebsaber-utils'; } from '$lib/utils/plebsaber-utils';
@ -42,12 +40,8 @@
let errorMsg: string | null = null; let errorMsg: string | null = null;
let results: SongItem[] = []; let results: SongItem[] = [];
let loadingMeta = false; let loadingMeta = false;
let playerAProfile: BeatLeaderPlayerProfile | null = null;
let playerBProfile: BeatLeaderPlayerProfile | null = null;
let hasCompared = false;
// Sorting and pagination state // Sorting and pagination state
let sortBy: 'date' | 'difficulty' = 'date';
let sortDir: 'asc' | 'desc' = 'desc'; let sortDir: 'asc' | 'desc' = 'desc';
let page = 1; let page = 1;
let pageSize: number | string = 24; let pageSize: number | string = 24;
@ -57,16 +51,9 @@
const monthsA = 24; // default 24 months const monthsA = 24; // default 24 months
const monthsB = 24; // default 24 months const monthsB = 24; // default 24 months
// Derived lists // Derived lists - always sort by date
$: sortedResults = [...results].sort((a, b) => { $: sortedResults = [...results].sort((a, b) => {
let cmp = 0; const cmp = a.timeset - b.timeset;
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; return sortDir === 'asc' ? cmp : -cmp;
}); });
$: totalPages = Math.max(1, Math.ceil(sortedResults.length / pageSizeNum)); $: totalPages = Math.max(1, Math.ceil(sortedResults.length / pageSizeNum));
@ -97,26 +84,6 @@
loadingStars = false; loadingStars = false;
} }
async function fetchPlayerProfile(playerId: string): Promise<BeatLeaderPlayerProfile | null> {
try {
const res = await fetch(`https://api.beatleader.xyz/player/${encodeURIComponent(playerId)}`);
if (!res.ok) return null;
return (await res.json()) as BeatLeaderPlayerProfile;
} catch {
return null;
}
}
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 handleDownloadPlaylist(): void { function handleDownloadPlaylist(): void {
@ -131,9 +98,6 @@
async function onCompare() { async function onCompare() {
errorMsg = null; errorMsg = null;
results = []; results = [];
playerAProfile = null;
playerBProfile = null;
hasCompared = false;
const a = playerA.trim(); const a = playerA.trim();
const b = playerB.trim(); const b = playerB.trim();
if (!a || !b) { if (!a || !b) {
@ -145,17 +109,11 @@
try { try {
const cutoff = getCutoffEpochFromMonths(monthsA); const cutoff = getCutoffEpochFromMonths(monthsA);
const cutoffB = getCutoffEpochFromMonths(monthsB); const cutoffB = getCutoffEpochFromMonths(monthsB);
const [aScores, bScores, profileA, profileB] = await Promise.all([ const [aScores, bScores] = await Promise.all([
fetchAllRecentScores(a, cutoff), fetchAllRecentScoresForDiff(a, cutoff, 'ExpertPlus', 15),
fetchAllRecentScores(b, cutoffB, 100), fetchAllRecentScoresForDiff(b, cutoffB, 'ExpertPlus', 100)
fetchPlayerProfile(a),
fetchPlayerProfile(b)
]); ]);
playerAProfile = profileA;
playerBProfile = profileB;
hasCompared = true;
const bLeaderboardIds = new Set<string>(); const bLeaderboardIds = new Set<string>();
const bExpertPlusKeys = new Set<string>(); // `${hashLower}|ExpertPlus|${modeName}` const bExpertPlusKeys = new Set<string>(); // `${hashLower}|ExpertPlus|${modeName}`
for (const s of bScores) { for (const s of bScores) {
@ -224,57 +182,7 @@
<p class="mt-2 text-muted">Maps Player A has played that Player B hasn't — configurable lookback.</p> <p class="mt-2 text-muted">Maps Player A has played that Player B hasn't — configurable lookback.</p>
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}> <HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}>
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={results.length > 0} oncompare={onCompare}> <PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={results.length > 0} oncompare={onCompare} currentPlayer={playerProfile}>
<svelte:fragment slot="player-a-card">
{#if hasCompared}
<div class="player-card-wrapper">
{#if playerAProfile}
<PlayerCard
name={playerAProfile.name ?? 'Player A'}
avatar={playerAProfile.avatar ?? null}
country={playerAProfile.country ?? null}
rank={playerAProfile.rank ?? null}
showRank={typeof playerAProfile.rank === 'number'}
width="100%"
avatarSize={56}
techPp={playerAProfile.techPp}
accPp={playerAProfile.accPp}
passPp={playerAProfile.passPp}
playerId={playerAProfile.id ?? null}
gradientId="compare-player-a"
/>
{:else}
<div class="empty-profile">Player A profile not found</div>
{/if}
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="player-b-card">
{#if hasCompared}
<div class="player-card-wrapper">
{#if playerBProfile}
<PlayerCard
name={playerBProfile.name ?? 'Player B'}
avatar={playerBProfile.avatar ?? null}
country={playerBProfile.country ?? null}
rank={playerBProfile.rank ?? null}
showRank={typeof playerBProfile.rank === 'number'}
width="100%"
avatarSize={56}
techPp={playerBProfile.techPp}
accPp={playerBProfile.accPp}
passPp={playerBProfile.passPp}
playerId={playerBProfile.id ?? null}
gradientId="compare-player-b"
/>
{:else}
<div class="empty-profile">Player B profile not found</div>
{/if}
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="extra-buttons"> <svelte:fragment slot="extra-buttons">
{#if results.length > 0} {#if results.length > 0}
<button type="button" class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={handleDownloadPlaylist}>Download .bplist</button> <button type="button" class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={handleDownloadPlaylist}>Download .bplist</button>
@ -292,17 +200,10 @@
{results.length} songs {results.length} songs
</div> </div>
<div class="flex items-center gap-4 text-sm text-muted flex-wrap justify-end"> <div class="flex items-center gap-4 text-sm text-muted flex-wrap justify-end">
<label class="flex items-center gap-3"> <label class="flex items-center gap-2">
<span class="filter-label">Options:</span>
<select class="neon-select" 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="neon-select" bind:value={sortDir}> <select class="neon-select" bind:value={sortDir}>
<option value="desc">Desc</option> <option value="desc">Newest First</option>
<option value="asc">Asc</option> <option value="asc">Oldest First</option>
</select> </select>
</label> </label>
<label class="flex items-center gap-2">Page size <label class="flex items-center gap-2">Page size
@ -360,20 +261,6 @@
<style> <style>
.text-danger { color: #dc2626; } .text-danger { color: #dc2626; }
/* Filter label styling */
.filter-label {
font-size: 1em;
letter-spacing: 0.05em;
font-weight: 700;
color: rgba(255, 0, 170, 0.95);
text-shadow: 0 0 8px rgba(255, 0, 170, 0.3);
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
font-family: var(--font-display);
line-height: 1;
}
/* Neon select dropdown */ /* Neon select dropdown */
.neon-select { .neon-select {
border-radius: 0.375rem; border-radius: 0.375rem;
@ -401,26 +288,6 @@
color: #fff; color: #fff;
} }
.player-card-wrapper {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 0.75rem;
padding: 1.25rem;
background: linear-gradient(160deg, rgba(15, 23, 42, 0.6), rgba(8, 12, 24, 0.85));
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), inset 0 0 0 1px rgba(34, 211, 238, 0.05);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.player-card-wrapper:hover {
border-color: rgba(34, 211, 238, 0.25);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35), inset 0 0 0 1px rgba(34, 211, 238, 0.15);
}
.empty-profile {
padding: 2rem;
text-align: center;
color: rgba(148, 163, 184, 0.7);
font-size: 0.9rem;
}
</style> </style>

View File

@ -41,7 +41,7 @@
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null; adminPlayer: BeatLeaderPlayerProfile | null }; export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null; adminPlayer: BeatLeaderPlayerProfile | null };
const requirement = TOOL_REQUIREMENTS['beatleader-headtohead']; const requirement = TOOL_REQUIREMENTS['player-headtohead'];
$: playerProfile = data?.player ?? null; $: playerProfile = data?.player ?? null;
$: adminRank = data?.adminRank ?? null; $: adminRank = data?.adminRank ?? null;
@ -60,15 +60,35 @@
let sortDir: 'asc' | 'desc' = 'desc'; let sortDir: 'asc' | 'desc' = 'desc';
let page = 1; let page = 1;
let pageSize: number | string = 24; let pageSize: number | string = 24;
let filterWinMargin: string = 'all'; // 'all', '1', '2' let filterWinMargin: string = 'all'; // 'all', 'a1', 'a2', 'b1', 'b2'
$: pageSizeNum = Number(pageSize) || 24; $: pageSizeNum = Number(pageSize) || 24;
$: filtered = (() => { $: filtered = (() => {
if (filterWinMargin === 'all') return items; if (filterWinMargin === 'all') return items;
const margin = Number(filterWinMargin); if (filterWinMargin === 'a1') {
return items.filter(i => { return items.filter(i => {
if (i.accA == null || i.accB == null) return false; if (i.accA == null || i.accB == null) return false;
return i.accA > i.accB && (i.accA - i.accB) > margin; return i.accA > i.accB && (i.accA - i.accB) > 1;
}); });
}
if (filterWinMargin === 'a2') {
return items.filter(i => {
if (i.accA == null || i.accB == null) return false;
return i.accA > i.accB && (i.accA - i.accB) > 2;
});
}
if (filterWinMargin === 'b1') {
return items.filter(i => {
if (i.accA == null || i.accB == null) return false;
return i.accB > i.accA && (i.accB - i.accA) > 1;
});
}
if (filterWinMargin === 'b2') {
return items.filter(i => {
if (i.accA == null || i.accB == null) return false;
return i.accB > i.accA && (i.accB - i.accA) > 2;
});
}
return items;
})(); })();
$: sorted = [...filtered].sort((a, b) => (sortDir === 'asc' ? a.timeset - b.timeset : b.timeset - a.timeset)); $: sorted = [...filtered].sort((a, b) => (sortDir === 'asc' ? a.timeset - b.timeset : b.timeset - a.timeset));
$: totalPages = Math.max(1, Math.ceil(sorted.length / pageSizeNum)); $: totalPages = Math.max(1, Math.ceil(sorted.length / pageSizeNum));
@ -166,7 +186,7 @@
const size = sp.get('size') ?? sp.get('ps'); const size = sp.get('size') ?? sp.get('ps');
if (size) pageSize = Number(size) || pageSize; if (size) pageSize = Number(size) || pageSize;
const filter = sp.get('filter'); const filter = sp.get('filter');
if (filter && ['all', '1', '2'].includes(filter)) filterWinMargin = filter; if (filter && ['all', 'a1', 'a2', 'b1', 'b2'].includes(filter)) filterWinMargin = filter;
initialized = true; initialized = true;
}); });
@ -292,11 +312,11 @@
</script> </script>
<section class="py-8"> <section class="py-8">
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Head-to-Head</h1> <h1 class="font-display text-3xl sm:text-4xl">Player Head-to-Head</h1>
<p class="mt-2 text-muted">Paginated head-to-head results on the same map and difficulty.</p> <p class="mt-2 text-muted">Paginated head-to-head results on the same map and difficulty.</p>
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}> <HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}>
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={items.length > 0} oncompare={onCompare} /> <PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={items.length > 0} oncompare={onCompare} currentPlayer={playerProfile} />
{#if errorMsg} {#if errorMsg}
<div class="mt-4 text-danger">{errorMsg}</div> <div class="mt-4 text-danger">{errorMsg}</div>
@ -435,8 +455,10 @@
<span class="filter-label {filterWinMargin !== 'all' ? 'active' : ''}">Options:</span> <span class="filter-label {filterWinMargin !== 'all' ? 'active' : ''}">Options:</span>
<select class="neon-select {filterWinMargin !== 'all' ? 'active' : ''}" bind:value={filterWinMargin}> <select class="neon-select {filterWinMargin !== 'all' ? 'active' : ''}" bind:value={filterWinMargin}>
<option value="all">All Songs</option> <option value="all">All Songs</option>
<option value="1">{idShortA} wins by &gt;1%</option> <option value="a1">{idShortA} wins by &gt;1%</option>
<option value="2">{idShortA} wins by &gt;2%</option> <option value="a2">{idShortA} wins by &gt;2%</option>
<option value="b1">{idShortB} wins by &gt;1%</option>
<option value="b2">{idShortB} wins by &gt;2%</option>
</select> </select>
</label> </label>
<label class="flex items-center gap-2">Dir <label class="flex items-center gap-2">Dir

View File

@ -1,10 +1,14 @@
<script lang="ts"> <script lang="ts">
import SongPlayer from '$lib/components/SongPlayer.svelte'; import SongPlayer from '$lib/components/SongPlayer.svelte';
import HasToolAccess from '$lib/components/HasToolAccess.svelte'; import HasToolAccess from '$lib/components/HasToolAccess.svelte';
import PlayerCard from '$lib/components/PlayerCard.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
TOOL_REQUIREMENTS, TOOL_REQUIREMENTS,
type BeatLeaderPlayerProfile type BeatLeaderPlayerProfile,
fetchAllPlayerScores,
fetchBeatSaverMeta,
type MapMeta
} from '$lib/utils/plebsaber-utils'; } from '$lib/utils/plebsaber-utils';
type Difficulty = { type Difficulty = {
@ -39,20 +43,9 @@
}; };
}; };
type BeatLeaderScoresResponse = {
data?: BeatLeaderScore[];
};
type MapMeta = {
songName?: string;
key?: string;
coverURL?: string;
mapper?: string;
};
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null; adminPlayer: BeatLeaderPlayerProfile | null }; export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null; adminPlayer: BeatLeaderPlayerProfile | null };
const requirement = TOOL_REQUIREMENTS['beatleader-playlist-gap']; const requirement = TOOL_REQUIREMENTS['player-playlist-gaps'];
$: playerProfile = data?.player ?? null; $: playerProfile = data?.player ?? null;
$: adminRank = data?.adminRank ?? null; $: adminRank = data?.adminRank ?? null;
@ -70,6 +63,11 @@
let metaByHash: Record<string, MapMeta> = {}; let metaByHash: Record<string, MapMeta> = {};
let loadingMeta = false; let loadingMeta = false;
let blUrlByHash: Record<string, string> = {}; let blUrlByHash: Record<string, string> = {};
let hasAnalyzed = false;
let analyzedPlayerProfile: BeatLeaderPlayerProfile | null = null;
let previewPlayerProfile: BeatLeaderPlayerProfile | null = null;
let loadingPreview = false;
// Persist playerId across refreshes (client-side only) // Persist playerId across refreshes (client-side only)
let hasMounted = false; let hasMounted = false;
@ -105,7 +103,18 @@
} }
onMount(() => { onMount(() => {
try { try {
loadPlayerIdFromStorage(); // Prefill with logged-in player ID if available
if (!playerId && playerProfile?.id) {
playerId = playerProfile.id;
// Load preview immediately for prefilled user
void loadPreviewProfile(playerProfile.id);
} else {
loadPlayerIdFromStorage();
// Load preview for stored player ID
if (playerId && playerId.trim()) {
void loadPreviewProfile(playerId.trim());
}
}
} finally { } finally {
hasMounted = true; hasMounted = true;
} }
@ -119,6 +128,47 @@
} }
} }
// Debounced preview loading
let previewDebounceTimer: ReturnType<typeof setTimeout> | null = null;
$: {
if (hasMounted && playerId) {
const trimmed = playerId.trim();
if (previewDebounceTimer) clearTimeout(previewDebounceTimer);
if (trimmed) {
// Debounce for 800ms to avoid excessive requests
previewDebounceTimer = setTimeout(() => {
void loadPreviewProfile(trimmed);
}, 800);
} else {
previewPlayerProfile = null;
}
}
}
async function loadPreviewProfile(id: string): Promise<void> {
if (!id || id.trim().length < 3) {
previewPlayerProfile = null;
return;
}
loadingPreview = true;
try {
const profile = await fetchPlayerProfile(id);
// Only update if this is still the current player ID
if (playerId.trim() === id) {
previewPlayerProfile = profile;
}
} catch {
// Silently fail - don't show errors for preview loading
if (playerId.trim() === id) {
previewPlayerProfile = null;
}
} finally {
loadingPreview = false;
}
}
async function onFileChange(ev: Event) { async function onFileChange(ev: Event) {
const input = ev.target as HTMLInputElement; const input = ev.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
@ -151,39 +201,6 @@
} }
} }
async function fetchAllScoresAnyTime(player: string, maxPages = 200): Promise<BeatLeaderScore[]> {
const pageSize = 100;
let page = 1;
const all: BeatLeaderScore[] = [];
while (page <= maxPages) {
const url = `/api/beatleader/player/${encodeURIComponent(player)}?scores=1&count=${pageSize}&page=${page}&sortBy=date&order=desc`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch scores for ${player}: ${res.status}`);
const data = (await res.json()) as BeatLeaderScoresResponse;
const batch = data.data ?? [];
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
}
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 {
return { coverURL: `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg` };
}
}
async function loadMetaForResults(items: PlaylistSong[]): Promise<void> { async function loadMetaForResults(items: PlaylistSong[]): Promise<void> {
const needed = Array.from(new Set(items.map((i) => i.hash.toLowerCase()))).filter((h) => !metaByHash[h]); const needed = Array.from(new Set(items.map((i) => i.hash.toLowerCase()))).filter((h) => !metaByHash[h]);
@ -313,11 +330,23 @@
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
async function fetchPlayerProfile(id: string): Promise<BeatLeaderPlayerProfile | null> {
try {
const res = await fetch(`https://api.beatleader.xyz/player/${encodeURIComponent(id)}`);
if (!res.ok) return null;
return (await res.json()) as BeatLeaderPlayerProfile;
} catch {
return null;
}
}
async function onAnalyze(ev: SubmitEvent) { async function onAnalyze(ev: SubmitEvent) {
ev.preventDefault(); ev.preventDefault();
errorMsg = null; errorMsg = null;
results = []; results = [];
metaByHash = {}; metaByHash = {};
analyzedPlayerProfile = null;
hasAnalyzed = false;
if (!playerId.trim()) { if (!playerId.trim()) {
errorMsg = 'Please enter a BeatLeader player ID or SteamID64.'; errorMsg = 'Please enter a BeatLeader player ID or SteamID64.';
return; return;
@ -328,7 +357,13 @@
} }
loading = true; loading = true;
try { try {
const scores = await fetchAllScoresAnyTime(playerId.trim(), 150); const [scores, profile] = await Promise.all([
fetchAllPlayerScores(playerId.trim(), 150),
fetchPlayerProfile(playerId.trim())
]);
analyzedPlayerProfile = profile;
hasAnalyzed = true;
const playedHashes = new Set<string>(); const playedHashes = new Set<string>();
for (const s of scores) { for (const s of scores) {
const raw = s.leaderboard?.song?.hash ?? undefined; const raw = s.leaderboard?.song?.hash ?? undefined;
@ -351,37 +386,108 @@
loading = false; loading = false;
} }
} }
function resetForm(): void {
hasAnalyzed = false;
analyzedPlayerProfile = null;
results = [];
metaByHash = {};
blUrlByHash = {};
errorMsg = null;
selectedFileName = null;
parsedTitle = null;
playlistSongs = [];
// Don't reset playerId - let user keep it for next analysis
}
// Computed: which profile to display (analyzed takes priority over preview)
$: displayProfile = hasAnalyzed ? analyzedPlayerProfile : previewPlayerProfile;
$: showPlayerCard = !!(displayProfile || (loadingPreview && playerId.trim().length >= 3));
</script> </script>
<section class="py-8"> <section class="py-8">
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Playlist Gap</h1> <h1 class="font-display text-3xl sm:text-4xl">Player Playlist Gaps</h1>
<p class="mt-2 text-muted">Upload a .bplist and enter a player ID to find songs they have not played.</p> <p class="mt-2 text-muted">Upload a .bplist and enter a player ID to find songs they have not played.</p>
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}> <HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}>
<form class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 items-end" on:submit|preventDefault={onAnalyze}> {#if !hasAnalyzed}
<div class="sm:col-span-2 lg:col-span-2"> <form class="mt-6" on:submit|preventDefault={onAnalyze}>
<label class="block text-sm text-muted">Playlist file (.bplist) <div class="grid gap-4 sm:grid-cols-2 items-end">
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="file" accept=".bplist,application/json" on:change={onFileChange} /> <div>
</label> <label class="block text-sm text-muted">Playlist file (.bplist)
{#if selectedFileName} <input class="mt-1 rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none file-input" type="file" accept=".bplist,application/json" on:change={onFileChange} />
<div class="mt-1 text-xs text-muted">{selectedFileName}{#if parsedTitle} · title: {parsedTitle}{/if} · {playlistSongs.length} songs</div> </label>
{#if selectedFileName}
<div class="mt-1 text-xs text-muted">{selectedFileName}{#if parsedTitle} · title: {parsedTitle}{/if} · {playlistSongs.length} songs</div>
{/if}
</div>
<div class="input-tile">
<label class="block text-sm text-muted">Player ID
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerId} placeholder="7656119... or BL ID" required />
</label>
{#if showPlayerCard}
<div class="player-card-wrapper">
{#if displayProfile}
<PlayerCard
name={displayProfile.name ?? 'Player'}
avatar={displayProfile.avatar ?? null}
country={displayProfile.country ?? null}
rank={displayProfile.rank ?? null}
showRank={typeof displayProfile.rank === 'number'}
width="100%"
avatarSize={56}
techPp={displayProfile.techPp}
accPp={displayProfile.accPp}
passPp={displayProfile.passPp}
playerId={displayProfile.id ?? null}
gradientId="playlist-gap-player-preview"
/>
{:else}
<div class="loading-placeholder">Loading player...</div>
{/if}
</div>
{/if}
</div>
</div>
<div class="mt-4">
<button class="btn-neon" disabled={loading}>
{#if loading}
Loading...
{:else}
Analyze
{/if}
</button>
</div>
</form>
{:else}
<div class="mt-6 flex items-start gap-4">
{#if displayProfile}
<div class="analyzed-player-summary">
<PlayerCard
name={displayProfile.name ?? 'Player'}
avatar={displayProfile.avatar ?? null}
country={displayProfile.country ?? null}
rank={displayProfile.rank ?? null}
showRank={typeof displayProfile.rank === 'number'}
width="100%"
avatarSize={56}
techPp={displayProfile.techPp}
accPp={displayProfile.accPp}
passPp={displayProfile.passPp}
playerId={displayProfile.id ?? null}
gradientId="playlist-gap-player"
/>
</div>
{/if} {/if}
</div> <button
<div> class="rounded-md border border-white/10 px-3 py-2 text-sm hover:bg-white/10 transition-colors"
<label class="block text-sm text-muted">Player ID on:click={resetForm}
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerId} placeholder="7656119... or BL ID" required /> title="Reset and analyze another playlist"
</label> >
</div> Reset
<div>
<button class="btn-neon" disabled={loading}>
{#if loading}
Loading...
{:else}
Analyze
{/if}
</button> </button>
</div> </div>
</form> {/if}
{#if errorMsg} {#if errorMsg}
<div class="mt-4 text-danger">{errorMsg}</div> <div class="mt-4 text-danger">{errorMsg}</div>
@ -442,7 +548,7 @@
target="_blank" target="_blank"
rel="noopener" rel="noopener"
title="Open in BeatSaver" title="Open in BeatSaver"
>BSR</a> >BS</a>
<button <button
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20 disabled:opacity-50" class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20 disabled:opacity-50"
on:click={() => { const key = metaByHash[item.hash.toLowerCase()]?.key; if (key) navigator.clipboard.writeText(`!bsr ${key}`); }} on:click={() => { const key = metaByHash[item.hash.toLowerCase()]?.key; if (key) navigator.clipboard.writeText(`!bsr ${key}`); }}
@ -463,6 +569,43 @@
.btn-neon { cursor: pointer; } .btn-neon { cursor: pointer; }
.card-surface { border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); } .card-surface { border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); }
.text-muted { color: rgba(255,255,255,0.7); } .text-muted { color: rgba(255,255,255,0.7); }
.file-input {
width: 15em;
}
.input-tile {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 0.75rem;
padding: 1.25rem;
background: linear-gradient(160deg, rgba(15, 23, 42, 0.6), rgba(8, 12, 24, 0.85));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: border-color 0.2s ease;
}
.input-tile:hover {
border-color: rgba(148, 163, 184, 0.3);
}
.player-card-wrapper {
margin-top: 1rem;
}
.loading-placeholder {
padding: 2rem;
text-align: center;
color: rgba(148, 163, 184, 0.7);
font-size: 0.9rem;
}
.analyzed-player-summary {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 0.75rem;
padding: 1.25rem;
background: linear-gradient(160deg, rgba(15, 23, 42, 0.6), rgba(8, 12, 24, 0.85));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 600px;
}
</style> </style>