Build re-usable playerCard component with the skill triangle
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user