Build re-usable playerCard component with the skill triangle
This commit is contained in:
parent
5daf221cd7
commit
f575844479
@ -3,9 +3,11 @@
|
||||
formatToolRequirementSummary,
|
||||
meetsToolRequirement,
|
||||
DEFAULT_ADMIN_RANK_FALLBACK,
|
||||
PLEB_BEATLEADER_ID,
|
||||
type BeatLeaderPlayerProfile,
|
||||
type ToolRequirement
|
||||
} from '$lib/utils/plebsaber-utils';
|
||||
import PlayerCard from '$lib/components/PlayerCard.svelte';
|
||||
|
||||
const BL_PATREON_URL = 'https://www.patreon.com/BeatLeader';
|
||||
|
||||
@ -14,17 +16,19 @@
|
||||
export let customLockedMessage: string | null = null;
|
||||
export let showCurrentRank = true;
|
||||
export let adminRank: number | null = null;
|
||||
export let adminPlayer: BeatLeaderPlayerProfile | null = null;
|
||||
|
||||
$: requirementContext = { adminRank };
|
||||
$: hasAccess = meetsToolRequirement(player, requirement, requirementContext);
|
||||
$: summary = formatToolRequirementSummary(requirement, requirementContext);
|
||||
$: fallbackBaseline = DEFAULT_ADMIN_RANK_FALLBACK;
|
||||
$: resolvedBaseline = typeof adminRank === 'number' && Number.isFinite(adminRank) && adminRank > 0
|
||||
? adminRank
|
||||
: fallbackBaseline;
|
||||
$: resolvedBaseline =
|
||||
typeof adminRank === 'number' && Number.isFinite(adminRank) && adminRank > 0 ? adminRank : fallbackBaseline;
|
||||
$: plebProfile = adminPlayer;
|
||||
$: plebName = plebProfile?.name;
|
||||
$: baselineCopy = resolvedBaseline
|
||||
? `players ranked better than pleb (#${resolvedBaseline.toLocaleString()})`
|
||||
: 'players ranked better than pleb';
|
||||
? `players ranked better than ${plebName} (#${resolvedBaseline.toLocaleString()})`
|
||||
: `players ranked better than ${plebName}`;
|
||||
$: defaultLockedMessage = requirement?.requiresBetterRankThanAdmin
|
||||
? `You must be a BL Patreon supporter or ${baselineCopy} to use this tool.`
|
||||
: requirement?.lockedMessage ?? null;
|
||||
@ -47,15 +51,43 @@
|
||||
{#if showLockedMessage}
|
||||
<p class="mt-1">{showLockedMessage}</p>
|
||||
{/if}
|
||||
{#if plebProfile}
|
||||
<div class="pleb-card">
|
||||
<PlayerCard
|
||||
name={plebProfile.name ?? plebName}
|
||||
country={plebProfile.country ?? null}
|
||||
rank={plebProfile.rank ?? resolvedBaseline}
|
||||
showRank={Boolean(plebProfile.rank ?? resolvedBaseline)}
|
||||
avatar={plebProfile.avatar ?? null}
|
||||
avatarSize={48}
|
||||
techPp={plebProfile?.techPp}
|
||||
accPp={plebProfile?.accPp}
|
||||
passPp={plebProfile?.passPp}
|
||||
gradientId="pleb-baseline"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showCurrentRank}
|
||||
<p class="mt-2 text-xs text-amber-200/80">
|
||||
{#if playerRankDisplay}
|
||||
Current global rank: {playerRankDisplay}
|
||||
{:else}
|
||||
We couldn't determine your current BeatLeader rank. Refresh after your profile updates.
|
||||
We couldn't determine your current BeatLeader rank. Try logging in.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pleb-card {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pleb-card :global(.summary-header) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
131
src/lib/components/PlayerCard.svelte
Normal file
131
src/lib/components/PlayerCard.svelte
Normal file
@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { calculateSkillTriangle } from '$lib/utils/plebsaber-utils';
|
||||
|
||||
export let name: string = 'Unknown';
|
||||
export let country: string | null = null;
|
||||
export let avatar: string | null = null;
|
||||
export let rank: number | null = null;
|
||||
export let showRank: boolean = true;
|
||||
export let width: string = '12em';
|
||||
export let avatarSize: number = 48;
|
||||
export let techPp: number | null | undefined = null;
|
||||
export let accPp: 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
|
||||
export let gradientId: string = 'summary-triangle';
|
||||
|
||||
$: triangle = calculateSkillTriangle(techPp, accPp, passPp);
|
||||
$: triangleCorners = triangle?.corners;
|
||||
$: normalized = triangle?.normalized;
|
||||
$: hasTriangle = triangle !== null;
|
||||
|
||||
const gradientTechId = `${gradientId}-tech`;
|
||||
const gradientAccId = `${gradientId}-acc`;
|
||||
const gradientPassId = `${gradientId}-pass`;
|
||||
</script>
|
||||
|
||||
<div class="summary-header" 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}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.summary-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: var(--header-width, 50%);
|
||||
max-width: var(--header-width, 50%);
|
||||
min-width: 200px;
|
||||
flex: 0 0 var(--header-width, 50%);
|
||||
}
|
||||
|
||||
img {
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(34, 211, 238, 0.35);
|
||||
box-shadow: 0 0 12px rgba(34, 211, 238, 0.25);
|
||||
}
|
||||
|
||||
img.placeholder {
|
||||
opacity: 0.3;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.summary-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.summary-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(148, 163, 184, 0.75);
|
||||
}
|
||||
|
||||
.summary-triangle {
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary-triangle svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 4px 12px rgba(34, 211, 238, 0.25));
|
||||
}
|
||||
</style>
|
||||
|
||||
135
src/lib/components/PlayerSummaryHeader.svelte
Normal file
135
src/lib/components/PlayerSummaryHeader.svelte
Normal file
@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
export let name: string = 'Unknown';
|
||||
export let country: string | null = null;
|
||||
export let avatar: string | null = null;
|
||||
export let rank: number | null = null;
|
||||
export let showRank: boolean = true;
|
||||
export let width: string = '12em';
|
||||
export let avatarSize: number = 48;
|
||||
export let triangleCorners:
|
||||
| {
|
||||
tech: { x: number; y: number };
|
||||
acc: { x: number; y: number };
|
||||
pass: { x: number; y: number };
|
||||
}
|
||||
| null = null;
|
||||
export let normalized:
|
||||
| {
|
||||
tech: number;
|
||||
acc: number;
|
||||
pass: number;
|
||||
}
|
||||
| null = null;
|
||||
export let gradientId: string = 'summary-triangle';
|
||||
|
||||
const hasTriangle = triangleCorners && normalized;
|
||||
|
||||
const gradientTechId = `${gradientId}-tech`;
|
||||
const gradientAccId = `${gradientId}-acc`;
|
||||
const gradientPassId = `${gradientId}-pass`;
|
||||
</script>
|
||||
|
||||
<div class="summary-header" 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}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.summary-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: var(--header-width, 50%);
|
||||
max-width: var(--header-width, 50%);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(34, 211, 238, 0.35);
|
||||
box-shadow: 0 0 12px rgba(34, 211, 238, 0.25);
|
||||
}
|
||||
|
||||
img.placeholder {
|
||||
opacity: 0.3;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.summary-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.summary-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(148, 163, 184, 0.75);
|
||||
}
|
||||
|
||||
.summary-triangle {
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary-triangle svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 4px 12px rgba(34, 211, 238, 0.25));
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -46,6 +46,9 @@ export type BeatLeaderPlayerProfile = {
|
||||
country?: string | null;
|
||||
rank?: number | null;
|
||||
countryRank?: number | null;
|
||||
techPp?: number | null;
|
||||
accPp?: number | null;
|
||||
passPp?: number | null;
|
||||
};
|
||||
|
||||
export type RequirementContext = {
|
||||
@ -64,6 +67,23 @@ export type Difficulty = {
|
||||
characteristic: string;
|
||||
};
|
||||
|
||||
export type TriangleCorners = {
|
||||
tech: { x: number; y: number };
|
||||
acc: { x: number; y: number };
|
||||
pass: { x: number; y: number };
|
||||
};
|
||||
|
||||
export type TriangleNormalized = {
|
||||
tech: number;
|
||||
acc: number;
|
||||
pass: number;
|
||||
};
|
||||
|
||||
export type TriangleData = {
|
||||
corners: TriangleCorners;
|
||||
normalized: TriangleNormalized;
|
||||
} | null;
|
||||
|
||||
// ============================================================================
|
||||
// 2. Constants
|
||||
// ============================================================================
|
||||
@ -413,7 +433,81 @@ export function percentile(values: number[], p: number): number {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 6. Playlist Generation
|
||||
// 6. Skill Triangle Calculations
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_MAX_TECH_PP = 1300;
|
||||
const DEFAULT_MAX_ACC_PP = 15000;
|
||||
const DEFAULT_MAX_PASS_PP = 6000;
|
||||
const GYRON_LENGTH = 57.74;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||
|
||||
/**
|
||||
* Calculate skill triangle data from player's PP values
|
||||
* Returns corner coordinates and normalized values for rendering the skill triangle
|
||||
*/
|
||||
export function calculateSkillTriangle(
|
||||
techPp: number | null | undefined,
|
||||
accPp: number | null | undefined,
|
||||
passPp: number | null | undefined
|
||||
): TriangleData {
|
||||
const tech = typeof techPp === 'number' ? techPp : 0;
|
||||
const acc = typeof accPp === 'number' ? accPp : 0;
|
||||
const pass = typeof passPp === 'number' ? passPp : 0;
|
||||
|
||||
// If all values are zero, return null
|
||||
if (tech === 0 && acc === 0 && pass === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate triangle scale
|
||||
const triangleScale = Math.max(
|
||||
1,
|
||||
tech > 0 ? tech / DEFAULT_MAX_TECH_PP : 0,
|
||||
acc > 0 ? acc / DEFAULT_MAX_ACC_PP : 0,
|
||||
pass > 0 ? pass / DEFAULT_MAX_PASS_PP : 0
|
||||
);
|
||||
|
||||
const maxTechPp = DEFAULT_MAX_TECH_PP * triangleScale;
|
||||
const maxAccPp = DEFAULT_MAX_ACC_PP * triangleScale;
|
||||
const maxPassPp = DEFAULT_MAX_PASS_PP * triangleScale;
|
||||
|
||||
// Normalize PP values
|
||||
const normalizedTechPp = maxTechPp ? clamp(tech / maxTechPp, 0, 1) : 0;
|
||||
const normalizedAccPp = maxAccPp ? clamp(acc / maxAccPp, 0, 1) : 0;
|
||||
const normalizedPassPp = maxPassPp ? clamp(pass / maxPassPp, 0, 1) : 0;
|
||||
|
||||
// Calculate corner positions
|
||||
const cornerTech = {
|
||||
x: (GYRON_LENGTH - normalizedTechPp * GYRON_LENGTH) * 0.866,
|
||||
y: 86.6 - (GYRON_LENGTH - normalizedTechPp * GYRON_LENGTH) / 2
|
||||
};
|
||||
const cornerAcc = {
|
||||
x: 100 - (GYRON_LENGTH - normalizedAccPp * GYRON_LENGTH) * 0.866,
|
||||
y: 86.6 - (GYRON_LENGTH - normalizedAccPp * GYRON_LENGTH) / 2
|
||||
};
|
||||
const cornerPass = {
|
||||
x: 50,
|
||||
y: (86.6 - GYRON_LENGTH / 2) * (1 - normalizedPassPp)
|
||||
};
|
||||
|
||||
return {
|
||||
corners: {
|
||||
tech: cornerTech,
|
||||
acc: cornerAcc,
|
||||
pass: cornerPass
|
||||
},
|
||||
normalized: {
|
||||
tech: normalizedTechPp,
|
||||
acc: normalizedAccPp,
|
||||
pass: normalizedPassPp
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 7. Playlist Generation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
@ -472,7 +566,7 @@ export function downloadPlaylist(playlistData: unknown): void {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 7. Pagination Utilities
|
||||
// 8. Pagination Utilities
|
||||
// ============================================================================
|
||||
|
||||
export type PaginationResult<T> = {
|
||||
@ -503,7 +597,7 @@ export function calculatePagination<T>(
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 8. URL Parameter Utilities
|
||||
// 9. URL Parameter Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
|
||||
@ -27,8 +27,12 @@
|
||||
Educational use only: The information and resources on this page are for learning purposes. Do not use them for real authentication or accessing accounts.
|
||||
</div>
|
||||
<p>
|
||||
This app supports three ways to access your BeatLeader data: Steam, OAuth, and a website‑style session.
|
||||
For this app, I explored three ways to access your BeatLeader data: Steam, OAuth, or a website‑style session.
|
||||
</p>
|
||||
<p>
|
||||
I wanted tools like <a href="/tools/beatleader-compare">Compare Players</a> to show unranked star ratings when your BeatLeader account is a supporter and <code>ShowAllRatings</code> is enabled. That turns out to not be possible without implementing Steam ticket handling using the Steamworks SDK. <strong>The rest of the notes here were written before I realized that.</strong>
|
||||
</p>
|
||||
|
||||
|
||||
<!-- Top navigation -->
|
||||
<nav class="not-prose mt-4 flex flex-wrap gap-2">
|
||||
@ -82,11 +86,6 @@
|
||||
Default API auth is <strong>Steam</strong>. You can override per request using <code>?auth=steam|oauth|session|auto|none</code>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Tools like <a href="/tools/beatleader-compare">Compare Players</a>
|
||||
can show unranked star ratings when your BeatLeader account is a supporter and <code>ShowAllRatings</code> is enabled.
|
||||
</p>
|
||||
|
||||
<h2 id="steam">Steam Login</h2>
|
||||
<p>
|
||||
Authenticate via Steam OpenID to link your Steam account. Then use the <a href="/tools/beatleader-auth">BeatLeader Auth Tool</a> with your Steam session ticket to capture a website session.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import PlayerCard from '$lib/components/PlayerCard.svelte';
|
||||
|
||||
type Identity = {
|
||||
id?: string;
|
||||
@ -62,69 +63,35 @@
|
||||
{error}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<div class="card">
|
||||
<h2 class="card-title">Identity</h2>
|
||||
{#if identity}
|
||||
<dl class="info-grid">
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd>{identity.id ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>{identity.name ?? '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{:else}
|
||||
<p class="empty">No identity data returned.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if player}
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<div class="card">
|
||||
<h2 class="card-title">Player</h2>
|
||||
<PlayerCard
|
||||
name={player.name ?? identity?.name ?? 'Unknown'}
|
||||
country={player.country ?? null}
|
||||
rank={player.rank ?? null}
|
||||
showRank={typeof player.rank === 'number'}
|
||||
avatar={player.avatar ?? null}
|
||||
width="50%"
|
||||
avatarSize={48}
|
||||
techPp={player.techPp}
|
||||
accPp={player.accPp}
|
||||
passPp={player.passPp}
|
||||
gradientId="player-header"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Player</h2>
|
||||
{#if player}
|
||||
<div class="player-header">
|
||||
<img src={player.avatar ?? ''} alt="Avatar" class:placeholder={!player.avatar} />
|
||||
<div>
|
||||
<div class="player-name">{player.name ?? 'Unknown'}</div>
|
||||
<div class="player-meta">
|
||||
{#if player.country}
|
||||
<span>{player.country}</span>
|
||||
{#if player.countryRank !== null}
|
||||
<span>Rank: {player.countryRank}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if player.rank !== null}
|
||||
<span>• Global Rank: {player.rank}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-title">Player Details</h2>
|
||||
<dl class="info-grid">
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd>{player.id ?? '—'}</dd>
|
||||
<dt>Country Rank</dt>
|
||||
<dd>{typeof player.countryRank === 'number' ? player.countryRank : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Role</dt>
|
||||
<dd>{player.role ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Mapper</dt>
|
||||
<dd>
|
||||
{#if player.mapperId}
|
||||
<a class="link" href={`https://beatsaver.com/profile/${player.mapperId}`} target="_blank" rel="noreferrer">
|
||||
{player.mapperId}
|
||||
</a>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Level</dt>
|
||||
<dd>{player.level ?? '—'}</dd>
|
||||
<dt>Global Rank</dt>
|
||||
<dd>{typeof player.rank === 'number' ? player.rank : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>PP (Global)</dt>
|
||||
@ -142,6 +109,26 @@
|
||||
<dt>Pass PP</dt>
|
||||
<dd>{player.passPp ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Level</dt>
|
||||
<dd>{player.level ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Role</dt>
|
||||
<dd>{player.role ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Mapper</dt>
|
||||
<dd>
|
||||
{#if player.mapperId}
|
||||
<a class="link" href={`https://beatsaver.com/profile/${player.mapperId}`} target="_blank" rel="noreferrer">
|
||||
{player.mapperId}
|
||||
</a>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Banned</dt>
|
||||
<dd>{player.banned ? 'Yes' : 'No'}</dd>
|
||||
@ -151,11 +138,14 @@
|
||||
<dd>{player.profileSettings?.showAllRatings ? 'Enabled' : 'Disabled'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{:else}
|
||||
<p class="empty">No player profile found for this identity.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-6 card">
|
||||
<h2 class="card-title">Player</h2>
|
||||
<p class="empty">No player profile found for this identity.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@ -199,41 +189,6 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.player-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.player-header img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(34, 211, 238, 0.35);
|
||||
box-shadow: 0 0 18px rgba(34, 211, 238, 0.25);
|
||||
}
|
||||
|
||||
.player-header img.placeholder {
|
||||
opacity: 0.3;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.player-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(148, 163, 184, 0.75);
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(148, 163, 184, 0.7);
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getSession } from '../../lib/server/sessionStore';
|
||||
import { PLEB_BEATLEADER_ID } from '../../lib/utils/plebsaber-utils';
|
||||
import type { BeatLeaderPlayerProfile } from '../../lib/utils/plebsaber-utils';
|
||||
@ -8,36 +7,41 @@ const PLAYER_ENDPOINT = 'https://api.beatleader.com/player/';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies, fetch, url }) => {
|
||||
export const load: LayoutServerLoad = async ({ cookies, fetch }) => {
|
||||
const session = getSession(cookies);
|
||||
if (!session) {
|
||||
const pathWithQuery = `${url.pathname}${url.search}` || '/tools';
|
||||
throw redirect(302, `/auth/beatleader/login?redirect_uri=${encodeURIComponent(pathWithQuery)}`);
|
||||
}
|
||||
|
||||
let player: BeatLeaderPlayerProfile | null = null;
|
||||
let adminRank: number | null = null;
|
||||
try {
|
||||
const res = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
player = {
|
||||
id: typeof data.id === 'string' ? (data.id as string) : session.beatleaderId,
|
||||
name: typeof data.name === 'string' ? (data.name as string) : session.name ?? undefined,
|
||||
avatar: typeof data.avatar === 'string' ? (data.avatar as string) : session.avatar ?? null,
|
||||
country: typeof data.country === 'string' ? (data.country as string) : null,
|
||||
rank: typeof data.rank === 'number' ? (data.rank as number) : null,
|
||||
countryRank: typeof data.countryRank === 'number' ? (data.countryRank as number) : null
|
||||
};
|
||||
if (session.beatleaderId === PLEB_BEATLEADER_ID) {
|
||||
adminRank = typeof data.rank === 'number' ? (data.rank as number) : null;
|
||||
let adminPlayer: BeatLeaderPlayerProfile | null = null;
|
||||
|
||||
if (session) {
|
||||
try {
|
||||
const res = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
const profile: BeatLeaderPlayerProfile = {
|
||||
id: typeof data.id === 'string' ? (data.id as string) : session.beatleaderId,
|
||||
name: typeof data.name === 'string' ? (data.name as string) : session.name ?? undefined,
|
||||
avatar: typeof data.avatar === 'string' ? (data.avatar as string) : session.avatar ?? null,
|
||||
country: typeof data.country === 'string' ? (data.country as string) : null,
|
||||
rank: typeof data.rank === 'number' ? (data.rank as number) : null,
|
||||
countryRank: typeof data.countryRank === 'number' ? (data.countryRank as number) : null,
|
||||
techPp: typeof data.techPp === 'number' ? (data.techPp as number) : null,
|
||||
accPp: typeof data.accPp === 'number' ? (data.accPp as number) : null,
|
||||
passPp: typeof data.passPp === 'number' ? (data.passPp as number) : null
|
||||
};
|
||||
player = profile;
|
||||
if (session.beatleaderId === PLEB_BEATLEADER_ID) {
|
||||
adminRank = typeof data.rank === 'number' ? (data.rank as number) : null;
|
||||
adminPlayer = profile;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch BeatLeader profile for tools layout', err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch BeatLeader profile for tools layout', err);
|
||||
}
|
||||
|
||||
if (adminRank === null) {
|
||||
if (!adminPlayer || adminRank === null) {
|
||||
try {
|
||||
const adminRes = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(PLEB_BEATLEADER_ID)}?stats=true`);
|
||||
if (adminRes.ok) {
|
||||
@ -46,12 +50,23 @@ export const load: LayoutServerLoad = async ({ cookies, fetch, url }) => {
|
||||
if (typeof rankValue === 'number') {
|
||||
adminRank = rankValue;
|
||||
}
|
||||
adminPlayer = {
|
||||
id: typeof adminData.id === 'string' ? (adminData.id as string) : PLEB_BEATLEADER_ID,
|
||||
name: typeof adminData.name === 'string' ? (adminData.name as string) : undefined,
|
||||
avatar: typeof adminData.avatar === 'string' ? (adminData.avatar as string) : null,
|
||||
country: typeof adminData.country === 'string' ? (adminData.country as string) : null,
|
||||
rank: typeof adminData.rank === 'number' ? (adminData.rank as number) : null,
|
||||
countryRank: typeof adminData.countryRank === 'number' ? (adminData.countryRank as number) : null,
|
||||
techPp: typeof adminData.techPp === 'number' ? (adminData.techPp as number) : null,
|
||||
accPp: typeof adminData.accPp === 'number' ? (adminData.accPp as number) : null,
|
||||
passPp: typeof adminData.passPp === 'number' ? (adminData.passPp as number) : null
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch BeatLeader admin baseline', err);
|
||||
}
|
||||
}
|
||||
|
||||
return { hasBeatLeaderOAuth: true, player, adminRank };
|
||||
return { hasBeatLeaderOAuth: Boolean(session), player, adminRank, adminPlayer };
|
||||
};
|
||||
|
||||
|
||||
@ -27,12 +27,13 @@
|
||||
leaderboardId?: string;
|
||||
};
|
||||
|
||||
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null };
|
||||
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null; adminPlayer: BeatLeaderPlayerProfile | null };
|
||||
|
||||
const requirement = TOOL_REQUIREMENTS['beatleader-compare'];
|
||||
|
||||
$: playerProfile = data?.player ?? null;
|
||||
$: adminRank = data?.adminRank ?? null;
|
||||
$: adminPlayer = data?.adminPlayer ?? null;
|
||||
|
||||
let playerA = '';
|
||||
let playerB = '';
|
||||
@ -199,7 +200,7 @@
|
||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Compare Players</h1>
|
||||
<p class="mt-2 text-muted">Maps Player A has played that Player B hasn't — configurable lookback.</p>
|
||||
|
||||
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank}>
|
||||
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}>
|
||||
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={results.length > 0} oncompare={onCompare}>
|
||||
<svelte:fragment slot="extra-buttons">
|
||||
{#if results.length > 0}
|
||||
|
||||
@ -39,12 +39,13 @@
|
||||
leaderboardId?: string;
|
||||
};
|
||||
|
||||
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null };
|
||||
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null; adminPlayer: BeatLeaderPlayerProfile | null };
|
||||
|
||||
const requirement = TOOL_REQUIREMENTS['beatleader-headtohead'];
|
||||
|
||||
$: playerProfile = data?.player ?? null;
|
||||
$: adminRank = data?.adminRank ?? null;
|
||||
$: adminPlayer = data?.adminPlayer ?? null;
|
||||
|
||||
let playerA = '';
|
||||
let playerB = '';
|
||||
@ -294,7 +295,7 @@
|
||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Head-to-Head</h1>
|
||||
<p class="mt-2 text-muted">Paginated head-to-head results on the same map and difficulty.</p>
|
||||
|
||||
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank}>
|
||||
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}>
|
||||
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={items.length > 0} oncompare={onCompare} />
|
||||
|
||||
{#if errorMsg}
|
||||
|
||||
@ -50,12 +50,13 @@
|
||||
mapper?: string;
|
||||
};
|
||||
|
||||
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null };
|
||||
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null; adminPlayer: BeatLeaderPlayerProfile | null };
|
||||
|
||||
const requirement = TOOL_REQUIREMENTS['beatleader-playlist-gap'];
|
||||
|
||||
$: playerProfile = data?.player ?? null;
|
||||
$: adminRank = data?.adminRank ?? null;
|
||||
$: adminPlayer = data?.adminPlayer ?? null;
|
||||
|
||||
let playerId = '';
|
||||
let selectedFileName: string | null = null;
|
||||
@ -356,7 +357,7 @@
|
||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Playlist Gap</h1>
|
||||
<p class="mt-2 text-muted">Upload a .bplist and enter a player ID to find songs they have not played.</p>
|
||||
|
||||
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank}>
|
||||
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}>
|
||||
<form class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 items-end" on:submit|preventDefault={onAnalyze}>
|
||||
<div class="sm:col-span-2 lg:col-span-2">
|
||||
<label class="block text-sm text-muted">Playlist file (.bplist)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user