add ui feedback on clicking bsr

This commit is contained in:
Brian Lee 2025-09-28 23:15:05 -07:00
parent 3460bfe401
commit 2f3f2d94ef
3 changed files with 356 additions and 85 deletions

View File

@ -0,0 +1,201 @@
import type { RequestHandler } from '@sveltejs/kit';
import { createBeatLeaderAPI } from '$lib/server/beatleader';
import { getBeatLeaderSessionCookieHeader, getValidAccessToken } from '$lib/server/beatleaderAuth';
import { getSteamIdFromCookies } from '$lib/server/steam';
import { promises as fsp } from 'fs';
import path from 'path';
type BeatLeaderScore = {
timeset?: string | number;
leaderboard?: {
id?: string | number | null;
leaderboardId?: string | number | null;
song?: { hash?: string | null };
difficulty?: { value?: number | string | null; modeName?: string | null };
};
};
function normalizeDifficultyName(value: number | string | null | undefined): string {
if (value === null || value === undefined) return 'ExpertPlus';
if (typeof value === 'string') {
const v = value.toLowerCase();
if (v.includes('expertplus') || v === 'expertplus' || v === 'ex+' || v.includes('ex+')) return 'ExpertPlus';
if (v.includes('expert')) return 'Expert';
if (v.includes('hard')) return 'Hard';
if (v.includes('normal')) return 'Normal';
if (v.includes('easy')) return 'Easy';
return value;
}
switch (value) {
case 1:
return 'Easy';
case 3:
return 'Normal';
case 5:
return 'Hard';
case 7:
return 'Expert';
case 9:
return 'ExpertPlus';
default:
return 'ExpertPlus';
}
}
function parseTimeset(ts: string | number | undefined): number {
if (ts === undefined) return 0;
if (typeof ts === 'number') return ts;
const n = Number(ts);
return Number.isFinite(n) ? n : 0;
}
const DATA_DIR = path.join(process.cwd(), '.data', 'beatleader-cache', 'player-scores');
async function ensureDir(dir: string): Promise<void> {
await fsp.mkdir(dir, { recursive: true });
}
async function readJson<T>(filePath: string): Promise<T | null> {
try {
const buf = await fsp.readFile(filePath, 'utf8');
return JSON.parse(buf) as T;
} catch {
return null;
}
}
async function writeJson(filePath: string, data: unknown): Promise<void> {
const dir = path.dirname(filePath);
await ensureDir(dir);
const tmp = filePath + '.tmp';
await fsp.writeFile(tmp, JSON.stringify(data));
await fsp.rename(tmp, filePath);
}
function getCacheFilePath(playerId: string, diff: string): string {
const safeId = encodeURIComponent(playerId);
const safeDiff = encodeURIComponent(diff);
return path.join(DATA_DIR, `${safeId}__${safeDiff}.json`);
}
function scoreKey(score: BeatLeaderScore): string | null {
const lb: any = score?.leaderboard ?? {};
const lbId = lb?.id ?? lb?.leaderboardId;
if (lbId != null) return `lb:${String(lbId)}`;
const hash = lb?.song?.hash ? String(lb.song.hash).toLowerCase() : undefined;
const diff = normalizeDifficultyName(lb?.difficulty?.value ?? undefined);
const mode = lb?.difficulty?.modeName ?? 'Standard';
if (hash) return `hdm:${hash}|${diff}|${mode}`;
const ts = parseTimeset(score?.timeset);
return ts ? `ts:${ts}` : null;
}
export const GET: RequestHandler<{ id: string }> = async ({ fetch, params, url, cookies }) => {
type AuthMode = 'steam' | 'oauth' | 'session' | 'auto' | 'none';
const authMode = (url.searchParams.get('auth') as AuthMode | null) ?? 'steam';
let token: string | undefined;
let websiteCookieHeader: string | undefined;
if (authMode === 'oauth') {
const maybeToken = await getValidAccessToken(cookies);
token = maybeToken ?? undefined;
} else if (authMode === 'session') {
websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined;
} else if (authMode === 'steam') {
websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined;
if (!websiteCookieHeader) {
const steamId = getSteamIdFromCookies(cookies);
if (steamId) {
try {
const probe = await fetch('https://api.beatleader.com/user', { credentials: 'include' as RequestCredentials });
const setCookieHeaders = (probe.headers as any).getSetCookie?.() ?? probe.headers.get('set-cookie')?.split(',') ?? [];
const { setBeatLeaderSessionFromSetCookieHeaders } = await import('$lib/server/beatleaderAuth');
setBeatLeaderSessionFromSetCookieHeaders(cookies, Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]);
websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined;
} catch {}
}
}
} else if (authMode === 'auto') {
const maybeToken = await getValidAccessToken(cookies);
if (maybeToken) {
token = maybeToken;
} else {
websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined;
if (!websiteCookieHeader) {
const steamId = getSteamIdFromCookies(cookies);
if (steamId) {
try {
const probe = await fetch('https://api.beatleader.com/user', { credentials: 'include' as RequestCredentials });
const setCookieHeaders = (probe.headers as any).getSetCookie?.() ?? probe.headers.get('set-cookie')?.split(',') ?? [];
const { setBeatLeaderSessionFromSetCookieHeaders } = await import('$lib/server/beatleaderAuth');
setBeatLeaderSessionFromSetCookieHeaders(cookies, Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]);
websiteCookieHeader = getBeatLeaderSessionCookieHeader(cookies) ?? undefined;
} catch {}
}
}
}
}
const api = createBeatLeaderAPI(fetch, token, websiteCookieHeader);
const playerId = params.id as string;
const diff = (url.searchParams.get('diff') ?? 'ExpertPlus');
const cutoffEpochParam = url.searchParams.get('cutoffEpoch');
const cutoffEpoch = cutoffEpochParam ? Number(cutoffEpochParam) : undefined;
const pageSize = 100;
const maxPages = url.searchParams.get('maxPages') ? Number(url.searchParams.get('maxPages')) : 100;
try {
const filePath = getCacheFilePath(playerId, diff);
const existing = await readJson<{ scores: BeatLeaderScore[]; updatedAt: number }>(filePath);
const existingScores = Array.isArray(existing?.scores) ? existing!.scores : [];
const existingNewest = existingScores.length > 0 ? Math.max(...existingScores.map((s) => parseTimeset(s.timeset))) : 0;
// Fetch new pages until we reach an older-or-equal score than the newest cached
let page = 1;
const newScores: BeatLeaderScore[] = [];
while (page <= maxPages) {
const data = (await api.getPlayerScores(playerId, { page, count: pageSize, sortBy: 'date', order: 'desc' })) as any;
const batch: BeatLeaderScore[] = Array.isArray(data?.data) ? data.data : [];
if (batch.length === 0) break;
// Filter by requested diff immediately
const filtered = batch.filter((s) => normalizeDifficultyName(s?.leaderboard?.difficulty?.value ?? undefined) === diff);
newScores.push(...filtered);
const last = batch[batch.length - 1];
const lastTs = parseTimeset(last?.timeset);
if (existingNewest && lastTs <= existingNewest) break;
if (cutoffEpoch !== undefined && lastTs < cutoffEpoch) break;
page += 1;
}
// Merge new with existing, dedupe by scoreboard key
const mergedMap = new Map<string, BeatLeaderScore>();
for (const s of existingScores) {
const k = scoreKey(s);
if (k) mergedMap.set(k, s);
}
for (const s of newScores) {
const k = scoreKey(s);
if (k) mergedMap.set(k, s);
}
const merged = Array.from(mergedMap.values());
// Keep only requested diff in the stored file to reduce size
const stored = merged.filter((s) => normalizeDifficultyName(s?.leaderboard?.difficulty?.value ?? undefined) === diff);
// Persist
await writeJson(filePath, { scores: stored, updatedAt: Date.now() });
// Prepare response: filter by cutoffEpoch if provided and sort desc by timeset
const responseScores = stored
.filter((s) => (cutoffEpoch !== undefined ? parseTimeset(s.timeset) >= cutoffEpoch : true))
.sort((a, b) => parseTimeset(b.timeset) - parseTimeset(a.timeset));
return new Response(JSON.stringify({ data: responseScores }), { headers: { 'content-type': 'application/json' } });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'content-type': 'application/json' } });
}
};

View File

@ -1,6 +1,12 @@
import type { RequestHandler } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit';
import { clearTokens } from '$lib/server/beatleaderAuth'; import { clearTokens } from '$lib/server/beatleaderAuth';
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 }) => { export const POST: RequestHandler = async ({ url, cookies }) => {
clearTokens(cookies); clearTokens(cookies);
const redirectTo = url.searchParams.get('redirect_uri') ?? '/'; const redirectTo = url.searchParams.get('redirect_uri') ?? '/';

View File

@ -34,7 +34,6 @@
let playerA = ''; let playerA = '';
let playerB = ''; let playerB = '';
let songCount = 10;
let loading = false; let loading = false;
let errorMsg: string | null = null; let errorMsg: string | null = null;
let results: SongItem[] = []; let results: SongItem[] = [];
@ -47,6 +46,10 @@
let pageSize: number | string = 24; let pageSize: number | string = 24;
$: pageSizeNum = Number(pageSize) || 24; $: pageSizeNum = Number(pageSize) || 24;
// Configurable lookback windows (months)
let monthsA: number | string = 6; // default 6 months
let monthsB: number | string = 24; // default 24 months
// Derived lists // Derived lists
$: sortedResults = [...results].sort((a, b) => { $: sortedResults = [...results].sort((a, b) => {
let cmp = 0; let cmp = 0;
@ -82,6 +85,55 @@
let starsByKey: Record<string, StarInfo> = {}; let starsByKey: Record<string, StarInfo> = {};
let loadingStars = false; let loadingStars = false;
// Toast notification state
let toastMessage = '';
let showToast = false;
let toastTimeout: number | null = null;
// Button feedback state - track which buttons are currently "lit up"
let litButtons = new Set<string>();
function showToastMessage(message: string) {
// Clear any existing toast
if (toastTimeout) {
clearTimeout(toastTimeout);
}
toastMessage = message;
showToast = true;
// Auto-hide toast after 3 seconds
toastTimeout = setTimeout(() => {
showToast = false;
toastMessage = '';
}, 3000);
}
function lightUpButton(buttonId: string) {
litButtons.add(buttonId);
// Reassign here too to trigger reactivity immediately
litButtons = litButtons;
// Remove the lighting effect after 1 second
setTimeout(() => {
litButtons.delete(buttonId);
litButtons = litButtons; // Trigger reactivity
}, 1000);
}
async function copyBsrCommand(key: string, hash: string) {
try {
const bsrCommand = `!bsr ${key}`;
await navigator.clipboard.writeText(bsrCommand);
// Show success feedback with the actual command
showToastMessage(`Copied "${bsrCommand}" to clipboard`);
lightUpButton(`bsr-${hash}`);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
showToastMessage('Failed to copy to clipboard');
}
}
async function fetchBeatSaverMeta(hash: string): Promise<MapMeta | null> { async function fetchBeatSaverMeta(hash: string): Promise<MapMeta | null> {
try { try {
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`); const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`);
@ -147,6 +199,8 @@
loadingStars = false; loadingStars = false;
} }
function difficultyToColor(name: string | undefined): string { function difficultyToColor(name: string | undefined): string {
const n = (name ?? 'ExpertPlus').toLowerCase(); const n = (name ?? 'ExpertPlus').toLowerCase();
if (n === 'easy') return 'MediumSeaGreen'; if (n === 'easy') return 'MediumSeaGreen';
@ -195,69 +249,23 @@
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
} }
function getCutoffEpoch(): number { function getCutoffEpochFromMonths(months: number | string): number {
return Math.floor(Date.now() / 1000) - ONE_YEAR_SECONDS; const m = Number(months) || 0;
const seconds = Math.max(0, m) * 30 * 24 * 60 * 60; // approx 30 days per month
return Math.floor(Date.now() / 1000) - seconds;
} }
async function fetchAllRecentScores(playerId: string, cutoffEpoch: number): Promise<BeatLeaderScore[]> { async function fetchAllRecentScores(playerId: string, cutoffEpoch: number, maxPages = 15): Promise<BeatLeaderScore[]> {
const pageSize = 100; const qs = new URLSearchParams({ diff: 'ExpertPlus', cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
let page = 1; const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
const maxPages = 15; // safety cap
const all: BeatLeaderScore[] = [];
while (page <= maxPages) {
const url = `/api/beatleader/player/${encodeURIComponent(playerId)}?scores=1&count=${pageSize}&page=${page}&sortBy=date&order=desc`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`); if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`);
const data = (await res.json()) as BeatLeaderScoresResponse; const data = (await res.json()) as BeatLeaderScoresResponse;
const batch = data.data ?? []; return data.data ?? [];
if (batch.length === 0) break;
all.push(...batch);
const last = batch[batch.length - 1];
const lastTs = parseTimeset(last?.timeset);
if (lastTs < cutoffEpoch) break; // remaining pages will be older
page += 1;
} }
return all;
}
async function fetchAllScoresAnyTime(playerId: string, maxPages = 100): Promise<BeatLeaderScore[]> {
const pageSize = 100;
let page = 1;
const all: BeatLeaderScore[] = [];
while (page <= maxPages) {
const url = `/api/beatleader/player/${encodeURIComponent(playerId)}?scores=1&count=${pageSize}&page=${page}&sortBy=date&order=desc`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`);
const data = (await res.json()) as BeatLeaderScoresResponse;
const batch = data.data ?? [];
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
}
function loadHistory(): Record<string, string[]> {
try {
const raw = localStorage.getItem('bl_compare_history');
if (!raw) return {};
const obj = JSON.parse(raw);
if (obj && typeof obj === 'object') return obj as Record<string, string[]>;
} catch {}
return {};
}
function saveHistory(history: Record<string, string[]>): void {
try {
localStorage.setItem('bl_compare_history', JSON.stringify(history));
} catch {}
}
function incrementPlaylistCount(): number { function incrementPlaylistCount(): number {
try { try {
@ -316,24 +324,29 @@
loading = true; loading = true;
try { try {
const cutoff = getCutoffEpoch(); const cutoff = getCutoffEpochFromMonths(monthsA);
const cutoffB = getCutoffEpochFromMonths(monthsB);
const [aScores, bScores] = await Promise.all([ const [aScores, bScores] = await Promise.all([
fetchAllRecentScores(a, cutoff), fetchAllRecentScores(a, cutoff),
fetchAllScoresAnyTime(b, 100) fetchAllRecentScores(b, cutoffB, 100)
]); ]);
const bHashes = new Set<string>();
const bLeaderboardIds = new Set<string>(); const bLeaderboardIds = new Set<string>();
const bExpertPlusKeys = new Set<string>(); // `${hashLower}|ExpertPlus|${modeName}`
for (const s of bScores) { for (const s of bScores) {
const rawHash = s.leaderboard?.song?.hash ?? undefined; const rawHash = s.leaderboard?.song?.hash ?? undefined;
const hashLower = rawHash ? String(rawHash).toLowerCase() : undefined; const hashLower = rawHash ? String(rawHash).toLowerCase() : undefined;
const lbIdRaw = (s.leaderboard as any)?.id ?? (s.leaderboard as any)?.leaderboardId; const lbIdRaw = (s.leaderboard as any)?.id ?? (s.leaderboard as any)?.leaderboardId;
const lbId = lbIdRaw != null ? String(lbIdRaw) : undefined; const lbId = lbIdRaw != null ? String(lbIdRaw) : undefined;
if (hashLower) bHashes.add(hashLower); const bDiffValue = s.leaderboard?.difficulty?.value ?? undefined;
const bModeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
const bDiffName = normalizeDifficultyName(bDiffValue);
if (bDiffName !== 'ExpertPlus') continue; // ignore non-ExpertPlus for B
if (lbId) bLeaderboardIds.add(lbId); if (lbId) bLeaderboardIds.add(lbId);
if (hashLower) bExpertPlusKeys.add(`${hashLower}|ExpertPlus|${bModeName}`);
} }
const history = loadHistory();
const runSeen = new Set<string>(); // avoid duplicates within this run const runSeen = new Set<string>(); // avoid duplicates within this run
const candidates: SongItem[] = []; const candidates: SongItem[] = [];
@ -348,11 +361,11 @@
const leaderboardId = leaderboardIdRaw != null ? String(leaderboardIdRaw) : undefined; const leaderboardId = leaderboardIdRaw != null ? String(leaderboardIdRaw) : undefined;
if (!rawHash) continue; if (!rawHash) continue;
const hashLower = String(rawHash).toLowerCase(); const hashLower = String(rawHash).toLowerCase();
if (bHashes.has(hashLower) || (leaderboardId && bLeaderboardIds.has(leaderboardId))) continue; // B has played this song
const diffName = normalizeDifficultyName(diffValue); const diffName = normalizeDifficultyName(diffValue);
const historyDiffs = history[rawHash] ?? []; if (diffName !== 'ExpertPlus') continue; // Only compare ExpertPlus for A
if (historyDiffs.includes(diffName)) continue; // used previously // B has played ExpertPlus of same map+mode if matching leaderboard or key exists
if ((leaderboardId && bLeaderboardIds.has(leaderboardId)) || bExpertPlusKeys.has(`${hashLower}|${diffName}|${modeName}`)) continue;
const key = `${rawHash}|${diffName}|${modeName}`; const key = `${rawHash}|${diffName}|${modeName}`;
if (runSeen.has(key)) continue; if (runSeen.has(key)) continue;
@ -367,15 +380,9 @@
} }
candidates.sort((x, y) => y.timeset - x.timeset); candidates.sort((x, y) => y.timeset - x.timeset);
const limited = candidates.slice(0, Math.max(0, Math.min(200, Number(songCount) || 40))); const limited = candidates; // return all; pagination handled client-side
// update history for saved pairs
for (const s of limited) {
const diff = s.difficulties[0]?.name ?? 'ExpertPlus';
if (!history[s.hash]) history[s.hash] = [];
if (!history[s.hash].includes(diff)) history[s.hash].push(diff);
}
saveHistory(history);
results = limited; results = limited;
page = 1; page = 1;
@ -395,17 +402,12 @@
const sp = new URLSearchParams(location.search); const sp = new URLSearchParams(location.search);
playerA = sp.get('a') ?? ''; playerA = sp.get('a') ?? '';
playerB = sp.get('b') ?? ''; playerB = sp.get('b') ?? '';
const sc = sp.get('n');
if (sc) {
const n = Number(sc);
if (Number.isFinite(n) && n > 0) songCount = n;
}
}); });
</script> </script>
<section class="py-8"> <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">BeatLeader: Compare Players</h1>
<p class="mt-2 text-muted">Maps Player A has played that Player B hasn't — last 12 months.</p> <p class="mt-2 text-muted">Maps Player A has played that Player B hasn't — configurable lookback.</p>
<form class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 items-end" on:submit|preventDefault={onCompare}> <form class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 items-end" on:submit|preventDefault={onCompare}>
@ -420,8 +422,13 @@
</label> </label>
</div> </div>
<div> <div>
<label class="block text-sm text-muted">Song count <label class="block text-sm text-muted">Player A lookback (months)
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="number" min="1" max="200" bind:value={songCount} /> <input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="number" min="0" max="120" bind:value={monthsA} />
</label>
</div>
<div>
<label class="block text-sm text-muted">Player B lookback (months)
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="number" min="0" max="120" bind:value={monthsB} />
</label> </label>
</div> </div>
<div> <div>
@ -464,6 +471,7 @@
<option value={48}>48</option> <option value={48}>48</option>
</select> </select>
</label> </label>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={downloadPlaylist}>Download .bplist</button> <button class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={downloadPlaylist}>Download .bplist</button>
@ -539,7 +547,8 @@
> >
<button <button
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20 disabled:opacity-50" class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20 disabled:opacity-50"
on:click={() => { const key = metaByHash[item.hash]?.key; if (key) navigator.clipboard.writeText(`!bsr ${key}`); }} class:lit-up={litButtons.has(`bsr-${item.hash}`)}
on:click={() => { const key = metaByHash[item.hash]?.key; if (key) copyBsrCommand(key, item.hash); }}
disabled={!metaByHash[item.hash]?.key} disabled={!metaByHash[item.hash]?.key}
title="Copy !bsr" title="Copy !bsr"
>Copy !bsr</button> >Copy !bsr</button>
@ -563,8 +572,63 @@
{/if} {/if}
</section> </section>
<!-- Toast Notification -->
{#if showToast}
<div class="toast-notification" role="status" aria-live="polite">
{toastMessage}
</div>
{/if}
<style> <style>
.text-danger { color: #dc2626; } .text-danger { color: #dc2626; }
/* Toast notification styles */
.toast-notification {
position: fixed;
top: 20px;
right: 20px;
background: #10b981;
color: white;
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
font-size: 14px;
font-weight: 500;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Button lighting effect */
.lit-up {
background: linear-gradient(45deg, #10b981, #059669);
border-color: #10b981;
color: white;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.5);
animation: pulse 0.6s ease-out;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
</style> </style>