Polish inputs and make re-usable components
This commit is contained in:
parent
8e86b65a45
commit
0d404a8d84
@ -9,6 +9,7 @@
|
||||
export let loading = false;
|
||||
export let hasResults = false;
|
||||
export let oncompare: (() => void) | undefined = undefined;
|
||||
export let currentPlayer: BeatLeaderPlayerProfile | null = null;
|
||||
|
||||
let initialized = false;
|
||||
|
||||
@ -17,13 +18,36 @@
|
||||
let playerAProfile: 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
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
const sp = new URLSearchParams(location.search);
|
||||
playerA = sp.get('a') ?? playerA;
|
||||
playerB = sp.get('b') ?? playerB;
|
||||
const urlA = sp.get('a');
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
e.preventDefault();
|
||||
hasCompared = true;
|
||||
@ -90,31 +196,33 @@
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
{#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 class="player-column">
|
||||
@ -127,31 +235,33 @@
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
{#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>
|
||||
|
||||
@ -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);
|
||||
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>
|
||||
|
||||
|
||||
@ -104,8 +104,8 @@ const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = {
|
||||
|
||||
export const TOOL_REQUIREMENTS = {
|
||||
'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'beatleader-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'beatleader-playlist-gap': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
||||
'player-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'player-playlist-gaps': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
||||
} as const satisfies Record<string, ToolRequirement>;
|
||||
|
||||
export type ToolKey = keyof typeof TOOL_REQUIREMENTS;
|
||||
@ -320,6 +320,27 @@ export async function fetchAllRecentScoresAllDiffs(
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each [
|
||||
{ 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: 'BeatLeader Head-to-Head', href: '/tools/beatleader-headtohead', desc: 'Compare two players on the same map/difficulty' }
|
||||
{ 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' }
|
||||
] as tool}
|
||||
<a href={tool.href} class="card-surface p-5 block">
|
||||
<div class="font-semibold">{tool.name}</div>
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
import MapCard from '$lib/components/MapCard.svelte';
|
||||
import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte';
|
||||
import PlayerCard from '$lib/components/PlayerCard.svelte';
|
||||
import HasToolAccess from '$lib/components/HasToolAccess.svelte';
|
||||
import {
|
||||
type MapMeta,
|
||||
type StarInfo,
|
||||
type BeatLeaderScore,
|
||||
type BeatLeaderScoresResponse,
|
||||
type Difficulty,
|
||||
type BeatLeaderPlayerProfile,
|
||||
loadMetaForHashes,
|
||||
@ -17,7 +15,7 @@
|
||||
getCutoffEpochFromMonths,
|
||||
toPlaylistJson,
|
||||
downloadPlaylist,
|
||||
ONE_YEAR_SECONDS,
|
||||
fetchAllRecentScoresForDiff,
|
||||
TOOL_REQUIREMENTS
|
||||
} from '$lib/utils/plebsaber-utils';
|
||||
|
||||
@ -42,12 +40,8 @@
|
||||
let errorMsg: string | null = null;
|
||||
let results: SongItem[] = [];
|
||||
let loadingMeta = false;
|
||||
let playerAProfile: BeatLeaderPlayerProfile | null = null;
|
||||
let playerBProfile: BeatLeaderPlayerProfile | null = null;
|
||||
let hasCompared = false;
|
||||
|
||||
// Sorting and pagination state
|
||||
let sortBy: 'date' | 'difficulty' = 'date';
|
||||
let sortDir: 'asc' | 'desc' = 'desc';
|
||||
let page = 1;
|
||||
let pageSize: number | string = 24;
|
||||
@ -57,16 +51,9 @@
|
||||
const monthsA = 24; // default 24 months
|
||||
const monthsB = 24; // default 24 months
|
||||
|
||||
// Derived lists
|
||||
// Derived lists - always sort by date
|
||||
$: 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);
|
||||
}
|
||||
const cmp = a.timeset - b.timeset;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
$: totalPages = Math.max(1, Math.ceil(sortedResults.length / pageSizeNum));
|
||||
@ -97,26 +84,6 @@
|
||||
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 {
|
||||
@ -131,9 +98,6 @@
|
||||
async function onCompare() {
|
||||
errorMsg = null;
|
||||
results = [];
|
||||
playerAProfile = null;
|
||||
playerBProfile = null;
|
||||
hasCompared = false;
|
||||
const a = playerA.trim();
|
||||
const b = playerB.trim();
|
||||
if (!a || !b) {
|
||||
@ -145,17 +109,11 @@
|
||||
try {
|
||||
const cutoff = getCutoffEpochFromMonths(monthsA);
|
||||
const cutoffB = getCutoffEpochFromMonths(monthsB);
|
||||
const [aScores, bScores, profileA, profileB] = await Promise.all([
|
||||
fetchAllRecentScores(a, cutoff),
|
||||
fetchAllRecentScores(b, cutoffB, 100),
|
||||
fetchPlayerProfile(a),
|
||||
fetchPlayerProfile(b)
|
||||
const [aScores, bScores] = await Promise.all([
|
||||
fetchAllRecentScoresForDiff(a, cutoff, 'ExpertPlus', 15),
|
||||
fetchAllRecentScoresForDiff(b, cutoffB, 'ExpertPlus', 100)
|
||||
]);
|
||||
|
||||
playerAProfile = profileA;
|
||||
playerBProfile = profileB;
|
||||
hasCompared = true;
|
||||
|
||||
const bLeaderboardIds = new Set<string>();
|
||||
const bExpertPlusKeys = new Set<string>(); // `${hashLower}|ExpertPlus|${modeName}`
|
||||
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>
|
||||
|
||||
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}>
|
||||
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={results.length > 0} oncompare={onCompare}>
|
||||
<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>
|
||||
|
||||
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={results.length > 0} oncompare={onCompare} currentPlayer={playerProfile}>
|
||||
<svelte:fragment slot="extra-buttons">
|
||||
{#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>
|
||||
@ -292,17 +200,10 @@
|
||||
{results.length} songs
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm text-muted flex-wrap justify-end">
|
||||
<label class="flex items-center gap-3">
|
||||
<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
|
||||
<label class="flex items-center gap-2">
|
||||
<select class="neon-select" bind:value={sortDir}>
|
||||
<option value="desc">Desc</option>
|
||||
<option value="asc">Asc</option>
|
||||
<option value="desc">Newest First</option>
|
||||
<option value="asc">Oldest First</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">Page size
|
||||
@ -360,20 +261,6 @@
|
||||
<style>
|
||||
.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 {
|
||||
border-radius: 0.375rem;
|
||||
@ -401,26 +288,6 @@
|
||||
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>
|
||||
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
|
||||
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;
|
||||
$: adminRank = data?.adminRank ?? null;
|
||||
@ -60,15 +60,35 @@
|
||||
let sortDir: 'asc' | 'desc' = 'desc';
|
||||
let page = 1;
|
||||
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;
|
||||
$: filtered = (() => {
|
||||
if (filterWinMargin === 'all') return items;
|
||||
const margin = Number(filterWinMargin);
|
||||
return items.filter(i => {
|
||||
if (i.accA == null || i.accB == null) return false;
|
||||
return i.accA > i.accB && (i.accA - i.accB) > margin;
|
||||
});
|
||||
if (filterWinMargin === 'a1') {
|
||||
return items.filter(i => {
|
||||
if (i.accA == null || i.accB == null) return false;
|
||||
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));
|
||||
$: totalPages = Math.max(1, Math.ceil(sorted.length / pageSizeNum));
|
||||
@ -166,7 +186,7 @@
|
||||
const size = sp.get('size') ?? sp.get('ps');
|
||||
if (size) pageSize = Number(size) || pageSize;
|
||||
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;
|
||||
});
|
||||
|
||||
@ -292,11 +312,11 @@
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<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}
|
||||
<div class="mt-4 text-danger">{errorMsg}</div>
|
||||
@ -435,8 +455,10 @@
|
||||
<span class="filter-label {filterWinMargin !== 'all' ? 'active' : ''}">Options:</span>
|
||||
<select class="neon-select {filterWinMargin !== 'all' ? 'active' : ''}" bind:value={filterWinMargin}>
|
||||
<option value="all">All Songs</option>
|
||||
<option value="1">{idShortA} wins by >1%</option>
|
||||
<option value="2">{idShortA} wins by >2%</option>
|
||||
<option value="a1">{idShortA} wins by >1%</option>
|
||||
<option value="a2">{idShortA} wins by >2%</option>
|
||||
<option value="b1">{idShortB} wins by >1%</option>
|
||||
<option value="b2">{idShortB} wins by >2%</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">Dir
|
||||
@ -1,10 +1,14 @@
|
||||
<script lang="ts">
|
||||
import SongPlayer from '$lib/components/SongPlayer.svelte';
|
||||
import HasToolAccess from '$lib/components/HasToolAccess.svelte';
|
||||
import PlayerCard from '$lib/components/PlayerCard.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
TOOL_REQUIREMENTS,
|
||||
type BeatLeaderPlayerProfile
|
||||
type BeatLeaderPlayerProfile,
|
||||
fetchAllPlayerScores,
|
||||
fetchBeatSaverMeta,
|
||||
type MapMeta
|
||||
} from '$lib/utils/plebsaber-utils';
|
||||
|
||||
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 };
|
||||
|
||||
const requirement = TOOL_REQUIREMENTS['beatleader-playlist-gap'];
|
||||
const requirement = TOOL_REQUIREMENTS['player-playlist-gaps'];
|
||||
|
||||
$: playerProfile = data?.player ?? null;
|
||||
$: adminRank = data?.adminRank ?? null;
|
||||
@ -70,6 +63,11 @@
|
||||
let metaByHash: Record<string, MapMeta> = {};
|
||||
let loadingMeta = false;
|
||||
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)
|
||||
let hasMounted = false;
|
||||
@ -105,7 +103,18 @@
|
||||
}
|
||||
onMount(() => {
|
||||
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 {
|
||||
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) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
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> {
|
||||
const needed = Array.from(new Set(items.map((i) => i.hash.toLowerCase()))).filter((h) => !metaByHash[h]);
|
||||
@ -313,11 +330,23 @@
|
||||
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) {
|
||||
ev.preventDefault();
|
||||
errorMsg = null;
|
||||
results = [];
|
||||
metaByHash = {};
|
||||
analyzedPlayerProfile = null;
|
||||
hasAnalyzed = false;
|
||||
if (!playerId.trim()) {
|
||||
errorMsg = 'Please enter a BeatLeader player ID or SteamID64.';
|
||||
return;
|
||||
@ -328,7 +357,13 @@
|
||||
}
|
||||
loading = true;
|
||||
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>();
|
||||
for (const s of scores) {
|
||||
const raw = s.leaderboard?.song?.hash ?? undefined;
|
||||
@ -351,37 +386,108 @@
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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}>
|
||||
<div class="sm:col-span-2 lg:col-span-2">
|
||||
<label class="block text-sm text-muted">Playlist file (.bplist)
|
||||
<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} />
|
||||
</label>
|
||||
{#if selectedFileName}
|
||||
<div class="mt-1 text-xs text-muted">{selectedFileName}{#if parsedTitle} · title: {parsedTitle}{/if} · {playlistSongs.length} songs</div>
|
||||
{#if !hasAnalyzed}
|
||||
<form class="mt-6" on:submit|preventDefault={onAnalyze}>
|
||||
<div class="grid gap-4 sm:grid-cols-2 items-end">
|
||||
<div>
|
||||
<label class="block text-sm text-muted">Playlist file (.bplist)
|
||||
<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} />
|
||||
</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}
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-neon" disabled={loading}>
|
||||
{#if loading}
|
||||
Loading...
|
||||
{:else}
|
||||
Analyze
|
||||
{/if}
|
||||
<button
|
||||
class="rounded-md border border-white/10 px-3 py-2 text-sm hover:bg-white/10 transition-colors"
|
||||
on:click={resetForm}
|
||||
title="Reset and analyze another playlist"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="mt-4 text-danger">{errorMsg}</div>
|
||||
@ -442,7 +548,7 @@
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Open in BeatSaver"
|
||||
>BSR</a>
|
||||
>BS</a>
|
||||
<button
|
||||
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}`); }}
|
||||
@ -463,6 +569,43 @@
|
||||
.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); }
|
||||
.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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user