add ui feedback on clicking bsr
This commit is contained in:
parent
3460bfe401
commit
2f3f2d94ef
201
src/routes/api/beatleader-cache/player/[id]/+server.ts
Normal file
201
src/routes/api/beatleader-cache/player/[id]/+server.ts
Normal 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' } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -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') ?? '/';
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user