From f59db0021d5b41f0f44a1252743f55a34be87f39 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 29 Oct 2025 15:01:17 -0700 Subject: [PATCH] Rankwall and paywall the tools --- src/lib/assets/beatsaver-logo_16px.png | Bin 0 -> 383 bytes src/lib/assets/beatsaver-logo_32px.png | Bin 0 -> 534 bytes src/lib/assets/beatsaver-logo_512px.png | Bin 0 -> 4773 bytes src/lib/components/HasToolAccess.svelte | 48 ++ src/lib/components/NavBar.svelte | 216 +++++++-- src/lib/server/sessionStore.ts | 125 +++++ src/lib/utils/plebsaber-utils.ts | 55 +++ src/routes/api/beatleader/me/+server.ts | 102 ++-- .../auth/beatleader/callback/+server.ts | 55 ++- .../auth/beatleader/logout-all/+server.ts | 2 + src/routes/auth/beatleader/logout/+server.ts | 3 +- src/routes/testing/+page.svelte | 242 ++++++++++ src/routes/tools/+layout.server.ts | 31 +- .../tools/beatleader-compare/+page.svelte | 165 ++++--- .../tools/beatleader-headtohead/+page.svelte | 443 +++++++++--------- .../beatleader-playlist-gap/+page.svelte | 191 ++++---- 16 files changed, 1204 insertions(+), 474 deletions(-) create mode 100644 src/lib/assets/beatsaver-logo_16px.png create mode 100644 src/lib/assets/beatsaver-logo_32px.png create mode 100644 src/lib/assets/beatsaver-logo_512px.png create mode 100644 src/lib/components/HasToolAccess.svelte create mode 100644 src/lib/server/sessionStore.ts create mode 100644 src/routes/testing/+page.svelte diff --git a/src/lib/assets/beatsaver-logo_16px.png b/src/lib/assets/beatsaver-logo_16px.png new file mode 100644 index 0000000000000000000000000000000000000000..64d33c1f387d05bf7d8ee8536570aa811f5417ce GIT binary patch literal 383 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4rT@h1`S>QUNSs54@ z7>k44ofy`glX=O&z+e;L6XLp?A^ktYgue{^|Cy)WW~koDkhX^*!zZ)wIYZ+!hQ|ND z|Na-5^WS&#|Ne9TC!YUbb@cz{d;d4y{r~9G|A!wtJ(ZU+FfeeH1o;K4{{R2~fTlLX z!F|P7CQCCgFl2bTIEF}E4(*TRJD|Y9I=}1M+O4PmzK`PWS}ET$ljULJWATork_l34 zeIpiaHZ)7(dX~AshDWU1G2)KzCxxd$(Hoa6npbWYBW~BZv*_Mkb0a%le&do{*){E3 zog@79o6~oE;XnRyZ;5_qJ1g&uxo`X#7#PBhddb|v%EI20MVN&ZTpCOcr!XsT4pBILf|q z*!*17o-59<$Yow>?6fIM?!WVmHP;M%<0sx8eDrBWqIK8~IkA1eU0>ui?u>mW3-JG^aubZo?xOY^ud}+S3EOdjs z;-o8j2_-gh^8$AreX=`zg<`>S2HW;L@ejxD=-I89&-#V!`(lRNeLLn`n>>BqH|x{u zWmDu?-)3*Di@PPcxhddb|v%EI20MVN&ZTpCOcr!XsT4pBIL pU}oXkrG28QYYpAc7{%)(j*rve76QU;q22G1G>$2tb58U}}E2G?E&p9u`U zr3^Oz87BPy|NkFD|8IugZwy@@7}_2))URQPd&SV)z~Fp{A$u!B@*Rfis|@Am8A>KG z_|IYp*~5@=lA)lB!D}%?5az=-!AYYErzL7X{2WVPFvS^>lFzskrrK?#;4CyTn}i1(*~yFENF(I2Abm z_^)p>?M}kwC5M^Tn0_rdzV`Esz0a>)+P2~U@BX7n8zXdfno8WcVJh)qdw}*|-~YQV z?|S>~&8vH5R^hd~9G3n1>$m#)y|}%3&tJ9K_c?vty?0`4_eG`HlmETL*Sqpp#QoDg zyiapq?E0Htw-ZdfTrRx*^3(Wd^uOD2L4S|F&M~Vte|>Y!_mZ<|%LFH>y|~CUZ(ZH# z1N#lWp6}itS;;NI`^AdI>Hjs~<>?RV=c+E9@1yqlXHCqSC-R@4>{rd5VA6P#J8%7u z=AUi<*DkG}cJ2PO|7#;Mg>Udx%%9oI!1-g_I^jR@@x4FPw|%+%a{sGYe!lWcd=qpp zC~;Oy`Df+%BU<9O^pE3_KmX^2EoENA>h@Mo_QBb2_cwhy|6=~EOZKX-mqt8`_g}{E zBYZ>7K=*<&M@8^|p8r2>CjR&P`B~0D$-`zsiNiMDFYA6jt~^w~>9_hP|Ci=l>rGxS zjQBRce;GFyw~FqDhzAV*Km0$+r+-=gwSL9AB6gqS$(wl}Fx3Cp{&aiw{*`q>@2fYr z-Q4v5#4d&H4wsnHqz^F0efa+;r|kcofBc!uJm#=lN6f zFV4U6Coem-Hla!(yRq;Vlf?n{f5$Jcy!n^^y8U~GY{t#JD!UtU8^UESX-I>wMY&zhARtZhaTYk{o9-4VVyqw`rwl%}Qt+EXNZ}BzM=dvHzzm54p{B6b$ z>)$f`xL!sL>HeYv$(JD({GU~HV8={^Mf=6rZOpDBEVG`$ZgPV0!}?mWhWfWM4F7Lg zV!Ewbj^Y2U&kR4VpJV*6el8ZrK4<)}-j<äk8&-i~A8IG8Ji81dP#2x>SSw48W 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 TC)2463=9mOu6{1-oD!M + import { + formatToolRequirementSummary, + meetsToolRequirement, + type BeatLeaderPlayerProfile, + type ToolRequirement + } from '$lib/utils/plebsaber-utils'; + + const BL_PATREON_URL = 'https://www.patreon.com/BeatLeader'; + + export let player: BeatLeaderPlayerProfile | null = null; + export let requirement: ToolRequirement | null = null; + export let customLockedMessage: string | null = null; + export let showCurrentRank = true; + + $: hasAccess = meetsToolRequirement(player, requirement); + $: summary = formatToolRequirementSummary(requirement); + $: lockedMessage = customLockedMessage ?? requirement?.lockedMessage ?? null; + $: showLockedMessage = lockedMessage && lockedMessage !== summary ? lockedMessage : null; + $: playerRank = typeof player?.rank === 'number' ? player?.rank ?? null : null; + $: playerRankDisplay = playerRank !== null ? `#${playerRank.toLocaleString()}` : null; + + +{#if hasAccess} + +{:else} +
+

+ Tools are restricted to BeatLeader supporters (and the top 3k ranked players). +

+ {#if summary} +

{summary}

+ {/if} + {#if showLockedMessage} +

{showLockedMessage}

+ {/if} + {#if showCurrentRank} +

+ {#if playerRankDisplay} + Current global rank: {playerRankDisplay} + {:else} + We couldn't determine your current BeatLeader rank. Refresh after your profile updates. + {/if} +

+ {/if} +
+{/if} + diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte index 4cc3f86..8efbfb1 100644 --- a/src/lib/components/NavBar.svelte +++ b/src/lib/components/NavBar.svelte @@ -1,5 +1,6 @@
@@ -104,21 +122,45 @@ {#if checkingSession} Connecting… {:else if user} - - BeatLeader avatar - {user.name ?? 'BeatLeader user'} - +
+ + {#if menuOpen} + + {/if} +
{:else} BeatLeader @@ -142,21 +184,24 @@ {#if checkingSession} Connecting… {:else if user} - - BeatLeader avatar - {user.name ?? 'BeatLeader user'} - +
+ {#if typeof user.rank === 'number' && user.rank > 3000} + + {/if} + {#if dev} + Testing + {/if} + + Profile + BeatLeader + +
+ +
+
{:else} BeatLeader @@ -168,4 +213,113 @@ {/if}
+ + diff --git a/src/lib/server/sessionStore.ts b/src/lib/server/sessionStore.ts new file mode 100644 index 0000000..24e7627 --- /dev/null +++ b/src/lib/server/sessionStore.ts @@ -0,0 +1,125 @@ +import type { Cookies } from '@sveltejs/kit'; +import { dev } from '$app/environment'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const SESSION_COOKIE = 'plebsaber_session'; +const DATA_DIR = '.data'; +const SESSION_FILE = 'plebsaber_sessions.json'; + +export type StoredSession = { + sessionId: string; + beatleaderId: string; + name: string | null; + avatar: string | null; + createdAt: number; + lastSeenAt: number; +}; + +function ensureDataDir(): void { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } +} + +function sessionFilePath(): string { + return path.join(process.cwd(), DATA_DIR, SESSION_FILE); +} + +function readSessions(): Record { + try { + const file = sessionFilePath(); + if (!fs.existsSync(file)) return {}; + const raw = fs.readFileSync(file, 'utf-8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + return parsed as Record; + } + } catch (err) { + console.error('Failed to read session store', err); + } + return {}; +} + +function writeSessions(sessions: Record): void { + try { + ensureDataDir(); + fs.writeFileSync(sessionFilePath(), JSON.stringify(sessions, null, 2), 'utf-8'); + } catch (err) { + console.error('Failed to persist session store', err); + } +} + +function baseCookieOptions() { + return { + path: '/', + httpOnly: true as const, + sameSite: 'lax' as const, + secure: !dev + }; +} + +export function upsertSession( + cookies: Cookies, + input: { beatleaderId: string; name: string | null; avatar: string | null } +): StoredSession { + const sessions = readSessions(); + const existingSessionId = cookies.get(SESSION_COOKIE); + const now = Date.now(); + + if (existingSessionId && sessions[existingSessionId]?.beatleaderId === input.beatleaderId) { + const current = sessions[existingSessionId]; + const updated: StoredSession = { + ...current, + name: input.name, + avatar: input.avatar, + lastSeenAt: now + }; + sessions[existingSessionId] = updated; + writeSessions(sessions); + cookies.set(SESSION_COOKIE, existingSessionId, { ...baseCookieOptions(), maxAge: 10 * 365 * 24 * 3600 }); + return updated; + } + + const sessionId = crypto.randomUUID(); + const session: StoredSession = { + sessionId, + beatleaderId: input.beatleaderId, + name: input.name, + avatar: input.avatar, + createdAt: now, + lastSeenAt: now + }; + + sessions[sessionId] = session; + writeSessions(sessions); + + cookies.set(SESSION_COOKIE, sessionId, { ...baseCookieOptions(), maxAge: 10 * 365 * 24 * 3600 }); + return session; +} + +export function getSession(cookies: Cookies): StoredSession | null { + const sessionId = cookies.get(SESSION_COOKIE); + if (!sessionId) return null; + const sessions = readSessions(); + const session = sessions[sessionId]; + if (!session) return null; + + session.lastSeenAt = Date.now(); + sessions[sessionId] = session; + writeSessions(sessions); + return session; +} + +export function clearSession(cookies: Cookies): void { + const sessionId = cookies.get(SESSION_COOKIE); + if (!sessionId) return; + + const sessions = readSessions(); + if (sessions[sessionId]) { + delete sessions[sessionId]; + writeSessions(sessions); + } + + cookies.delete(SESSION_COOKIE, baseCookieOptions()); +} diff --git a/src/lib/utils/plebsaber-utils.ts b/src/lib/utils/plebsaber-utils.ts index aed958a..527234a 100644 --- a/src/lib/utils/plebsaber-utils.ts +++ b/src/lib/utils/plebsaber-utils.ts @@ -39,6 +39,21 @@ export type BeatLeaderScoresResponse = { metadata?: { page?: number; itemsPerPage?: number; total?: number }; }; +export type BeatLeaderPlayerProfile = { + id?: string; + name?: string; + avatar?: string | null; + country?: string | null; + rank?: number | null; + countryRank?: number | null; +}; + +export type ToolRequirement = { + minGlobalRank?: number; + summary: string; + lockedMessage?: string; +}; + export type Difficulty = { name: string; characteristic: string; @@ -54,6 +69,46 @@ export const DIFFICULTIES = ['Easy', 'Normal', 'Hard', 'Expert', 'ExpertPlus'] a export const MODES = ['Standard', 'Lawless', 'OneSaber', 'NoArrows', 'Lightshow'] as const; +const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = { + minGlobalRank: 3000, + summary: 'BeatLeader global rank within the top 3000', + lockedMessage: 'You must be a BL Patreon supporter or remain ranked in the global top 3k to use this tool.' +}; + +export const TOOL_REQUIREMENTS = { + 'beatleader-compare': DEFAULT_PRIVATE_TOOL_REQUIREMENT, + 'beatleader-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT, + 'beatleader-playlist-gap': DEFAULT_PRIVATE_TOOL_REQUIREMENT +} as const satisfies Record; + +export type ToolKey = keyof typeof TOOL_REQUIREMENTS; + +export function getToolRequirement(key: string): ToolRequirement | null { + return TOOL_REQUIREMENTS[key as ToolKey] ?? null; +} + +export function meetsToolRequirement( + profile: BeatLeaderPlayerProfile | null | undefined, + requirement: ToolRequirement | null | undefined +): boolean { + if (!requirement) return true; + if (requirement.minGlobalRank !== undefined) { + const rank = profile?.rank ?? null; + if (typeof rank !== 'number' || !Number.isFinite(rank) || rank <= 0) return false; + return rank <= requirement.minGlobalRank; + } + return true; +} + +export function formatToolRequirementSummary(requirement: ToolRequirement | null | undefined): string { + if (!requirement) return ''; + if (requirement.summary) return requirement.summary; + if (requirement.minGlobalRank !== undefined) { + return `BeatLeader global rank ≤ ${requirement.minGlobalRank}`; + } + return ''; +} + // ============================================================================ // 3. BeatSaver & BeatLeader API Functions // ============================================================================ diff --git a/src/routes/api/beatleader/me/+server.ts b/src/routes/api/beatleader/me/+server.ts index dbb0585..f455f35 100644 --- a/src/routes/api/beatleader/me/+server.ts +++ b/src/routes/api/beatleader/me/+server.ts @@ -1,64 +1,82 @@ import type { RequestHandler } from '@sveltejs/kit'; -import { createBeatLeaderAPI } from '$lib/server/beatleader'; -import { clearTokens, getValidAccessToken } from '$lib/server/beatleaderAuth'; +import { getSession } from '../../../../lib/server/sessionStore'; -type BeatLeaderIdentity = { id?: string; name?: string }; -type BeatLeaderPlayer = { id?: string; name?: string; avatar?: string | null }; +const PLAYER_ENDPOINT = 'https://api.beatleader.com/player/'; -const IDENTITY_ENDPOINT = 'https://api.beatleader.com/oauth2/identity'; +type BeatLeaderIdentity = { id: string; name: string | null }; +type BeatLeaderPlayer = { + id: string; + name: string | null; + avatar: string | null; + country: string | null; + role: string | null; + rank: number | null; + countryRank: number | null; + techPp: number | null; + accPp: number | null; + passPp: number | null; + pp: number | null; + mapperId: number | null; + level: number | null; + banned: boolean; + profileSettings: { showAllRatings: boolean } | null; +}; + +type ResponsePayload = { + identity: BeatLeaderIdentity; + player: BeatLeaderPlayer | null; + rawPlayer: Record | null; +}; export const GET: RequestHandler = async ({ cookies, fetch }) => { - const token = await getValidAccessToken(cookies); - if (!token) { + const session = getSession(cookies); + if (!session) { return new Response( JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login' }), { status: 401, headers: { 'content-type': 'application/json' } } ); } - const identityRes = await fetch(IDENTITY_ENDPOINT, { - headers: { Authorization: `Bearer ${token}` } - }); - - if (identityRes.status === 401) { - clearTokens(cookies); - return new Response( - JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login' }), - { status: 401, headers: { 'content-type': 'application/json' } } - ); - } - - if (!identityRes.ok) { - return new Response( - JSON.stringify({ error: `Identity lookup failed: ${identityRes.status}` }), - { status: 502, headers: { 'content-type': 'application/json' } } - ); - } - - const identityJson = (await identityRes.json()) as BeatLeaderIdentity; const identity: BeatLeaderIdentity = { - id: typeof identityJson.id === 'string' ? identityJson.id : undefined, - name: typeof identityJson.name === 'string' ? identityJson.name : undefined + id: session.beatleaderId, + name: session.name }; - const playerId = identity.id ?? null; let player: BeatLeaderPlayer | null = null; - if (playerId) { - try { - const api = createBeatLeaderAPI(fetch, token, undefined); - const raw = (await api.getPlayer(playerId)) as Record; - const candidate: BeatLeaderPlayer = { - id: typeof raw?.id === 'string' ? (raw.id as string) : playerId, - name: typeof raw?.name === 'string' ? (raw.name as string) : identity.name, - avatar: typeof raw?.avatar === 'string' ? (raw.avatar as string) : null + let rawPlayer: Record | null = null; + + try { + const res = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`); + if (res.ok) { + rawPlayer = (await res.json()) as Record; + player = { + id: typeof rawPlayer.id === 'string' ? (rawPlayer.id as string) : session.beatleaderId, + name: typeof rawPlayer.name === 'string' ? (rawPlayer.name as string) : session.name, + avatar: typeof rawPlayer.avatar === 'string' ? (rawPlayer.avatar as string) : session.avatar, + country: typeof rawPlayer.country === 'string' ? (rawPlayer.country as string) : null, + role: typeof rawPlayer.role === 'string' ? (rawPlayer.role as string) : null, + rank: typeof rawPlayer.rank === 'number' ? (rawPlayer.rank as number) : null, + countryRank: typeof rawPlayer.countryRank === 'number' ? (rawPlayer.countryRank as number) : null, + techPp: typeof rawPlayer.techPp === 'number' ? (rawPlayer.techPp as number) : null, + accPp: typeof rawPlayer.accPp === 'number' ? (rawPlayer.accPp as number) : null, + passPp: typeof rawPlayer.passPp === 'number' ? (rawPlayer.passPp as number) : null, + pp: typeof rawPlayer.pp === 'number' ? (rawPlayer.pp as number) : null, + mapperId: typeof rawPlayer.mapperId === 'number' ? (rawPlayer.mapperId as number) : null, + level: typeof rawPlayer.level === 'number' ? (rawPlayer.level as number) : null, + banned: Boolean(rawPlayer.banned), + profileSettings: typeof rawPlayer.profileSettings === 'object' && rawPlayer.profileSettings !== null + ? { + showAllRatings: Boolean((rawPlayer.profileSettings as Record).showAllRatings) + } + : null }; - player = candidate; - } catch (err) { - console.error('Failed to fetch BeatLeader player profile', err); } + } catch (err) { + console.error('Failed to refresh BeatLeader public profile', err); } - return new Response(JSON.stringify({ identity, player }), { + const payload: ResponsePayload = { identity, player, rawPlayer }; + return new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } }); }; diff --git a/src/routes/auth/beatleader/callback/+server.ts b/src/routes/auth/beatleader/callback/+server.ts index 58600e0..7dd467c 100644 --- a/src/routes/auth/beatleader/callback/+server.ts +++ b/src/routes/auth/beatleader/callback/+server.ts @@ -1,7 +1,8 @@ import type { RequestHandler } from '@sveltejs/kit'; -import { consumeAndValidateState, exchangeCodeForTokens, setTokens, clearTokens, consumeRedirectCookie, getValidAccessToken } from '$lib/server/beatleaderAuth'; +import { consumeAndValidateState, exchangeCodeForTokens, clearTokens, consumeRedirectCookie } from '$lib/server/beatleaderAuth'; +import { upsertSession } from '../../../../lib/server/sessionStore'; -export const GET: RequestHandler = async ({ url, cookies }) => { +export const GET: RequestHandler = async ({ url, cookies, fetch }) => { const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); @@ -19,19 +20,47 @@ export const GET: RequestHandler = async ({ url, cookies }) => { return new Response('Token exchange failed', { status: 400 }); } - setTokens(cookies, tokenData); - - // Best-effort: enable ShowAllRatings so unranked star ratings are visible for supporters try { - const tok = await getValidAccessToken(cookies); - if (tok) { - const enableUrl = new URL('https://api.beatleader.com/user/profile'); - enableUrl.searchParams.set('showAllRatings', 'true'); - await fetch(enableUrl.toString(), { method: 'PATCH', headers: { Authorization: `Bearer ${tok}` } }); - } - } catch {} + const identityRes = await fetch('https://api.beatleader.com/oauth2/identity', { + headers: { Authorization: `Bearer ${tokenData.access_token}` } + }); + + if (!identityRes.ok) { + clearTokens(cookies); + return new Response('Failed to retrieve identity', { status: 502 }); + } + + const identity = (await identityRes.json()) as { id?: string; name?: string }; + if (!identity?.id) { + clearTokens(cookies); + return new Response('BeatLeader identity missing id', { status: 502 }); + } + + let avatar: string | undefined; + try { + const profileRes = await fetch(`https://api.beatleader.com/player/${identity.id}`); + if (profileRes.ok) { + const profileJson = (await profileRes.json()) as { avatar?: string }; + if (typeof profileJson.avatar === 'string') { + avatar = profileJson.avatar; + } + } + } catch (err) { + console.error('Failed to prefetch BeatLeader avatar', err); + } + + upsertSession(cookies, { + beatleaderId: identity.id, + name: identity.name ?? null, + avatar: avatar ?? null + }); + clearTokens(cookies); + } catch (err) { + console.error('BeatLeader OAuth callback failed', err); + clearTokens(cookies); + return new Response('Internal error establishing session', { status: 500 }); + } - // Redirect back to original target stored before login const redirectTo = consumeRedirectCookie(cookies) ?? url.searchParams.get('redirect_uri') ?? '/'; return new Response(null, { status: 302, headers: { Location: redirectTo } }); }; diff --git a/src/routes/auth/beatleader/logout-all/+server.ts b/src/routes/auth/beatleader/logout-all/+server.ts index c0e0850..cf70a67 100644 --- a/src/routes/auth/beatleader/logout-all/+server.ts +++ b/src/routes/auth/beatleader/logout-all/+server.ts @@ -1,9 +1,11 @@ import type { RequestHandler } from '@sveltejs/kit'; import { clearTokens, clearBeatLeaderSession } from '$lib/server/beatleaderAuth'; +import { clearSession } from '../../../../lib/server/sessionStore'; export const POST: RequestHandler = async ({ url, cookies }) => { clearTokens(cookies); clearBeatLeaderSession(cookies); + clearSession(cookies); const redirectTo = url.searchParams.get('redirect_uri') ?? '/'; return new Response(null, { status: 302, headers: { Location: redirectTo } }); }; diff --git a/src/routes/auth/beatleader/logout/+server.ts b/src/routes/auth/beatleader/logout/+server.ts index ce8300f..81069f1 100644 --- a/src/routes/auth/beatleader/logout/+server.ts +++ b/src/routes/auth/beatleader/logout/+server.ts @@ -1,14 +1,15 @@ import type { RequestHandler } from '@sveltejs/kit'; import { clearTokens } from '$lib/server/beatleaderAuth'; +import { clearSession } from '../../../../lib/server/sessionStore'; export const GET: RequestHandler = async ({ url }) => { - // For prerendering, redirect to home page const redirectTo = url.searchParams.get('redirect_uri') ?? '/'; return new Response(null, { status: 302, headers: { Location: redirectTo } }); }; export const POST: RequestHandler = async ({ url, cookies }) => { clearTokens(cookies); + clearSession(cookies); const redirectTo = url.searchParams.get('redirect_uri') ?? '/'; return new Response(null, { status: 302, headers: { Location: redirectTo } }); }; diff --git a/src/routes/testing/+page.svelte b/src/routes/testing/+page.svelte new file mode 100644 index 0000000..e4d4eec --- /dev/null +++ b/src/routes/testing/+page.svelte @@ -0,0 +1,242 @@ + + +
+

BeatLeader Testing

+

Debug view for the current BeatLeader OAuth session.

+ + {#if loading} +
Loading player info…
+ {:else if error} +
+ {error} +
+ {:else} +
+
+

Identity

+ {#if identity} +
+
+
ID
+
{identity.id ?? '—'}
+
+
+
Name
+
{identity.name ?? '—'}
+
+
+ {:else} +

No identity data returned.

+ {/if} +
+ +
+

Player

+ {#if player} +
+ Avatar +
+
{player.name ?? 'Unknown'}
+
+ {#if player.country} + {player.country} + {#if player.countryRank !== null} + Rank: {player.countryRank} + {/if} + {/if} + {#if player.rank !== null} + • Global Rank: {player.rank} + {/if} +
+
+
+
+
+
ID
+
{player.id ?? '—'}
+
+
+
Role
+
{player.role ?? '—'}
+
+
+
+
Level
+
{player.level ?? '—'}
+
+
+
PP (Global)
+
{player.pp ?? '—'}
+
+
+
Tech PP
+
{player.techPp ?? '—'}
+
+
+
Acc PP
+
{player.accPp ?? '—'}
+
+
+
Pass PP
+
{player.passPp ?? '—'}
+
+
+
Banned
+
{player.banned ? 'Yes' : 'No'}
+
+
+
Show All Ratings
+
{player.profileSettings?.showAllRatings ? 'Enabled' : 'Disabled'}
+
+
+ {:else} +

No player profile found for this identity.

+ {/if} +
+
+ {/if} +
+ + + diff --git a/src/routes/tools/+layout.server.ts b/src/routes/tools/+layout.server.ts index 2f0550d..38791e2 100644 --- a/src/routes/tools/+layout.server.ts +++ b/src/routes/tools/+layout.server.ts @@ -1,16 +1,37 @@ import { redirect } from '@sveltejs/kit'; -import { getValidAccessToken } from '$lib/server/beatleaderAuth'; +import { getSession } from '../../lib/server/sessionStore'; +import type { BeatLeaderPlayerProfile } from '../../lib/utils/plebsaber-utils'; import type { LayoutServerLoad } from './$types'; +const PLAYER_ENDPOINT = 'https://api.beatleader.com/player/'; + export const prerender = false; -export const load: LayoutServerLoad = async ({ cookies, url }) => { - const token = await getValidAccessToken(cookies); - if (!token) { +export const load: LayoutServerLoad = async ({ cookies, fetch, url }) => { + const session = getSession(cookies); + if (!session) { const pathWithQuery = `${url.pathname}${url.search}` || '/tools'; throw redirect(302, `/auth/beatleader/login?redirect_uri=${encodeURIComponent(pathWithQuery)}`); } - return { hasBeatLeaderOAuth: true }; + let player: BeatLeaderPlayerProfile | null = null; + try { + const res = await fetch(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`); + if (res.ok) { + const data = (await res.json()) as Record; + 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 + }; + } + } catch (err) { + console.error('Failed to fetch BeatLeader profile for tools layout', err); + } + + return { hasBeatLeaderOAuth: true, player }; }; diff --git a/src/routes/tools/beatleader-compare/+page.svelte b/src/routes/tools/beatleader-compare/+page.svelte index f110a4c..bd82aec 100644 --- a/src/routes/tools/beatleader-compare/+page.svelte +++ b/src/routes/tools/beatleader-compare/+page.svelte @@ -1,12 +1,14 @@