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 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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 >1%</option>
|
<option value="a1">{idShortA} wins by >1%</option>
|
||||||
<option value="2">{idShortA} wins by >2%</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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2">Dir
|
<label class="flex items-center gap-2">Dir
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user