add player card to compare player form
This commit is contained in:
parent
f575844479
commit
8e86b65a45
@ -8,9 +8,10 @@
|
|||||||
- OAuth callback stores identity in a long-lived session (`plebsaber_session` via `.data/plebsaber_sessions.json`).
|
- OAuth callback stores identity in a long-lived session (`plebsaber_session` via `.data/plebsaber_sessions.json`).
|
||||||
- Tools expect BeatLeader supporter or global rank better than pleb's current rank; navbar dropdown warns when outside that threshold.
|
- Tools expect BeatLeader supporter or global rank better than pleb's current rank; navbar dropdown warns when outside that threshold.
|
||||||
- Global navigation lives in `src/lib/components/NavBar.svelte`; it houses the BeatLeader login dropdown with profile, testing (dev) and logout actions.
|
- Global navigation lives in `src/lib/components/NavBar.svelte`; it houses the BeatLeader login dropdown with profile, testing (dev) and logout actions.
|
||||||
- Core BeatLeader tool pages reuse shared components such as `PlayerCompareForm.svelte`, `MapCard.svelte`, and `SongPlayer.svelte` for consistent UI/UX.
|
- Core BeatLeader tool pages reuse shared components such as `PlayerCompareForm.svelte`, `MapCard.svelte`, `PlayerCard.svelte`, and `SongPlayer.svelte` for consistent UI/UX.
|
||||||
- `/testing` route (dev-only) consumes `/api/beatleader/me` to display and log raw BeatLeader session data for debugging.
|
- `/player-info` route consumes `/api/beatleader/me` to display and log raw BeatLeader session data for debugging.
|
||||||
- `/tools/stats` (admin-only, BL id `76561199407393962`) lists every stored BeatLeader session with avatars and last-seen timestamps; navigation link only appears for that account.
|
- `/tools/stats` (admin-only, BL id `76561199407393962`) lists every stored BeatLeader session with full player profiles, skill triangles, and last-seen timestamps; navigation link only appears for that account.
|
||||||
|
- `/tools/compare-histories` compares two players' play histories and displays their profiles with skill triangles when comparing.
|
||||||
|
|
||||||
## Shell command guidance
|
## Shell command guidance
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
formatToolRequirementSummary,
|
|
||||||
meetsToolRequirement,
|
meetsToolRequirement,
|
||||||
DEFAULT_ADMIN_RANK_FALLBACK,
|
DEFAULT_ADMIN_RANK_FALLBACK,
|
||||||
PLEB_BEATLEADER_ID,
|
PLEB_BEATLEADER_ID,
|
||||||
@ -13,14 +12,12 @@
|
|||||||
|
|
||||||
export let player: BeatLeaderPlayerProfile | null = null;
|
export let player: BeatLeaderPlayerProfile | null = null;
|
||||||
export let requirement: ToolRequirement | null = null;
|
export let requirement: ToolRequirement | null = null;
|
||||||
export let customLockedMessage: string | null = null;
|
|
||||||
export let showCurrentRank = true;
|
export let showCurrentRank = true;
|
||||||
export let adminRank: number | null = null;
|
export let adminRank: number | null = null;
|
||||||
export let adminPlayer: BeatLeaderPlayerProfile | null = null;
|
export let adminPlayer: BeatLeaderPlayerProfile | null = null;
|
||||||
|
|
||||||
$: requirementContext = { adminRank };
|
$: requirementContext = { adminRank };
|
||||||
$: hasAccess = meetsToolRequirement(player, requirement, requirementContext);
|
$: hasAccess = meetsToolRequirement(player, requirement, requirementContext);
|
||||||
$: summary = formatToolRequirementSummary(requirement, requirementContext);
|
|
||||||
$: fallbackBaseline = DEFAULT_ADMIN_RANK_FALLBACK;
|
$: fallbackBaseline = DEFAULT_ADMIN_RANK_FALLBACK;
|
||||||
$: resolvedBaseline =
|
$: resolvedBaseline =
|
||||||
typeof adminRank === 'number' && Number.isFinite(adminRank) && adminRank > 0 ? adminRank : fallbackBaseline;
|
typeof adminRank === 'number' && Number.isFinite(adminRank) && adminRank > 0 ? adminRank : fallbackBaseline;
|
||||||
@ -29,11 +26,6 @@
|
|||||||
$: baselineCopy = resolvedBaseline
|
$: baselineCopy = resolvedBaseline
|
||||||
? `players ranked better than ${plebName} (#${resolvedBaseline.toLocaleString()})`
|
? `players ranked better than ${plebName} (#${resolvedBaseline.toLocaleString()})`
|
||||||
: `players ranked better than ${plebName}`;
|
: `players ranked better than ${plebName}`;
|
||||||
$: defaultLockedMessage = requirement?.requiresBetterRankThanAdmin
|
|
||||||
? `You must be a BL Patreon supporter or ${baselineCopy} to use this tool.`
|
|
||||||
: requirement?.lockedMessage ?? null;
|
|
||||||
$: lockedMessage = customLockedMessage ?? defaultLockedMessage;
|
|
||||||
$: showLockedMessage = lockedMessage && lockedMessage !== summary ? lockedMessage : null;
|
|
||||||
$: playerRank = typeof player?.rank === 'number' ? player?.rank ?? null : null;
|
$: playerRank = typeof player?.rank === 'number' ? player?.rank ?? null : null;
|
||||||
$: playerRankDisplay = playerRank !== null ? `#${playerRank.toLocaleString()}` : null;
|
$: playerRankDisplay = playerRank !== null ? `#${playerRank.toLocaleString()}` : null;
|
||||||
</script>
|
</script>
|
||||||
@ -43,14 +35,8 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="mt-6 rounded-md border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-100 leading-relaxed">
|
<div class="mt-6 rounded-md border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-100 leading-relaxed">
|
||||||
<p class="font-semibold text-amber-200">
|
<p class="font-semibold text-amber-200">
|
||||||
Tools are restricted to <a class="underline hover:text-white" href={BL_PATREON_URL} target="_blank" rel="noreferrer noopener">BeatLeader supporters</a> (and {baselineCopy}).
|
Auth Required: Tools are restricted to <a class="underline hover:text-white" href={BL_PATREON_URL} target="_blank" rel="noreferrer noopener">BeatLeader supporters</a> (and {baselineCopy}).
|
||||||
</p>
|
</p>
|
||||||
{#if summary}
|
|
||||||
<p class="mt-1">{summary}</p>
|
|
||||||
{/if}
|
|
||||||
{#if showLockedMessage}
|
|
||||||
<p class="mt-1">{showLockedMessage}</p>
|
|
||||||
{/if}
|
|
||||||
{#if plebProfile}
|
{#if plebProfile}
|
||||||
<div class="pleb-card">
|
<div class="pleb-card">
|
||||||
<PlayerCard
|
<PlayerCard
|
||||||
@ -63,6 +49,7 @@
|
|||||||
techPp={plebProfile?.techPp}
|
techPp={plebProfile?.techPp}
|
||||||
accPp={plebProfile?.accPp}
|
accPp={plebProfile?.accPp}
|
||||||
passPp={plebProfile?.passPp}
|
passPp={plebProfile?.passPp}
|
||||||
|
playerId={plebProfile?.id ?? PLEB_BEATLEADER_ID}
|
||||||
gradientId="pleb-baseline"
|
gradientId="pleb-baseline"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { dev } from '$app/environment';
|
|
||||||
import beatleaderLogo from '$lib/assets/beatleader-logo.png';
|
import beatleaderLogo from '$lib/assets/beatleader-logo.png';
|
||||||
import { DEFAULT_ADMIN_RANK_FALLBACK, PLEB_BEATLEADER_ID } from '$lib/utils/plebsaber-utils';
|
import { DEFAULT_ADMIN_RANK_FALLBACK, PLEB_BEATLEADER_ID } from '$lib/utils/plebsaber-utils';
|
||||||
const links = [
|
const links = [
|
||||||
@ -134,10 +133,11 @@
|
|||||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-14 items-center justify-between">
|
<div class="flex h-14 items-center justify-between">
|
||||||
<a href="/" class="flex items-center gap-2">
|
<a href="/" class="flex items-center gap-2">
|
||||||
<span class="h-2 w-2 rounded-full bg-neon" style="box-shadow: 0 0 12px rgba(34,211,238,0.60);"></span>
|
|
||||||
<span class="font-display text-lg tracking-widest">
|
<span class="font-display text-lg tracking-widest">
|
||||||
<span class="neon-text">PLEBSABER</span><span class="text-muted">.stream</span>
|
<span class="neon-text">plebsaber</span><span class="text-muted">.stream</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="h-2 w-2 rounded-full bg-neon-fuchsia" style="box-shadow: 0 0 12px rgba(255,0,229,0.60);"></span>
|
||||||
|
<span class="h-2 w-2 rounded-full bg-neon" style="box-shadow: 0 0 12px rgba(34,211,238,0.60);"></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="hidden md:flex items-center gap-6">
|
<nav class="hidden md:flex items-center gap-6">
|
||||||
@ -176,9 +176,7 @@
|
|||||||
{#if user?.id === PLEB_BEATLEADER_ID}
|
{#if user?.id === PLEB_BEATLEADER_ID}
|
||||||
<a href="/tools/stats" class="dropdown-item" on:click={closeMenu}>Stats</a>
|
<a href="/tools/stats" class="dropdown-item" on:click={closeMenu}>Stats</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if dev}
|
<a href="/player-info" class="dropdown-item" on:click={closeMenu}>Player Info</a>
|
||||||
<a href="/testing" class="dropdown-item" on:click={closeMenu}>Testing</a>
|
|
||||||
{/if}
|
|
||||||
<a href={getProfileUrl(user.id)} class="dropdown-item dropdown-item--with-icon" target="_blank" rel="noreferrer noopener" on:click={closeMenu}>
|
<a href={getProfileUrl(user.id)} class="dropdown-item dropdown-item--with-icon" target="_blank" rel="noreferrer noopener" on:click={closeMenu}>
|
||||||
<span>Profile</span>
|
<span>Profile</span>
|
||||||
<img src={beatleaderLogo} alt="BeatLeader" class="dropdown-icon" />
|
<img src={beatleaderLogo} alt="BeatLeader" class="dropdown-icon" />
|
||||||
@ -222,9 +220,7 @@
|
|||||||
{#if user?.id === PLEB_BEATLEADER_ID}
|
{#if user?.id === PLEB_BEATLEADER_ID}
|
||||||
<a href="/tools/stats" class="dropdown-item" on:click={close}>Stats</a>
|
<a href="/tools/stats" class="dropdown-item" on:click={close}>Stats</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if dev}
|
<a href="/player-info" class="dropdown-item" on:click={close}>Player Info</a>
|
||||||
<a href="/testing" class="dropdown-item" on:click={close}>Testing</a>
|
|
||||||
{/if}
|
|
||||||
<a href={getProfileUrl(user.id)} class="dropdown-item dropdown-item--with-icon" target="_blank" rel="noreferrer noopener" on:click={close}>
|
<a href={getProfileUrl(user.id)} class="dropdown-item dropdown-item--with-icon" target="_blank" rel="noreferrer noopener" on:click={close}>
|
||||||
<span>Profile</span>
|
<span>Profile</span>
|
||||||
<img src={beatleaderLogo} alt="BeatLeader" class="dropdown-icon" />
|
<img src={beatleaderLogo} alt="BeatLeader" class="dropdown-icon" />
|
||||||
|
|||||||
@ -13,17 +13,65 @@
|
|||||||
export let passPp: number | null | undefined = null;
|
export let passPp: number | null | undefined = null;
|
||||||
// gradientId must be unique per instance to avoid SVG gradient conflicts when multiple cards render on the same page
|
// gradientId must be unique per instance to avoid SVG gradient conflicts when multiple cards render on the same page
|
||||||
export let gradientId: string = 'summary-triangle';
|
export let gradientId: string = 'summary-triangle';
|
||||||
|
export let playerId: string | null = null;
|
||||||
|
|
||||||
$: triangle = calculateSkillTriangle(techPp, accPp, passPp);
|
$: triangle = calculateSkillTriangle(techPp, accPp, passPp);
|
||||||
$: triangleCorners = triangle?.corners;
|
$: triangleCorners = triangle?.corners;
|
||||||
$: normalized = triangle?.normalized;
|
$: normalized = triangle?.normalized;
|
||||||
$: hasTriangle = triangle !== null;
|
$: hasTriangle = triangle !== null;
|
||||||
|
$: profileUrl = playerId ? `https://beatleader.com/u/${encodeURIComponent(playerId)}` : null;
|
||||||
|
|
||||||
const gradientTechId = `${gradientId}-tech`;
|
const gradientTechId = `${gradientId}-tech`;
|
||||||
const gradientAccId = `${gradientId}-acc`;
|
const gradientAccId = `${gradientId}-acc`;
|
||||||
const gradientPassId = `${gradientId}-pass`;
|
const gradientPassId = `${gradientId}-pass`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if profileUrl}
|
||||||
|
<a href={profileUrl} target="_blank" rel="noreferrer noopener" class="summary-header clickable" style={`--header-width:${width}; --avatar-size:${avatarSize}px`}>
|
||||||
|
<img src={avatar ?? ''} alt="Avatar" class:placeholder={!avatar} />
|
||||||
|
<div class="summary-body">
|
||||||
|
<div class="summary-name">{name}</div>
|
||||||
|
<div class="summary-meta">
|
||||||
|
{#if country}
|
||||||
|
<span>{country}</span>
|
||||||
|
{/if}
|
||||||
|
{#if showRank && typeof rank === 'number'}
|
||||||
|
<span>Rank: {rank}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if hasTriangle}
|
||||||
|
<div class="summary-triangle">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" width="100%" height="100%" viewBox="0 0 100 86.6">
|
||||||
|
<g transform="matrix(1 0 0 -1 0 86.6)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientTechId} gradientUnits="userSpaceOnUse" x1={triangleCorners!.tech.x} y1={triangleCorners!.tech.y} x2={(triangleCorners!.acc.x + triangleCorners!.pass.x) / 2} y2={(triangleCorners!.acc.y + triangleCorners!.pass.y) / 2}>
|
||||||
|
<stop offset="0%" stop-color={`rgb(255 0 120 / ${(normalized!.tech ?? 0) * 100}%)`} />
|
||||||
|
<stop offset="100%" stop-color={`rgb(255 0 120 / ${(normalized!.tech ?? 0) * 25}%)`} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={gradientAccId} gradientUnits="userSpaceOnUse" x1={triangleCorners!.acc.x} y1={triangleCorners!.acc.y} x2={(triangleCorners!.pass.x + triangleCorners!.tech.x) / 2} y2={(triangleCorners!.pass.y + triangleCorners!.tech.y) / 2}>
|
||||||
|
<stop offset="0%" stop-color={`rgb(0 180 255 / ${(normalized!.acc ?? 0) * 100}%)`} />
|
||||||
|
<stop offset="100%" stop-color={`rgb(0 180 255 / ${(normalized!.acc ?? 0) * 25}%)`} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={gradientPassId} gradientUnits="userSpaceOnUse" x1={triangleCorners!.pass.x} y1={triangleCorners!.pass.y} x2={(triangleCorners!.tech.x + triangleCorners!.acc.x) / 2} y2={(triangleCorners!.tech.y + triangleCorners!.acc.y) / 2}>
|
||||||
|
<stop offset="0%" stop-color={`rgb(0 255 180 / ${(normalized!.pass ?? 0) * 100}%)`} />
|
||||||
|
<stop offset="100%" stop-color={`rgb(0 255 180 / ${(normalized!.pass ?? 0) * 25}%)`} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g stroke="#fff" stroke-width="0.5">
|
||||||
|
<path d={`M ${triangleCorners!.pass.x},${triangleCorners!.pass.y} L ${triangleCorners!.tech.x},${triangleCorners!.tech.y} ${triangleCorners!.acc.x},${triangleCorners!.acc.y} Z`} fill={`url(#${gradientTechId})`} />
|
||||||
|
<path d={`M ${triangleCorners!.pass.x},${triangleCorners!.pass.y} L ${triangleCorners!.tech.x},${triangleCorners!.tech.y} ${triangleCorners!.acc.x},${triangleCorners!.acc.y} Z`} fill={`url(#${gradientPassId})`} />
|
||||||
|
<path d={`M ${triangleCorners!.pass.x},${triangleCorners!.pass.y} L ${triangleCorners!.tech.x},${triangleCorners!.tech.y} ${triangleCorners!.acc.x},${triangleCorners!.acc.y} Z`} fill={`url(#${gradientAccId})`} />
|
||||||
|
</g>
|
||||||
|
<g stroke="#fff" fill="none" stroke-width="2" stroke-dasharray="6 6">
|
||||||
|
<path d="M 50,0 L 0,86.6 100,86.6 Z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
<div class="summary-header" style={`--header-width:${width}; --avatar-size:${avatarSize}px`}>
|
<div class="summary-header" style={`--header-width:${width}; --avatar-size:${avatarSize}px`}>
|
||||||
<img src={avatar ?? ''} alt="Avatar" class:placeholder={!avatar} />
|
<img src={avatar ?? ''} alt="Avatar" class:placeholder={!avatar} />
|
||||||
<div class="summary-body">
|
<div class="summary-body">
|
||||||
@ -68,6 +116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.summary-header {
|
.summary-header {
|
||||||
@ -78,6 +127,8 @@
|
|||||||
max-width: var(--header-width, 50%);
|
max-width: var(--header-width, 50%);
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
flex: 0 0 var(--header-width, 50%);
|
flex: 0 0 var(--header-width, 50%);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
@ -127,5 +178,19 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
filter: drop-shadow(0 4px 12px rgba(34, 211, 238, 0.25));
|
filter: drop-shadow(0 4px 12px rgba(34, 211, 238, 0.25));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-header.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-header.clickable:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-header.clickable:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import PlayerCard from '$lib/components/PlayerCard.svelte';
|
||||||
|
import type { BeatLeaderPlayerProfile } from '$lib/utils/plebsaber-utils';
|
||||||
|
|
||||||
export let playerA = '';
|
export let playerA = '';
|
||||||
export let playerB = '';
|
export let playerB = '';
|
||||||
@ -10,6 +12,11 @@
|
|||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|
||||||
|
// Local state for default player cards
|
||||||
|
let hasCompared = false;
|
||||||
|
let playerAProfile: BeatLeaderPlayerProfile | null = null;
|
||||||
|
let playerBProfile: BeatLeaderPlayerProfile | null = null;
|
||||||
|
|
||||||
// Load from URL params on mount
|
// Load from URL params on mount
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
@ -38,14 +45,43 @@
|
|||||||
history.replaceState(null, '', url);
|
history.replaceState(null, '', url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: Event) {
|
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 loadDefaultPlayerCards(a: string, b: string): Promise<void> {
|
||||||
|
playerAProfile = null;
|
||||||
|
playerBProfile = null;
|
||||||
|
if (!a || !b) return;
|
||||||
|
const [pa, pb] = await Promise.all([
|
||||||
|
fetchPlayerProfile(a),
|
||||||
|
fetchPlayerProfile(b)
|
||||||
|
]);
|
||||||
|
playerAProfile = pa;
|
||||||
|
playerBProfile = pb;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
hasCompared = true;
|
||||||
|
const a = playerA.trim();
|
||||||
|
const b = playerB.trim();
|
||||||
|
// Fire and forget: show default cards irrespective of parent page implementation
|
||||||
|
void loadDefaultPlayerCards(a, b);
|
||||||
oncompare?.();
|
oncompare?.();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="mt-6 grid gap-4 sm:grid-cols-3 items-end" on:submit={handleSubmit}>
|
<form class="mt-6" on:submit={handleSubmit}>
|
||||||
<div>
|
<div class="form-grid">
|
||||||
|
<div class="player-column">
|
||||||
|
<div class="input-tile">
|
||||||
<label class="block text-sm text-muted">Player A ID
|
<label class="block text-sm text-muted">Player A ID
|
||||||
<input
|
<input
|
||||||
class="mt-1 w-full rounded-md border bg-transparent px-3 py-2 text-sm outline-none {hasResults ? 'border-white/10' : 'neon-input'}"
|
class="mt-1 w-full rounded-md border bg-transparent px-3 py-2 text-sm outline-none {hasResults ? 'border-white/10' : 'neon-input'}"
|
||||||
@ -55,7 +91,34 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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 class="player-column">
|
||||||
|
<div class="input-tile">
|
||||||
<label class="block text-sm text-muted">Player B ID
|
<label class="block text-sm text-muted">Player B ID
|
||||||
<input
|
<input
|
||||||
class="mt-1 w-full rounded-md border bg-transparent px-3 py-2 text-sm outline-none {hasResults ? 'border-white/10' : 'neon-input'}"
|
class="mt-1 w-full rounded-md border bg-transparent px-3 py-2 text-sm outline-none {hasResults ? 'border-white/10' : 'neon-input'}"
|
||||||
@ -65,7 +128,34 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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 class="button-row">
|
||||||
<button class="btn-neon" type="submit" disabled={loading}>
|
<button class="btn-neon" type="submit" disabled={loading}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
Loading...
|
Loading...
|
||||||
@ -78,6 +168,44 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.neon-input {
|
.neon-input {
|
||||||
border: 1px solid rgba(34, 211, 238, 0.3);
|
border: 1px solid rgba(34, 211, 238, 0.3);
|
||||||
background: linear-gradient(180deg, rgba(15,23,42,0.9), rgba(11,15,23,0.95));
|
background: linear-gradient(180deg, rgba(15,23,42,0.9), rgba(11,15,23,0.95));
|
||||||
|
|||||||
@ -103,7 +103,7 @@ const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TOOL_REQUIREMENTS = {
|
export const TOOL_REQUIREMENTS = {
|
||||||
'beatleader-compare': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||||
'beatleader-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
'beatleader-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||||
'beatleader-playlist-gap': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
'beatleader-playlist-gap': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
||||||
} as const satisfies Record<string, ToolRequirement>;
|
} as const satisfies Record<string, ToolRequirement>;
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="py-8">
|
<section class="py-8">
|
||||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader Testing</h1>
|
<h1 class="font-display text-3xl sm:text-4xl">Player Info</h1>
|
||||||
<p class="mt-2 text-muted text-sm">Debug view for the current BeatLeader OAuth session.</p>
|
<p class="mt-2 text-muted text-sm">Debug view for the current BeatLeader OAuth session.</p>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@ -64,60 +64,58 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if player}
|
{#if player}
|
||||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
<div class="mt-6">
|
||||||
<div class="card">
|
<div class="player-tile">
|
||||||
<h2 class="card-title">Player</h2>
|
|
||||||
<PlayerCard
|
<PlayerCard
|
||||||
name={player.name ?? identity?.name ?? 'Unknown'}
|
name={player.name ?? identity?.name ?? 'Unknown'}
|
||||||
country={player.country ?? null}
|
country={player.country ?? null}
|
||||||
rank={player.rank ?? null}
|
rank={player.rank ?? null}
|
||||||
showRank={typeof player.rank === 'number'}
|
showRank={typeof player.rank === 'number'}
|
||||||
avatar={player.avatar ?? null}
|
avatar={player.avatar ?? null}
|
||||||
width="50%"
|
width="100%"
|
||||||
avatarSize={48}
|
avatarSize={64}
|
||||||
techPp={player.techPp}
|
techPp={player.techPp}
|
||||||
accPp={player.accPp}
|
accPp={player.accPp}
|
||||||
passPp={player.passPp}
|
passPp={player.passPp}
|
||||||
|
playerId={player.id ?? null}
|
||||||
gradientId="player-header"
|
gradientId="player-header"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<h2 class="card-title">Player Details</h2>
|
<div class="stat-tile">
|
||||||
<dl class="info-grid">
|
|
||||||
<div>
|
|
||||||
<dt>Country Rank</dt>
|
<dt>Country Rank</dt>
|
||||||
<dd>{typeof player.countryRank === 'number' ? player.countryRank : '—'}</dd>
|
<dd>{typeof player.countryRank === 'number' ? `#${player.countryRank.toLocaleString()}` : '—'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="stat-tile">
|
||||||
<dt>Global Rank</dt>
|
<dt>Global Rank</dt>
|
||||||
<dd>{typeof player.rank === 'number' ? player.rank : '—'}</dd>
|
<dd>{typeof player.rank === 'number' ? `#${player.rank.toLocaleString()}` : '—'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="stat-tile">
|
||||||
<dt>PP (Global)</dt>
|
<dt>PP (Global)</dt>
|
||||||
<dd>{player.pp ?? '—'}</dd>
|
<dd>{typeof player.pp === 'number' ? player.pp.toFixed(2) : '—'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="stat-tile">
|
||||||
<dt>Tech PP</dt>
|
<dt>Tech PP</dt>
|
||||||
<dd>{player.techPp ?? '—'}</dd>
|
<dd>{typeof player.techPp === 'number' ? player.techPp.toFixed(2) : '—'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="stat-tile">
|
||||||
<dt>Acc PP</dt>
|
<dt>Acc PP</dt>
|
||||||
<dd>{player.accPp ?? '—'}</dd>
|
<dd>{typeof player.accPp === 'number' ? player.accPp.toFixed(2) : '—'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="stat-tile">
|
||||||
<dt>Pass PP</dt>
|
<dt>Pass PP</dt>
|
||||||
<dd>{player.passPp ?? '—'}</dd>
|
<dd>{typeof player.passPp === 'number' ? player.passPp.toFixed(2) : '—'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="stat-tile">
|
||||||
<dt>Level</dt>
|
<dt>Level</dt>
|
||||||
<dd>{player.level ?? '—'}</dd>
|
<dd>{player.level ?? '—'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="stat-tile">
|
||||||
<dt>Role</dt>
|
<dt>Role</dt>
|
||||||
<dd>{player.role ?? '—'}</dd>
|
<dd>{player.role ?? '—'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="stat-tile">
|
||||||
<dt>Mapper</dt>
|
<dt>Mapper</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{#if player.mapperId}
|
{#if player.mapperId}
|
||||||
@ -129,20 +127,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="stat-tile">
|
||||||
<dt>Banned</dt>
|
<dt>Banned</dt>
|
||||||
<dd>{player.banned ? 'Yes' : 'No'}</dd>
|
<dd>{player.banned ? 'Yes' : 'No'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="stat-tile">
|
||||||
<dt>Show All Ratings</dt>
|
<dt>Show All Ratings</dt>
|
||||||
<dd>{player.profileSettings?.showAllRatings ? 'Enabled' : 'Disabled'}</dd>
|
<dd>{player.profileSettings?.showAllRatings ? 'Enabled' : 'Disabled'}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-6 card">
|
<div class="mt-6 player-tile">
|
||||||
<h2 class="card-title">Player</h2>
|
|
||||||
<p class="empty">No player profile found for this identity.</p>
|
<p class="empty">No player profile found for this identity.</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@ -150,7 +146,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.card {
|
.player-tile {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@ -158,30 +154,32 @@
|
|||||||
box-shadow: 0 20px 40px rgba(8, 14, 35, 0.35);
|
box-shadow: 0 20px 40px rgba(8, 14, 35, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.stat-tile {
|
||||||
font-size: 1.05rem;
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
font-weight: 600;
|
border-radius: 0.5rem;
|
||||||
color: rgba(226, 232, 240, 0.95);
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
background: linear-gradient(160deg, rgba(15, 23, 42, 0.8), rgba(5, 9, 20, 0.85));
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-grid {
|
.stat-tile:hover {
|
||||||
display: grid;
|
border-color: rgba(34, 211, 238, 0.25);
|
||||||
gap: 0.75rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dt {
|
dt {
|
||||||
color: rgba(148, 163, 184, 0.75);
|
color: rgba(148, 163, 184, 0.75);
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.35rem;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
dd {
|
dd {
|
||||||
color: rgba(226, 232, 240, 0.92);
|
color: rgba(226, 232, 240, 0.95);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
@ -189,9 +187,14 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
color: rgba(34, 211, 238, 1);
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: rgba(148, 163, 184, 0.7);
|
color: rgba(148, 163, 184, 0.7);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
<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: 'BeatLeader Compare Players', href: '/tools/beatleader-compare', 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: '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: 'BeatLeader Head-to-Head', href: '/tools/beatleader-headtohead', desc: 'Compare two players on the same map/difficulty' }
|
||||||
] as tool}
|
] as tool}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<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,
|
||||||
@ -29,7 +30,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-compare'];
|
const requirement = TOOL_REQUIREMENTS['compare-histories'];
|
||||||
|
|
||||||
$: playerProfile = data?.player ?? null;
|
$: playerProfile = data?.player ?? null;
|
||||||
$: adminRank = data?.adminRank ?? null;
|
$: adminRank = data?.adminRank ?? null;
|
||||||
@ -41,6 +42,9 @@
|
|||||||
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 sortBy: 'date' | 'difficulty' = 'date';
|
||||||
@ -93,6 +97,16 @@
|
|||||||
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[]> {
|
async function fetchAllRecentScores(playerId: string, cutoffEpoch: number, maxPages = 15): Promise<BeatLeaderScore[]> {
|
||||||
const qs = new URLSearchParams({ diff: 'ExpertPlus', cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
|
const qs = new URLSearchParams({ diff: 'ExpertPlus', cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
|
||||||
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
|
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
|
||||||
@ -117,6 +131,9 @@
|
|||||||
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) {
|
||||||
@ -128,11 +145,17 @@
|
|||||||
try {
|
try {
|
||||||
const cutoff = getCutoffEpochFromMonths(monthsA);
|
const cutoff = getCutoffEpochFromMonths(monthsA);
|
||||||
const cutoffB = getCutoffEpochFromMonths(monthsB);
|
const cutoffB = getCutoffEpochFromMonths(monthsB);
|
||||||
const [aScores, bScores] = await Promise.all([
|
const [aScores, bScores, profileA, profileB] = await Promise.all([
|
||||||
fetchAllRecentScores(a, cutoff),
|
fetchAllRecentScores(a, cutoff),
|
||||||
fetchAllRecentScores(b, cutoffB, 100)
|
fetchAllRecentScores(b, cutoffB, 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) {
|
||||||
@ -197,11 +220,61 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="py-8">
|
<section class="py-8">
|
||||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Compare Players</h1>
|
<h1 class="font-display text-3xl sm:text-4xl">Compare Play Histories</h1>
|
||||||
<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}>
|
||||||
|
<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>
|
||||||
@ -327,6 +400,27 @@
|
|||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
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>
|
||||||
|
|
||||||
|
|
||||||
@ -7,10 +7,36 @@ type StatsUser = {
|
|||||||
beatleaderId: string;
|
beatleaderId: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
country: string | null;
|
||||||
|
rank: number | null;
|
||||||
|
techPp: number | null;
|
||||||
|
accPp: number | null;
|
||||||
|
passPp: number | null;
|
||||||
lastSeenAt: number;
|
lastSeenAt: number;
|
||||||
lastSeenIso: string;
|
lastSeenIso: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BeatLeaderPlayer = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar?: string;
|
||||||
|
country?: string;
|
||||||
|
rank?: number;
|
||||||
|
techPp?: number;
|
||||||
|
accPp?: number;
|
||||||
|
passPp?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchBeatLeaderProfile(playerId: string): Promise<BeatLeaderPlayer | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.beatleader.xyz/player/${playerId}`);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return (await response.json()) as BeatLeaderPlayer;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies }) => {
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
const currentSession = getSession(cookies);
|
const currentSession = getSession(cookies);
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
@ -22,22 +48,33 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessions = getAllSessions();
|
const sessions = getAllSessions();
|
||||||
const aggregated = new Map<string, StatsUser>();
|
const aggregated = new Map<string, { session: typeof sessions[0] }>();
|
||||||
|
|
||||||
for (const stored of sessions) {
|
for (const stored of sessions) {
|
||||||
const existing = aggregated.get(stored.beatleaderId);
|
const existing = aggregated.get(stored.beatleaderId);
|
||||||
if (!existing || stored.lastSeenAt > existing.lastSeenAt) {
|
if (!existing || stored.lastSeenAt > existing.session.lastSeenAt) {
|
||||||
aggregated.set(stored.beatleaderId, {
|
aggregated.set(stored.beatleaderId, { session: stored });
|
||||||
beatleaderId: stored.beatleaderId,
|
|
||||||
name: stored.name,
|
|
||||||
avatar: stored.avatar,
|
|
||||||
lastSeenAt: stored.lastSeenAt,
|
|
||||||
lastSeenIso: new Date(stored.lastSeenAt).toISOString()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = Array.from(aggregated.values()).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
// Fetch all player profiles in parallel
|
||||||
|
const userPromises = Array.from(aggregated.values()).map(async ({ session }) => {
|
||||||
|
const profile = await fetchBeatLeaderProfile(session.beatleaderId);
|
||||||
|
return {
|
||||||
|
beatleaderId: session.beatleaderId,
|
||||||
|
name: profile?.name ?? session.name,
|
||||||
|
avatar: profile?.avatar ?? session.avatar,
|
||||||
|
country: profile?.country ?? null,
|
||||||
|
rank: profile?.rank ?? null,
|
||||||
|
techPp: profile?.techPp ?? null,
|
||||||
|
accPp: profile?.accPp ?? null,
|
||||||
|
passPp: profile?.passPp ?? null,
|
||||||
|
lastSeenAt: session.lastSeenAt,
|
||||||
|
lastSeenIso: new Date(session.lastSeenAt).toISOString()
|
||||||
|
} satisfies StatsUser;
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = (await Promise.all(userPromises)).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users
|
users
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import beatleaderLogo from '$lib/assets/beatleader-logo.png';
|
import PlayerCard from '$lib/components/PlayerCard.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
@ -44,13 +44,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>PLEBSABER · Stats</title>
|
<title>plebsaber · Stats</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="page-wrapper">
|
<section class="page-wrapper">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>Historical BeatLeader Logins</h1>
|
<h1>Historical BeatLeader Logins</h1>
|
||||||
<p class="page-subtitle">Tracking everyone who has authenticated with PLEBSABER tools.</p>
|
<p class="page-subtitle">Tracking everyone who has authenticated with plebsaber tools.</p>
|
||||||
<p class="page-meta">Total users: {data.users.length}</p>
|
<p class="page-meta">Total users: {data.users.length}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -60,21 +60,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="user-grid" aria-live="polite">
|
<ul class="user-grid" aria-live="polite">
|
||||||
{#each data.users as user}
|
{#each data.users as user, index}
|
||||||
<li class="user-card">
|
<li class="user-card">
|
||||||
<img
|
<div class="card-header">
|
||||||
src={user.avatar ?? beatleaderLogo}
|
<PlayerCard
|
||||||
alt={user.name ? `${user.name}'s BeatLeader avatar` : 'BeatLeader avatar placeholder'}
|
name={user.name ?? 'Unknown BeatLeader user'}
|
||||||
class="user-avatar"
|
avatar={user.avatar ?? null}
|
||||||
loading="lazy"
|
country={user.country ?? null}
|
||||||
|
rank={user.rank ?? null}
|
||||||
|
showRank={typeof user.rank === 'number'}
|
||||||
|
width="100%"
|
||||||
|
avatarSize={64}
|
||||||
|
techPp={user.techPp}
|
||||||
|
accPp={user.accPp}
|
||||||
|
passPp={user.passPp}
|
||||||
|
playerId={user.beatleaderId}
|
||||||
|
gradientId={`stats-player-${index}`}
|
||||||
/>
|
/>
|
||||||
<div class="user-details">
|
</div>
|
||||||
<div class="user-name">{user.name ?? 'Unknown BeatLeader user'}</div>
|
<div class="user-meta">
|
||||||
<div class="user-id">ID: {user.beatleaderId}</div>
|
<div class="meta-item">
|
||||||
<time class="user-seen" datetime={user.lastSeenIso}>
|
<span class="meta-label">ID</span>
|
||||||
Last session {formatTimeSince(user.lastSeenAt)}
|
<span class="meta-value">{user.beatleaderId}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Last Session</span>
|
||||||
|
<time class="meta-value" datetime={user.lastSeenIso}>
|
||||||
|
{formatTimeSince(user.lastSeenAt)}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@ -121,8 +136,8 @@
|
|||||||
|
|
||||||
.user-grid {
|
.user-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
gap: 1.25rem;
|
gap: 1.5rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@ -130,65 +145,62 @@
|
|||||||
|
|
||||||
.user-card {
|
.user-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1.1rem;
|
padding: 1.25rem;
|
||||||
border-radius: 0.85rem;
|
border-radius: 0.85rem;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
background: linear-gradient(160deg, rgba(15, 23, 42, 0.6), rgba(8, 12, 24, 0.85));
|
background: linear-gradient(160deg, rgba(15, 23, 42, 0.6), rgba(8, 12, 24, 0.85));
|
||||||
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.05);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), inset 0 0 0 1px rgba(34, 211, 238, 0.05);
|
||||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-card:hover,
|
.user-card:hover,
|
||||||
.user-card:focus-within {
|
.user-card:focus-within {
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: rgba(34, 211, 238, 0.35);
|
border-color: rgba(34, 211, 238, 0.35);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35), inset 0 0 0 1px rgba(34, 211, 238, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.card-header {
|
||||||
width: 64px;
|
display: flex;
|
||||||
height: 64px;
|
width: 100%;
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 2px solid rgba(34, 211, 238, 0.35);
|
|
||||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.45);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-details {
|
.user-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.35rem;
|
grid-template-columns: 1fr 1fr;
|
||||||
align-content: center;
|
gap: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name {
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: rgba(148, 163, 184, 0.6);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(248, 250, 252, 0.95);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-id {
|
.meta-value {
|
||||||
font-size: 0.8rem;
|
|
||||||
color: rgba(148, 163, 184, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-seen {
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: rgba(34, 211, 238, 0.75);
|
color: rgba(226, 232, 240, 0.9);
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.user-card {
|
.user-grid {
|
||||||
flex-direction: column;
|
grid-template-columns: 1fr;
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-details {
|
.user-meta {
|
||||||
align-items: center;
|
grid-template-columns: 1fr;
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user