Must be ranked higher than pleb to use tools
This commit is contained in:
parent
f59db0021d
commit
5daf221cd7
18
AGENTS.md
Normal file
18
AGENTS.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# AGENT NOTES
|
||||||
|
|
||||||
|
- SvelteKit app under `src/`; tools require BeatLeader auth and rank gating.
|
||||||
|
- Shared logic lives in `src/lib/utils/plebsaber-utils.ts` (requirements, playlist helpers, etc.).
|
||||||
|
- Tool routes (`/tools/*`) use a layout that fetches the BeatLeader profile once (`+layout.server.ts`).
|
||||||
|
- UI components for gating (e.g., `HasToolAccess.svelte`) handle supporter/top-3k restrictions.
|
||||||
|
- BeatLeader session info exposed via `/api/beatleader/me`; navbar warns if outside allowed rank.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Shell command guidance
|
||||||
|
|
||||||
|
- Whitelisted commands: `grep`
|
||||||
|
- DO NOT USE: `cd` (prefer `pwd`)
|
||||||
@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
formatToolRequirementSummary,
|
formatToolRequirementSummary,
|
||||||
meetsToolRequirement,
|
meetsToolRequirement,
|
||||||
|
DEFAULT_ADMIN_RANK_FALLBACK,
|
||||||
type BeatLeaderPlayerProfile,
|
type BeatLeaderPlayerProfile,
|
||||||
type ToolRequirement
|
type ToolRequirement
|
||||||
} from '$lib/utils/plebsaber-utils';
|
} from '$lib/utils/plebsaber-utils';
|
||||||
@ -12,10 +13,22 @@
|
|||||||
export let requirement: ToolRequirement | null = null;
|
export let requirement: ToolRequirement | null = null;
|
||||||
export let customLockedMessage: string | null = null;
|
export let customLockedMessage: string | null = null;
|
||||||
export let showCurrentRank = true;
|
export let showCurrentRank = true;
|
||||||
|
export let adminRank: number | null = null;
|
||||||
|
|
||||||
$: hasAccess = meetsToolRequirement(player, requirement);
|
$: requirementContext = { adminRank };
|
||||||
$: summary = formatToolRequirementSummary(requirement);
|
$: hasAccess = meetsToolRequirement(player, requirement, requirementContext);
|
||||||
$: lockedMessage = customLockedMessage ?? requirement?.lockedMessage ?? null;
|
$: summary = formatToolRequirementSummary(requirement, requirementContext);
|
||||||
|
$: fallbackBaseline = DEFAULT_ADMIN_RANK_FALLBACK;
|
||||||
|
$: resolvedBaseline = typeof adminRank === 'number' && Number.isFinite(adminRank) && adminRank > 0
|
||||||
|
? adminRank
|
||||||
|
: fallbackBaseline;
|
||||||
|
$: baselineCopy = resolvedBaseline
|
||||||
|
? `players ranked better than pleb (#${resolvedBaseline.toLocaleString()})`
|
||||||
|
: 'players ranked better than pleb';
|
||||||
|
$: 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;
|
$: 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;
|
||||||
@ -26,7 +39,7 @@
|
|||||||
{: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 the top 3k ranked players).
|
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}
|
{#if summary}
|
||||||
<p class="mt-1">{summary}</p>
|
<p class="mt-1">{summary}</p>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { dev } from '$app/environment';
|
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';
|
||||||
const links = [
|
const links = [
|
||||||
{ href: '/', label: 'Home' },
|
{ href: '/', label: 'Home' },
|
||||||
{ href: '/tools', label: 'Tools' },
|
{ href: '/tools', label: 'Tools' },
|
||||||
@ -37,6 +37,8 @@
|
|||||||
let loginHref = '/auth/beatleader/login';
|
let loginHref = '/auth/beatleader/login';
|
||||||
let checkingSession = true;
|
let checkingSession = true;
|
||||||
let menuOpen = false;
|
let menuOpen = false;
|
||||||
|
let adminRank: number | null = null;
|
||||||
|
let adminFallbackRank = DEFAULT_ADMIN_RANK_FALLBACK;
|
||||||
|
|
||||||
const getProfileUrl = (id?: string) => (id ? `https://beatleader.com/u/${encodeURIComponent(id)}` : 'https://beatleader.com');
|
const getProfileUrl = (id?: string) => (id ? `https://beatleader.com/u/${encodeURIComponent(id)}` : 'https://beatleader.com');
|
||||||
|
|
||||||
@ -64,6 +66,18 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractAdminBaseline(payload: unknown): { rank: number | null; fallback: number } | null {
|
||||||
|
if (!payload || typeof payload !== 'object') return null;
|
||||||
|
const baseline = (payload as { adminBaseline?: { rank?: unknown; fallback?: unknown } }).adminBaseline;
|
||||||
|
if (!baseline || typeof baseline !== 'object') return null;
|
||||||
|
const rank = (baseline as { rank?: unknown }).rank;
|
||||||
|
const fallback = (baseline as { fallback?: unknown }).fallback;
|
||||||
|
return {
|
||||||
|
rank: typeof rank === 'number' && Number.isFinite(rank) && rank > 0 ? rank : null,
|
||||||
|
fallback: typeof fallback === 'number' && Number.isFinite(fallback) && fallback > 0 ? fallback : DEFAULT_ADMIN_RANK_FALLBACK
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const redirectTarget = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
|
const redirectTarget = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
|
||||||
loginHref = `/auth/beatleader/login?redirect_uri=${encodeURIComponent(redirectTarget)}`;
|
loginHref = `/auth/beatleader/login?redirect_uri=${encodeURIComponent(redirectTarget)}`;
|
||||||
@ -77,6 +91,11 @@
|
|||||||
if (profile) {
|
if (profile) {
|
||||||
user = profile;
|
user = profile;
|
||||||
}
|
}
|
||||||
|
const baseline = extractAdminBaseline(json);
|
||||||
|
if (baseline) {
|
||||||
|
adminRank = baseline.rank;
|
||||||
|
adminFallbackRank = baseline.fallback;
|
||||||
|
}
|
||||||
} else if (res.status === 401) {
|
} else if (res.status === 401) {
|
||||||
try {
|
try {
|
||||||
const body = (await res.json()) as Record<string, unknown>;
|
const body = (await res.json()) as Record<string, unknown>;
|
||||||
@ -103,6 +122,12 @@
|
|||||||
function closeMenu() {
|
function closeMenu() {
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: baselineRank = (adminRank ?? adminFallbackRank ?? DEFAULT_ADMIN_RANK_FALLBACK);
|
||||||
|
$: requiresWarning = typeof user?.rank === 'number' && Number.isFinite(user.rank)
|
||||||
|
? (user.rank as number) > baselineRank
|
||||||
|
: false;
|
||||||
|
$: baselineCopy = baselineRank ? `pleb (#${baselineRank.toLocaleString()})` : 'pleb';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="sticky top-0 z-40 backdrop-blur supports-[backdrop-filter]:bg-surface/50 border-b border-white/10">
|
<header class="sticky top-0 z-40 backdrop-blur supports-[backdrop-filter]:bg-surface/50 border-b border-white/10">
|
||||||
@ -142,12 +167,15 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if menuOpen}
|
{#if menuOpen}
|
||||||
<div class="dropdown" role="menu">
|
<div class="dropdown" role="menu">
|
||||||
{#if typeof user.rank === 'number' && user.rank > 3000}
|
{#if requiresWarning}
|
||||||
<div class="dropdown-warning">
|
<div class="dropdown-warning">
|
||||||
<strong>Heads up:</strong>
|
<strong>Heads up:</strong>
|
||||||
<span>Tools are limited to <a class="underline" href="https://www.patreon.com/BeatLeader" target="_blank" rel="noreferrer noopener">BeatLeader supporters</a> or players ranked in the global top 3k.</span>
|
<span>Tools are limited to <a class="underline" href="https://www.patreon.com/BeatLeader" target="_blank" rel="noreferrer noopener">BeatLeader supporters</a> or players ranked better than {baselineCopy}.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if user?.id === PLEB_BEATLEADER_ID}
|
||||||
|
<a href="/tools/stats" class="dropdown-item" on:click={closeMenu}>Stats</a>
|
||||||
|
{/if}
|
||||||
{#if dev}
|
{#if dev}
|
||||||
<a href="/testing" class="dropdown-item" on:click={closeMenu}>Testing</a>
|
<a href="/testing" class="dropdown-item" on:click={closeMenu}>Testing</a>
|
||||||
{/if}
|
{/if}
|
||||||
@ -185,12 +213,15 @@
|
|||||||
<span class="text-sm text-muted">Connecting…</span>
|
<span class="text-sm text-muted">Connecting…</span>
|
||||||
{:else if user}
|
{:else if user}
|
||||||
<div class="border border-white/10 rounded-md overflow-hidden">
|
<div class="border border-white/10 rounded-md overflow-hidden">
|
||||||
{#if typeof user.rank === 'number' && user.rank > 3000}
|
{#if requiresWarning}
|
||||||
<div class="dropdown-warning mobile">
|
<div class="dropdown-warning mobile">
|
||||||
<strong>Heads up:</strong>
|
<strong>Heads up:</strong>
|
||||||
<span>Tools are limited to <a class="underline" href="https://www.patreon.com/BeatLeader" target="_blank" rel="noreferrer noopener">BeatLeader supporters</a> or players inside the global top 3k.</span>
|
<span>Tools are limited to <a class="underline" href="https://www.patreon.com/BeatLeader" target="_blank" rel="noreferrer noopener">BeatLeader supporters</a> or players ranked better than {baselineCopy}.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if user?.id === PLEB_BEATLEADER_ID}
|
||||||
|
<a href="/tools/stats" class="dropdown-item" on:click={close}>Stats</a>
|
||||||
|
{/if}
|
||||||
{#if dev}
|
{#if dev}
|
||||||
<a href="/testing" class="dropdown-item" on:click={close}>Testing</a>
|
<a href="/testing" class="dropdown-item" on:click={close}>Testing</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -50,6 +50,11 @@ function writeSessions(sessions: Record<string, StoredSession>): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAllSessions(): StoredSession[] {
|
||||||
|
const sessions = readSessions();
|
||||||
|
return Object.values(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
function baseCookieOptions() {
|
function baseCookieOptions() {
|
||||||
return {
|
return {
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@ -48,8 +48,13 @@ export type BeatLeaderPlayerProfile = {
|
|||||||
countryRank?: number | null;
|
countryRank?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RequirementContext = {
|
||||||
|
adminRank?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ToolRequirement = {
|
export type ToolRequirement = {
|
||||||
minGlobalRank?: number;
|
minGlobalRank?: number;
|
||||||
|
requiresBetterRankThanAdmin?: boolean;
|
||||||
summary: string;
|
summary: string;
|
||||||
lockedMessage?: string;
|
lockedMessage?: string;
|
||||||
};
|
};
|
||||||
@ -64,15 +69,17 @@ export type Difficulty = {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
|
export const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
|
||||||
|
export const PLEB_BEATLEADER_ID = '76561199407393962';
|
||||||
|
export const DEFAULT_ADMIN_RANK_FALLBACK = 3000;
|
||||||
|
|
||||||
export const DIFFICULTIES = ['Easy', 'Normal', 'Hard', 'Expert', 'ExpertPlus'] as const;
|
export const DIFFICULTIES = ['Easy', 'Normal', 'Hard', 'Expert', 'ExpertPlus'] as const;
|
||||||
|
|
||||||
export const MODES = ['Standard', 'Lawless', 'OneSaber', 'NoArrows', 'Lightshow'] as const;
|
export const MODES = ['Standard', 'Lawless', 'OneSaber', 'NoArrows', 'Lightshow'] as const;
|
||||||
|
|
||||||
const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = {
|
const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = {
|
||||||
minGlobalRank: 3000,
|
requiresBetterRankThanAdmin: true,
|
||||||
summary: 'BeatLeader global rank within the top 3000',
|
summary: 'Ranked higher than pleb on BeatLeader',
|
||||||
lockedMessage: 'You must be a BL Patreon supporter or remain ranked in the global top 3k to use this tool.'
|
lockedMessage: 'You must either contribute to BL Patreon (or be ranked higher than pleb) to use this tool'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TOOL_REQUIREMENTS = {
|
export const TOOL_REQUIREMENTS = {
|
||||||
@ -89,19 +96,45 @@ export function getToolRequirement(key: string): ToolRequirement | null {
|
|||||||
|
|
||||||
export function meetsToolRequirement(
|
export function meetsToolRequirement(
|
||||||
profile: BeatLeaderPlayerProfile | null | undefined,
|
profile: BeatLeaderPlayerProfile | null | undefined,
|
||||||
requirement: ToolRequirement | null | undefined
|
requirement: ToolRequirement | null | undefined,
|
||||||
|
context?: RequirementContext
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!requirement) return true;
|
if (!requirement) return true;
|
||||||
|
const rank = profile?.rank ?? null;
|
||||||
|
|
||||||
|
if (requirement.requiresBetterRankThanAdmin) {
|
||||||
|
const baseline = resolveAdminBaseline(context);
|
||||||
|
if (typeof rank !== 'number' || !Number.isFinite(rank) || rank <= 0) return false;
|
||||||
|
return rank <= (baseline ?? DEFAULT_ADMIN_RANK_FALLBACK);
|
||||||
|
}
|
||||||
|
|
||||||
if (requirement.minGlobalRank !== undefined) {
|
if (requirement.minGlobalRank !== undefined) {
|
||||||
const rank = profile?.rank ?? null;
|
|
||||||
if (typeof rank !== 'number' || !Number.isFinite(rank) || rank <= 0) return false;
|
if (typeof rank !== 'number' || !Number.isFinite(rank) || rank <= 0) return false;
|
||||||
return rank <= requirement.minGlobalRank;
|
return rank <= requirement.minGlobalRank;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatToolRequirementSummary(requirement: ToolRequirement | null | undefined): string {
|
function resolveAdminBaseline(context?: RequirementContext): number | null {
|
||||||
|
const val = context?.adminRank;
|
||||||
|
if (typeof val === 'number' && Number.isFinite(val) && val > 0) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToolRequirementSummary(
|
||||||
|
requirement: ToolRequirement | null | undefined,
|
||||||
|
context?: RequirementContext
|
||||||
|
): string {
|
||||||
if (!requirement) return '';
|
if (!requirement) return '';
|
||||||
|
if (requirement.requiresBetterRankThanAdmin) {
|
||||||
|
const baseline = resolveAdminBaseline(context);
|
||||||
|
if (baseline) {
|
||||||
|
return `BeatLeader global rank ≤ ${baseline.toLocaleString()} (pleb's current rank)`;
|
||||||
|
}
|
||||||
|
return requirement.summary || `BeatLeader global rank ≤ ${DEFAULT_ADMIN_RANK_FALLBACK.toLocaleString()}`;
|
||||||
|
}
|
||||||
if (requirement.summary) return requirement.summary;
|
if (requirement.summary) return requirement.summary;
|
||||||
if (requirement.minGlobalRank !== undefined) {
|
if (requirement.minGlobalRank !== undefined) {
|
||||||
return `BeatLeader global rank ≤ ${requirement.minGlobalRank}`;
|
return `BeatLeader global rank ≤ ${requirement.minGlobalRank}`;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { RequestHandler } from '@sveltejs/kit';
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
import { getSession } from '../../../../lib/server/sessionStore';
|
import { getSession } from '../../../../lib/server/sessionStore';
|
||||||
|
import { PLEB_BEATLEADER_ID, DEFAULT_ADMIN_RANK_FALLBACK } from '../../../../lib/utils/plebsaber-utils';
|
||||||
|
|
||||||
const PLAYER_ENDPOINT = 'https://api.beatleader.com/player/';
|
const PLAYER_ENDPOINT = 'https://api.beatleader.com/player/';
|
||||||
|
|
||||||
@ -26,6 +27,10 @@ type ResponsePayload = {
|
|||||||
identity: BeatLeaderIdentity;
|
identity: BeatLeaderIdentity;
|
||||||
player: BeatLeaderPlayer | null;
|
player: BeatLeaderPlayer | null;
|
||||||
rawPlayer: Record<string, unknown> | null;
|
rawPlayer: Record<string, unknown> | null;
|
||||||
|
adminBaseline: {
|
||||||
|
rank: number | null;
|
||||||
|
fallback: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ cookies, fetch }) => {
|
export const GET: RequestHandler = async ({ cookies, fetch }) => {
|
||||||
@ -45,6 +50,8 @@ export const GET: RequestHandler = async ({ cookies, fetch }) => {
|
|||||||
let player: BeatLeaderPlayer | null = null;
|
let player: BeatLeaderPlayer | null = null;
|
||||||
let rawPlayer: Record<string, unknown> | null = null;
|
let rawPlayer: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
let adminRank: number | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`);
|
const res = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -70,12 +77,38 @@ export const GET: RequestHandler = async ({ cookies, fetch }) => {
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
|
if (session.beatleaderId === PLEB_BEATLEADER_ID) {
|
||||||
|
adminRank = typeof rawPlayer.rank === 'number' ? (rawPlayer.rank as number) : adminRank;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to refresh BeatLeader public profile', err);
|
console.error('Failed to refresh BeatLeader public profile', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: ResponsePayload = { identity, player, rawPlayer };
|
if (adminRank === null) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(PLEB_BEATLEADER_ID)}?stats=true`);
|
||||||
|
if (res.ok) {
|
||||||
|
const admin = (await res.json()) as Record<string, unknown>;
|
||||||
|
const rankValue = admin?.rank;
|
||||||
|
if (typeof rankValue === 'number') {
|
||||||
|
adminRank = rankValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to refresh BeatLeader admin profile', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: ResponsePayload = {
|
||||||
|
identity,
|
||||||
|
player,
|
||||||
|
rawPlayer,
|
||||||
|
adminBaseline: {
|
||||||
|
rank: adminRank,
|
||||||
|
fallback: DEFAULT_ADMIN_RANK_FALLBACK
|
||||||
|
}
|
||||||
|
};
|
||||||
return new Response(JSON.stringify(payload), {
|
return new Response(JSON.stringify(payload), {
|
||||||
headers: { 'content-type': 'application/json' }
|
headers: { 'content-type': 'application/json' }
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { getSession } from '../../lib/server/sessionStore';
|
import { getSession } from '../../lib/server/sessionStore';
|
||||||
|
import { PLEB_BEATLEADER_ID } from '../../lib/utils/plebsaber-utils';
|
||||||
import type { BeatLeaderPlayerProfile } from '../../lib/utils/plebsaber-utils';
|
import type { BeatLeaderPlayerProfile } from '../../lib/utils/plebsaber-utils';
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ export const load: LayoutServerLoad = async ({ cookies, fetch, url }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let player: BeatLeaderPlayerProfile | null = null;
|
let player: BeatLeaderPlayerProfile | null = null;
|
||||||
|
let adminRank: number | null = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`);
|
const res = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -27,11 +29,29 @@ export const load: LayoutServerLoad = async ({ cookies, fetch, url }) => {
|
|||||||
rank: typeof data.rank === 'number' ? (data.rank as number) : null,
|
rank: typeof data.rank === 'number' ? (data.rank as number) : null,
|
||||||
countryRank: typeof data.countryRank === 'number' ? (data.countryRank 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch BeatLeader profile for tools layout', err);
|
console.error('Failed to fetch BeatLeader profile for tools layout', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { hasBeatLeaderOAuth: true, player };
|
if (adminRank === null) {
|
||||||
|
try {
|
||||||
|
const adminRes = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(PLEB_BEATLEADER_ID)}?stats=true`);
|
||||||
|
if (adminRes.ok) {
|
||||||
|
const adminData = (await adminRes.json()) as Record<string, unknown>;
|
||||||
|
const rankValue = adminData?.rank;
|
||||||
|
if (typeof rankValue === 'number') {
|
||||||
|
adminRank = rankValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch BeatLeader admin baseline', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasBeatLeaderOAuth: true, player, adminRank };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -27,11 +27,12 @@
|
|||||||
leaderboardId?: string;
|
leaderboardId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export let data: { player: BeatLeaderPlayerProfile | null };
|
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null };
|
||||||
|
|
||||||
const requirement = TOOL_REQUIREMENTS['beatleader-compare'];
|
const requirement = TOOL_REQUIREMENTS['beatleader-compare'];
|
||||||
|
|
||||||
$: playerProfile = data?.player ?? null;
|
$: playerProfile = data?.player ?? null;
|
||||||
|
$: adminRank = data?.adminRank ?? null;
|
||||||
|
|
||||||
let playerA = '';
|
let playerA = '';
|
||||||
let playerB = '';
|
let playerB = '';
|
||||||
@ -198,7 +199,7 @@
|
|||||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Compare Players</h1>
|
<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>
|
<p class="mt-2 text-muted">Maps Player A has played that Player B hasn't — configurable lookback.</p>
|
||||||
|
|
||||||
<HasToolAccess player={playerProfile} requirement={requirement}>
|
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank}>
|
||||||
<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="extra-buttons">
|
<svelte:fragment slot="extra-buttons">
|
||||||
{#if results.length > 0}
|
{#if results.length > 0}
|
||||||
|
|||||||
@ -39,11 +39,12 @@
|
|||||||
leaderboardId?: string;
|
leaderboardId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export let data: { player: BeatLeaderPlayerProfile | null };
|
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null };
|
||||||
|
|
||||||
const requirement = TOOL_REQUIREMENTS['beatleader-headtohead'];
|
const requirement = TOOL_REQUIREMENTS['beatleader-headtohead'];
|
||||||
|
|
||||||
$: playerProfile = data?.player ?? null;
|
$: playerProfile = data?.player ?? null;
|
||||||
|
$: adminRank = data?.adminRank ?? null;
|
||||||
|
|
||||||
let playerA = '';
|
let playerA = '';
|
||||||
let playerB = '';
|
let playerB = '';
|
||||||
@ -293,7 +294,7 @@
|
|||||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Head-to-Head</h1>
|
<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>
|
<p class="mt-2 text-muted">Paginated head-to-head results on the same map and difficulty.</p>
|
||||||
|
|
||||||
<HasToolAccess player={playerProfile} requirement={requirement}>
|
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank}>
|
||||||
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={items.length > 0} oncompare={onCompare} />
|
<PlayerCompareForm bind:playerA bind:playerB {loading} hasResults={items.length > 0} oncompare={onCompare} />
|
||||||
|
|
||||||
{#if errorMsg}
|
{#if errorMsg}
|
||||||
|
|||||||
@ -50,11 +50,12 @@
|
|||||||
mapper?: string;
|
mapper?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export let data: { player: BeatLeaderPlayerProfile | null };
|
export let data: { player: BeatLeaderPlayerProfile | null; adminRank: number | null };
|
||||||
|
|
||||||
const requirement = TOOL_REQUIREMENTS['beatleader-playlist-gap'];
|
const requirement = TOOL_REQUIREMENTS['beatleader-playlist-gap'];
|
||||||
|
|
||||||
$: playerProfile = data?.player ?? null;
|
$: playerProfile = data?.player ?? null;
|
||||||
|
$: adminRank = data?.adminRank ?? null;
|
||||||
|
|
||||||
let playerId = '';
|
let playerId = '';
|
||||||
let selectedFileName: string | null = null;
|
let selectedFileName: string | null = null;
|
||||||
@ -355,7 +356,7 @@
|
|||||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Playlist Gap</h1>
|
<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>
|
<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}>
|
<HasToolAccess player={playerProfile} requirement={requirement} {adminRank}>
|
||||||
<form class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 items-end" on:submit|preventDefault={onAnalyze}>
|
<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">
|
<div class="sm:col-span-2 lg:col-span-2">
|
||||||
<label class="block text-sm text-muted">Playlist file (.bplist)
|
<label class="block text-sm text-muted">Playlist file (.bplist)
|
||||||
|
|||||||
46
src/routes/tools/stats/+page.server.ts
Normal file
46
src/routes/tools/stats/+page.server.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { getAllSessions, getSession } from '$lib/server/sessionStore';
|
||||||
|
import { PLEB_BEATLEADER_ID } from '$lib/utils/plebsaber-utils';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
type StatsUser = {
|
||||||
|
beatleaderId: string;
|
||||||
|
name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
lastSeenAt: number;
|
||||||
|
lastSeenIso: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
|
const currentSession = getSession(cookies);
|
||||||
|
if (!currentSession) {
|
||||||
|
throw redirect(302, '/auth/beatleader/login?redirect_uri=%2Ftools%2Fstats');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSession.beatleaderId !== PLEB_BEATLEADER_ID) {
|
||||||
|
throw error(403, 'Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = getAllSessions();
|
||||||
|
const aggregated = new Map<string, StatsUser>();
|
||||||
|
|
||||||
|
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()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = Array.from(aggregated.values()).sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
195
src/routes/tools/stats/+page.svelte
Normal file
195
src/routes/tools/stats/+page.svelte
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import beatleaderLogo from '$lib/assets/beatleader-logo.png';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
||||||
|
const ranges: Array<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [
|
||||||
|
{ unit: 'year', ms: 1000 * 60 * 60 * 24 * 365 },
|
||||||
|
{ unit: 'month', ms: 1000 * 60 * 60 * 24 * 30 },
|
||||||
|
{ unit: 'week', ms: 1000 * 60 * 60 * 24 * 7 },
|
||||||
|
{ unit: 'day', ms: 1000 * 60 * 60 * 24 },
|
||||||
|
{ unit: 'hour', ms: 1000 * 60 * 60 },
|
||||||
|
{ unit: 'minute', ms: 1000 * 60 },
|
||||||
|
{ unit: 'second', ms: 1000 }
|
||||||
|
];
|
||||||
|
|
||||||
|
let now = Date.now();
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const formatTimeSince = (timestamp: number): string => {
|
||||||
|
const diff = timestamp - now;
|
||||||
|
for (const { unit, ms } of ranges) {
|
||||||
|
const value = diff / ms;
|
||||||
|
if (Math.abs(value) >= 1 || unit === 'second') {
|
||||||
|
return formatter.format(Math.round(value), unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatter.format(0, 'second');
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
now = Date.now();
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<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-meta">Total users: {data.users.length}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if data.users.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No BeatLeader sessions have been recorded yet.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="user-grid" aria-live="polite">
|
||||||
|
{#each data.users as user}
|
||||||
|
<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="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)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-wrapper {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: clamp(1.75rem, 3vw, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
color: rgba(226, 232, 240, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-meta {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(226, 232, 240, 0.55);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 3rem;
|
||||||
|
border: 1px dashed rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(148, 163, 184, 0.85);
|
||||||
|
background: rgba(15, 23, 42, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.1rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card:hover,
|
||||||
|
.user-card:focus-within {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(34, 211, 238, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
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 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(34, 211, 238, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.user-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user