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 { 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 }) => {
|
||||
clearTokens(cookies);
|
||||
const redirectTo = url.searchParams.get('redirect_uri') ?? '/';
|
||||
|
||||
@ -34,7 +34,6 @@
|
||||
|
||||
let playerA = '';
|
||||
let playerB = '';
|
||||
let songCount = 10;
|
||||
let loading = false;
|
||||
let errorMsg: string | null = null;
|
||||
let results: SongItem[] = [];
|
||||
@ -47,6 +46,10 @@
|
||||
let pageSize: number | string = 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
|
||||
$: sortedResults = [...results].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
@ -82,6 +85,55 @@
|
||||
let starsByKey: Record<string, StarInfo> = {};
|
||||
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> {
|
||||
try {
|
||||
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`);
|
||||
@ -147,6 +199,8 @@
|
||||
loadingStars = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function difficultyToColor(name: string | undefined): string {
|
||||
const n = (name ?? 'ExpertPlus').toLowerCase();
|
||||
if (n === 'easy') return 'MediumSeaGreen';
|
||||
@ -195,69 +249,23 @@
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function getCutoffEpoch(): number {
|
||||
return Math.floor(Date.now() / 1000) - ONE_YEAR_SECONDS;
|
||||
function getCutoffEpochFromMonths(months: number | string): number {
|
||||
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[]> {
|
||||
const pageSize = 100;
|
||||
let page = 1;
|
||||
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);
|
||||
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);
|
||||
|
||||
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 fetchAllRecentScores(playerId: string, cutoffEpoch: number, maxPages = 15): Promise<BeatLeaderScore[]> {
|
||||
const qs = new URLSearchParams({ diff: 'ExpertPlus', cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
|
||||
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
|
||||
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;
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
@ -316,24 +324,29 @@
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const cutoff = getCutoffEpoch();
|
||||
const cutoff = getCutoffEpochFromMonths(monthsA);
|
||||
const cutoffB = getCutoffEpochFromMonths(monthsB);
|
||||
const [aScores, bScores] = await Promise.all([
|
||||
fetchAllRecentScores(a, cutoff),
|
||||
fetchAllScoresAnyTime(b, 100)
|
||||
fetchAllRecentScores(b, cutoffB, 100)
|
||||
]);
|
||||
|
||||
const bHashes = new Set<string>();
|
||||
const bLeaderboardIds = new Set<string>();
|
||||
const bExpertPlusKeys = new Set<string>(); // `${hashLower}|ExpertPlus|${modeName}`
|
||||
for (const s of bScores) {
|
||||
const rawHash = s.leaderboard?.song?.hash ?? undefined;
|
||||
const hashLower = rawHash ? String(rawHash).toLowerCase() : undefined;
|
||||
const lbIdRaw = (s.leaderboard as any)?.id ?? (s.leaderboard as any)?.leaderboardId;
|
||||
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 (hashLower) bExpertPlusKeys.add(`${hashLower}|ExpertPlus|${bModeName}`);
|
||||
}
|
||||
|
||||
const history = loadHistory();
|
||||
|
||||
const runSeen = new Set<string>(); // avoid duplicates within this run
|
||||
|
||||
const candidates: SongItem[] = [];
|
||||
@ -348,11 +361,11 @@
|
||||
const leaderboardId = leaderboardIdRaw != null ? String(leaderboardIdRaw) : undefined;
|
||||
if (!rawHash) continue;
|
||||
const hashLower = String(rawHash).toLowerCase();
|
||||
if (bHashes.has(hashLower) || (leaderboardId && bLeaderboardIds.has(leaderboardId))) continue; // B has played this song
|
||||
|
||||
const diffName = normalizeDifficultyName(diffValue);
|
||||
const historyDiffs = history[rawHash] ?? [];
|
||||
if (historyDiffs.includes(diffName)) continue; // used previously
|
||||
if (diffName !== 'ExpertPlus') continue; // Only compare ExpertPlus for A
|
||||
// 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}`;
|
||||
if (runSeen.has(key)) continue;
|
||||
@ -367,15 +380,9 @@
|
||||
}
|
||||
|
||||
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;
|
||||
page = 1;
|
||||
@ -395,17 +402,12 @@
|
||||
const sp = new URLSearchParams(location.search);
|
||||
playerA = sp.get('a') ?? '';
|
||||
playerB = sp.get('b') ?? '';
|
||||
const sc = sp.get('n');
|
||||
if (sc) {
|
||||
const n = Number(sc);
|
||||
if (Number.isFinite(n) && n > 0) songCount = n;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="py-8">
|
||||
<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}>
|
||||
@ -420,8 +422,13 @@
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-muted">Song count
|
||||
<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} />
|
||||
<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="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>
|
||||
</div>
|
||||
<div>
|
||||
@ -464,6 +471,7 @@
|
||||
<option value={48}>48</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
<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>
|
||||
@ -539,7 +547,8 @@
|
||||
>
|
||||
<button
|
||||
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}
|
||||
title="Copy !bsr"
|
||||
>Copy !bsr</button>
|
||||
@ -563,8 +572,63 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
{#if showToast}
|
||||
<div class="toast-notification" role="status" aria-live="polite">
|
||||
{toastMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.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>
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user