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`).
|
||||
- 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.
|
||||
- Core BeatLeader tool pages reuse shared components such as `PlayerCompareForm.svelte`, `MapCard.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.
|
||||
- `/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.
|
||||
- Core BeatLeader tool pages reuse shared components such as `PlayerCompareForm.svelte`, `MapCard.svelte`, `PlayerCard.svelte`, and `SongPlayer.svelte` for consistent UI/UX.
|
||||
- `/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 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
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
formatToolRequirementSummary,
|
||||
meetsToolRequirement,
|
||||
DEFAULT_ADMIN_RANK_FALLBACK,
|
||||
PLEB_BEATLEADER_ID,
|
||||
@ -13,14 +12,12 @@
|
||||
|
||||
export let player: BeatLeaderPlayerProfile | null = null;
|
||||
export let requirement: ToolRequirement | null = null;
|
||||
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;
|
||||
@ -29,11 +26,6 @@
|
||||
$: baselineCopy = resolvedBaseline
|
||||
? `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;
|
||||
$: lockedMessage = customLockedMessage ?? defaultLockedMessage;
|
||||
$: showLockedMessage = lockedMessage && lockedMessage !== summary ? lockedMessage : null;
|
||||
$: playerRank = typeof player?.rank === 'number' ? player?.rank ?? null : null;
|
||||
$: playerRankDisplay = playerRank !== null ? `#${playerRank.toLocaleString()}` : null;
|
||||
</script>
|
||||
@ -43,14 +35,8 @@
|
||||
{: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">
|
||||
<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>
|
||||
{#if summary}
|
||||
<p class="mt-1">{summary}</p>
|
||||
{/if}
|
||||
{#if showLockedMessage}
|
||||
<p class="mt-1">{showLockedMessage}</p>
|
||||
{/if}
|
||||
{#if plebProfile}
|
||||
<div class="pleb-card">
|
||||
<PlayerCard
|
||||
@ -63,6 +49,7 @@
|
||||
techPp={plebProfile?.techPp}
|
||||
accPp={plebProfile?.accPp}
|
||||
passPp={plebProfile?.passPp}
|
||||
playerId={plebProfile?.id ?? PLEB_BEATLEADER_ID}
|
||||
gradientId="pleb-baseline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { dev } from '$app/environment';
|
||||
import beatleaderLogo from '$lib/assets/beatleader-logo.png';
|
||||
import { DEFAULT_ADMIN_RANK_FALLBACK, PLEB_BEATLEADER_ID } from '$lib/utils/plebsaber-utils';
|
||||
const links = [
|
||||
@ -134,10 +133,11 @@
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-14 items-center justify-between">
|
||||
<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="neon-text">PLEBSABER</span><span class="text-muted">.stream</span>
|
||||
<span class="neon-text">plebsaber</span><span class="text-muted">.stream</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>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-6">
|
||||
@ -176,9 +176,7 @@
|
||||
{#if user?.id === PLEB_BEATLEADER_ID}
|
||||
<a href="/tools/stats" class="dropdown-item" on:click={closeMenu}>Stats</a>
|
||||
{/if}
|
||||
{#if dev}
|
||||
<a href="/testing" class="dropdown-item" on:click={closeMenu}>Testing</a>
|
||||
{/if}
|
||||
<a href="/player-info" class="dropdown-item" on:click={closeMenu}>Player Info</a>
|
||||
<a href={getProfileUrl(user.id)} class="dropdown-item dropdown-item--with-icon" target="_blank" rel="noreferrer noopener" on:click={closeMenu}>
|
||||
<span>Profile</span>
|
||||
<img src={beatleaderLogo} alt="BeatLeader" class="dropdown-icon" />
|
||||
@ -222,9 +220,7 @@
|
||||
{#if user?.id === PLEB_BEATLEADER_ID}
|
||||
<a href="/tools/stats" class="dropdown-item" on:click={close}>Stats</a>
|
||||
{/if}
|
||||
{#if dev}
|
||||
<a href="/testing" class="dropdown-item" on:click={close}>Testing</a>
|
||||
{/if}
|
||||
<a href="/player-info" class="dropdown-item" on:click={close}>Player Info</a>
|
||||
<a href={getProfileUrl(user.id)} class="dropdown-item dropdown-item--with-icon" target="_blank" rel="noreferrer noopener" on:click={close}>
|
||||
<span>Profile</span>
|
||||
<img src={beatleaderLogo} alt="BeatLeader" class="dropdown-icon" />
|
||||
|
||||
@ -13,17 +13,65 @@
|
||||
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';
|
||||
export let playerId: string | null = null;
|
||||
|
||||
$: triangle = calculateSkillTriangle(techPp, accPp, passPp);
|
||||
$: triangleCorners = triangle?.corners;
|
||||
$: normalized = triangle?.normalized;
|
||||
$: hasTriangle = triangle !== null;
|
||||
$: profileUrl = playerId ? `https://beatleader.com/u/${encodeURIComponent(playerId)}` : null;
|
||||
|
||||
const gradientTechId = `${gradientId}-tech`;
|
||||
const gradientAccId = `${gradientId}-acc`;
|
||||
const gradientPassId = `${gradientId}-pass`;
|
||||
</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`}>
|
||||
<img src={avatar ?? ''} alt="Avatar" class:placeholder={!avatar} />
|
||||
<div class="summary-body">
|
||||
@ -68,6 +116,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.summary-header {
|
||||
@ -78,6 +127,8 @@
|
||||
max-width: var(--header-width, 50%);
|
||||
min-width: 200px;
|
||||
flex: 0 0 var(--header-width, 50%);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img {
|
||||
@ -127,5 +178,19 @@
|
||||
height: 100%;
|
||||
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>
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
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 playerB = '';
|
||||
@ -10,6 +12,11 @@
|
||||
|
||||
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
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
@ -38,14 +45,43 @@
|
||||
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();
|
||||
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?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="mt-6 grid gap-4 sm:grid-cols-3 items-end" on:submit={handleSubmit}>
|
||||
<div>
|
||||
<form class="mt-6" on:submit={handleSubmit}>
|
||||
<div class="form-grid">
|
||||
<div class="player-column">
|
||||
<div class="input-tile">
|
||||
<label class="block text-sm text-muted">Player A ID
|
||||
<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>
|
||||
</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
|
||||
<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>
|
||||
</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}>
|
||||
{#if loading}
|
||||
Loading...
|
||||
@ -78,6 +168,44 @@
|
||||
</form>
|
||||
|
||||
<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 {
|
||||
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));
|
||||
|
||||
@ -103,7 +103,7 @@ const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = {
|
||||
};
|
||||
|
||||
export const TOOL_REQUIREMENTS = {
|
||||
'beatleader-compare': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'beatleader-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT,
|
||||
'beatleader-playlist-gap': DEFAULT_PRIVATE_TOOL_REQUIREMENT
|
||||
} as const satisfies Record<string, ToolRequirement>;
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
{#if loading}
|
||||
@ -64,60 +64,58 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#if player}
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<div class="card">
|
||||
<h2 class="card-title">Player</h2>
|
||||
<div class="mt-6">
|
||||
<div class="player-tile">
|
||||
<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}
|
||||
width="100%"
|
||||
avatarSize={64}
|
||||
techPp={player.techPp}
|
||||
accPp={player.accPp}
|
||||
passPp={player.passPp}
|
||||
playerId={player.id ?? null}
|
||||
gradientId="player-header"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Player Details</h2>
|
||||
<dl class="info-grid">
|
||||
<div>
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="stat-tile">
|
||||
<dt>Country Rank</dt>
|
||||
<dd>{typeof player.countryRank === 'number' ? player.countryRank : '—'}</dd>
|
||||
<dd>{typeof player.countryRank === 'number' ? `#${player.countryRank.toLocaleString()}` : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-tile">
|
||||
<dt>Global Rank</dt>
|
||||
<dd>{typeof player.rank === 'number' ? player.rank : '—'}</dd>
|
||||
<dd>{typeof player.rank === 'number' ? `#${player.rank.toLocaleString()}` : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-tile">
|
||||
<dt>PP (Global)</dt>
|
||||
<dd>{player.pp ?? '—'}</dd>
|
||||
<dd>{typeof player.pp === 'number' ? player.pp.toFixed(2) : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-tile">
|
||||
<dt>Tech PP</dt>
|
||||
<dd>{player.techPp ?? '—'}</dd>
|
||||
<dd>{typeof player.techPp === 'number' ? player.techPp.toFixed(2) : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-tile">
|
||||
<dt>Acc PP</dt>
|
||||
<dd>{player.accPp ?? '—'}</dd>
|
||||
<dd>{typeof player.accPp === 'number' ? player.accPp.toFixed(2) : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-tile">
|
||||
<dt>Pass PP</dt>
|
||||
<dd>{player.passPp ?? '—'}</dd>
|
||||
<dd>{typeof player.passPp === 'number' ? player.passPp.toFixed(2) : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-tile">
|
||||
<dt>Level</dt>
|
||||
<dd>{player.level ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-tile">
|
||||
<dt>Role</dt>
|
||||
<dd>{player.role ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-tile">
|
||||
<dt>Mapper</dt>
|
||||
<dd>
|
||||
{#if player.mapperId}
|
||||
@ -129,20 +127,18 @@
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-tile">
|
||||
<dt>Banned</dt>
|
||||
<dd>{player.banned ? 'Yes' : 'No'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-tile">
|
||||
<dt>Show All Ratings</dt>
|
||||
<dd>{player.profileSettings?.showAllRatings ? 'Enabled' : 'Disabled'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-6 card">
|
||||
<h2 class="card-title">Player</h2>
|
||||
<div class="mt-6 player-tile">
|
||||
<p class="empty">No player profile found for this identity.</p>
|
||||
</div>
|
||||
{/if}
|
||||
@ -150,7 +146,7 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
.player-tile {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
@ -158,30 +154,32 @@
|
||||
box-shadow: 0 20px 40px rgba(8, 14, 35, 0.35);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: rgba(226, 232, 240, 0.95);
|
||||
margin-bottom: 1rem;
|
||||
.stat-tile {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
padding: 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 {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
.stat-tile:hover {
|
||||
border-color: rgba(34, 211, 238, 0.25);
|
||||
}
|
||||
|
||||
dt {
|
||||
color: rgba(148, 163, 184, 0.75);
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
dd {
|
||||
color: rgba(226, 232, 240, 0.92);
|
||||
color: rgba(226, 232, 240, 0.95);
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link {
|
||||
@ -189,9 +187,14 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: rgba(34, 211, 238, 1);
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(148, 163, 184, 0.7);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#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 Head-to-Head', href: '/tools/beatleader-headtohead', desc: 'Compare two players on the same map/difficulty' }
|
||||
] as tool}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import MapCard from '$lib/components/MapCard.svelte';
|
||||
import PlayerCompareForm from '$lib/components/PlayerCompareForm.svelte';
|
||||
import PlayerCard from '$lib/components/PlayerCard.svelte';
|
||||
import HasToolAccess from '$lib/components/HasToolAccess.svelte';
|
||||
import {
|
||||
type MapMeta,
|
||||
@ -29,7 +30,7 @@
|
||||
|
||||
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;
|
||||
$: adminRank = data?.adminRank ?? null;
|
||||
@ -41,6 +42,9 @@
|
||||
let errorMsg: string | null = null;
|
||||
let results: SongItem[] = [];
|
||||
let loadingMeta = false;
|
||||
let playerAProfile: BeatLeaderPlayerProfile | null = null;
|
||||
let playerBProfile: BeatLeaderPlayerProfile | null = null;
|
||||
let hasCompared = false;
|
||||
|
||||
// Sorting and pagination state
|
||||
let sortBy: 'date' | 'difficulty' = 'date';
|
||||
@ -93,6 +97,16 @@
|
||||
loadingStars = false;
|
||||
}
|
||||
|
||||
async function fetchPlayerProfile(playerId: string): Promise<BeatLeaderPlayerProfile | null> {
|
||||
try {
|
||||
const res = await fetch(`https://api.beatleader.xyz/player/${encodeURIComponent(playerId)}`);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as BeatLeaderPlayerProfile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllRecentScores(playerId: string, cutoffEpoch: number, maxPages = 15): Promise<BeatLeaderScore[]> {
|
||||
const qs = new URLSearchParams({ diff: 'ExpertPlus', cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
|
||||
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
|
||||
@ -117,6 +131,9 @@
|
||||
async function onCompare() {
|
||||
errorMsg = null;
|
||||
results = [];
|
||||
playerAProfile = null;
|
||||
playerBProfile = null;
|
||||
hasCompared = false;
|
||||
const a = playerA.trim();
|
||||
const b = playerB.trim();
|
||||
if (!a || !b) {
|
||||
@ -128,11 +145,17 @@
|
||||
try {
|
||||
const cutoff = getCutoffEpochFromMonths(monthsA);
|
||||
const cutoffB = getCutoffEpochFromMonths(monthsB);
|
||||
const [aScores, bScores] = await Promise.all([
|
||||
const [aScores, bScores, profileA, profileB] = await Promise.all([
|
||||
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 bExpertPlusKeys = new Set<string>(); // `${hashLower}|ExpertPlus|${modeName}`
|
||||
for (const s of bScores) {
|
||||
@ -197,11 +220,61 @@
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank} adminPlayer={adminPlayer}>
|
||||
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={results.length > 0} oncompare={onCompare}>
|
||||
<svelte:fragment slot="player-a-card">
|
||||
{#if hasCompared}
|
||||
<div class="player-card-wrapper">
|
||||
{#if playerAProfile}
|
||||
<PlayerCard
|
||||
name={playerAProfile.name ?? 'Player A'}
|
||||
avatar={playerAProfile.avatar ?? null}
|
||||
country={playerAProfile.country ?? null}
|
||||
rank={playerAProfile.rank ?? null}
|
||||
showRank={typeof playerAProfile.rank === 'number'}
|
||||
width="100%"
|
||||
avatarSize={56}
|
||||
techPp={playerAProfile.techPp}
|
||||
accPp={playerAProfile.accPp}
|
||||
passPp={playerAProfile.passPp}
|
||||
playerId={playerAProfile.id ?? null}
|
||||
gradientId="compare-player-a"
|
||||
/>
|
||||
{:else}
|
||||
<div class="empty-profile">Player A profile not found</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="player-b-card">
|
||||
{#if hasCompared}
|
||||
<div class="player-card-wrapper">
|
||||
{#if playerBProfile}
|
||||
<PlayerCard
|
||||
name={playerBProfile.name ?? 'Player B'}
|
||||
avatar={playerBProfile.avatar ?? null}
|
||||
country={playerBProfile.country ?? null}
|
||||
rank={playerBProfile.rank ?? null}
|
||||
showRank={typeof playerBProfile.rank === 'number'}
|
||||
width="100%"
|
||||
avatarSize={56}
|
||||
techPp={playerBProfile.techPp}
|
||||
accPp={playerBProfile.accPp}
|
||||
passPp={playerBProfile.passPp}
|
||||
playerId={playerBProfile.id ?? null}
|
||||
gradientId="compare-player-b"
|
||||
/>
|
||||
{:else}
|
||||
<div class="empty-profile">Player B profile not found</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="extra-buttons">
|
||||
{#if results.length > 0}
|
||||
<button type="button" class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={handleDownloadPlaylist}>Download .bplist</button>
|
||||
@ -327,6 +400,27 @@
|
||||
background: #0f172a;
|
||||
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>
|
||||
|
||||
|
||||
@ -7,10 +7,36 @@ type StatsUser = {
|
||||
beatleaderId: string;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
country: string | null;
|
||||
rank: number | null;
|
||||
techPp: number | null;
|
||||
accPp: number | null;
|
||||
passPp: number | null;
|
||||
lastSeenAt: number;
|
||||
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 }) => {
|
||||
const currentSession = getSession(cookies);
|
||||
if (!currentSession) {
|
||||
@ -22,22 +48,33 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
||||
}
|
||||
|
||||
const sessions = getAllSessions();
|
||||
const aggregated = new Map<string, StatsUser>();
|
||||
const aggregated = new Map<string, { session: typeof sessions[0] }>();
|
||||
|
||||
for (const stored of sessions) {
|
||||
const existing = aggregated.get(stored.beatleaderId);
|
||||
if (!existing || stored.lastSeenAt > existing.lastSeenAt) {
|
||||
aggregated.set(stored.beatleaderId, {
|
||||
beatleaderId: stored.beatleaderId,
|
||||
name: stored.name,
|
||||
avatar: stored.avatar,
|
||||
lastSeenAt: stored.lastSeenAt,
|
||||
lastSeenIso: new Date(stored.lastSeenAt).toISOString()
|
||||
});
|
||||
if (!existing || stored.lastSeenAt > existing.session.lastSeenAt) {
|
||||
aggregated.set(stored.beatleaderId, { session: stored });
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
users
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
|
||||
export let data: PageData;
|
||||
@ -44,13 +44,13 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>PLEBSABER · Stats</title>
|
||||
<title>plebsaber · Stats</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="page-wrapper">
|
||||
<header class="page-header">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@ -60,21 +60,36 @@
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="user-grid" aria-live="polite">
|
||||
{#each data.users as user}
|
||||
{#each data.users as user, index}
|
||||
<li class="user-card">
|
||||
<img
|
||||
src={user.avatar ?? beatleaderLogo}
|
||||
alt={user.name ? `${user.name}'s BeatLeader avatar` : 'BeatLeader avatar placeholder'}
|
||||
class="user-avatar"
|
||||
loading="lazy"
|
||||
<div class="card-header">
|
||||
<PlayerCard
|
||||
name={user.name ?? 'Unknown BeatLeader user'}
|
||||
avatar={user.avatar ?? null}
|
||||
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 class="user-name">{user.name ?? 'Unknown BeatLeader user'}</div>
|
||||
<div class="user-id">ID: {user.beatleaderId}</div>
|
||||
<time class="user-seen" datetime={user.lastSeenIso}>
|
||||
Last session {formatTimeSince(user.lastSeenAt)}
|
||||
</div>
|
||||
<div class="user-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">ID</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@ -121,8 +136,8 @@
|
||||
|
||||
.user-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1.25rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
@ -130,65 +145,62 @@
|
||||
|
||||
.user-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem;
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.85rem;
|
||||
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));
|
||||
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.05);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
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;
|
||||
}
|
||||
|
||||
.user-card:hover,
|
||||
.user-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
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 {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
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);
|
||||
.card-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
.user-meta {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
align-content: center;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
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;
|
||||
color: rgba(248, 250, 252, 0.95);
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(148, 163, 184, 0.7);
|
||||
}
|
||||
|
||||
.user-seen {
|
||||
.meta-value {
|
||||
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) {
|
||||
.user-card {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
.user-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
.user-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user