add components for song metadata

This commit is contained in:
pleb 2025-10-29 10:20:37 -07:00
parent 0eb11db7d8
commit c0a564393c
5 changed files with 287 additions and 352 deletions

View File

@ -0,0 +1,22 @@
<script lang="ts">
export let diffName: string;
export let modeName: string = 'Standard';
function difficultyToColor(name: string | undefined): string {
const n = (name ?? 'ExpertPlus').toLowerCase();
if (n === 'easy') return 'MediumSeaGreen';
if (n === 'normal') return '#59b0f4';
if (n === 'hard') return 'tomato';
if (n === 'expert') return '#bf2a42';
if (n === 'expertplus' || n === 'expert+' || n === 'ex+' ) return '#8f48db';
return '#8f48db';
}
</script>
<span class="rounded bg-white/10 px-2 py-0.5 text-[11px]">
{modeName} ·
<span class="rounded px-1 ml-1" style="background-color: {difficultyToColor(diffName)}; color: #fff;">
{diffName}
</span>
</span>

View File

@ -0,0 +1,147 @@
<script lang="ts">
export let hash: string;
export let leaderboardId: string | undefined = undefined;
export let diffName: string = 'ExpertPlus';
export let modeName: string = 'Standard';
export let beatsaverKey: string | undefined = undefined;
// Toast notification state
let toastMessage = '';
let showToast = false;
let toastTimeout: ReturnType<typeof setTimeout> | null = null;
// Button feedback state
let isLitUp = false;
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() {
isLitUp = true;
// Remove the lighting effect after 1 second
setTimeout(() => {
isLitUp = false;
}, 1000);
}
async function copyBsrCommand() {
if (!beatsaverKey) return;
try {
const bsrCommand = `!bsr ${beatsaverKey}`;
await navigator.clipboard.writeText(bsrCommand);
// Show success feedback with the actual command
showToastMessage(`Copied "${bsrCommand}" to clipboard`);
lightUpButton();
} catch (err) {
console.error('Failed to copy to clipboard:', err);
showToastMessage('Failed to copy to clipboard');
}
}
$: beatLeaderUrl = leaderboardId
? `https://beatleader.com/leaderboard/global/${leaderboardId}`
: `https://beatleader.com/leaderboard/global/${hash}?diff=${encodeURIComponent(diffName)}&mode=${encodeURIComponent(modeName)}`;
$: beatSaverUrl = beatsaverKey
? `https://beatsaver.com/maps/${beatsaverKey}`
: `https://beatsaver.com/search/hash/${hash}`;
</script>
<div class="flex flex-wrap gap-2">
<a
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
href={beatLeaderUrl}
target="_blank"
rel="noopener"
title="Open in BeatLeader"
>BL</a
>
<a
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
href={beatSaverUrl}
target="_blank"
rel="noopener"
title="Open in BeatSaver"
>BS</a
>
<button
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20 disabled:opacity-50"
class:lit-up={isLitUp}
on:click={copyBsrCommand}
disabled={!beatsaverKey}
title="!bsr"
>!bsr</button>
</div>
<!-- Toast Notification -->
{#if showToast}
<div class="toast-notification" role="status" aria-live="polite">
{toastMessage}
</div>
{/if}
<style>
/* 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 !important;
color: white !important;
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>

View File

@ -0,0 +1,43 @@
<script lang="ts">
export let hash: string;
export let coverURL: string | undefined = undefined;
export let songName: string | undefined = undefined;
export let mapper: string | undefined = undefined;
export let stars: number | undefined = undefined;
export let timeset: number | undefined = undefined;
</script>
<div class="aspect-square bg-black/30">
{#if coverURL}
<img
src={coverURL}
alt={songName ?? hash}
loading="lazy"
class="h-full w-full object-cover"
/>
{:else}
<div class="h-full w-full flex items-center justify-center text-xs text-muted">No cover</div>
{/if}
</div>
<div class="p-3">
<div class="font-semibold truncate" title={songName ?? hash}>
{songName ?? hash}
</div>
{#if mapper}
<div class="mt-0.5 text-xs text-muted truncate flex items-center justify-between">
<span>
{mapper}
{#if stars !== undefined}
<span class="ml-3" title="BeatLeader star rating">{stars.toFixed(2)}</span>
{/if}
</span>
{#if timeset !== undefined}
<span class="text-[11px] ml-2">{new Date(timeset * 1000).toLocaleDateString()}</span>
{/if}
</div>
{/if}
<slot name="difficulty" />
<slot name="content" />
<slot name="actions" />
</div>

View File

@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import SongPlayer from '$lib/components/SongPlayer.svelte'; import SongPlayer from '$lib/components/SongPlayer.svelte';
import MapActionButtons from '$lib/components/MapActionButtons.svelte';
import DifficultyLabel from '$lib/components/DifficultyLabel.svelte';
import SongCard from '$lib/components/SongCard.svelte';
type BeatLeaderScore = { type BeatLeaderScore = {
timeset?: string | number; timeset?: string | number;
@ -85,55 +88,6 @@
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: ReturnType<typeof setTimeout> | 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)}`);
@ -188,7 +142,7 @@
// ignore // ignore
} }
} }
async function loadStarsForResults(items: SongItem[]): Promise<void> { async function loadStarsForResults(items: SongItem[]): Promise<void> {
const neededHashes = Array.from(new Set(items.map((i) => i.hash))); const neededHashes = Array.from(new Set(items.map((i) => i.hash)));
if (neededHashes.length === 0) return; if (neededHashes.length === 0) return;
@ -199,21 +153,6 @@
loadingStars = false; loadingStars = false;
} }
function difficultyToColor(name: string | undefined): string {
const n = (name ?? 'ExpertPlus').toLowerCase();
if (n === 'easy') return 'MediumSeaGreen';
if (n === 'normal') return '#59b0f4';
if (n === 'hard') return 'tomato';
if (n === 'expert') return '#bf2a42';
if (n === 'expertplus' || n === 'expert+' || n === 'ex+' ) return '#8f48db';
return '#8f48db';
}
function normalizeDifficultyName(value: number | string | null | undefined): string { function normalizeDifficultyName(value: number | string | null | undefined): string {
if (value === null || value === undefined) return 'ExpertPlus'; if (value === null || value === undefined) return 'ExpertPlus';
@ -488,72 +427,33 @@
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each pageItems as item} {#each pageItems as item}
<article class="card-surface overflow-hidden"> <article class="card-surface overflow-hidden">
<div class="aspect-square bg-black/30"> <SongCard
{#if metaByHash[item.hash]?.coverURL} hash={item.hash}
<img coverURL={metaByHash[item.hash]?.coverURL}
src={metaByHash[item.hash].coverURL} songName={metaByHash[item.hash]?.songName}
alt={metaByHash[item.hash]?.songName ?? item.hash} mapper={metaByHash[item.hash]?.mapper}
loading="lazy" stars={starsByKey[`${item.hash}|${item.difficulties[0]?.name ?? 'ExpertPlus'}|${item.difficulties[0]?.characteristic ?? 'Standard'}`]?.stars}
class="h-full w-full object-cover" timeset={item.timeset}
>
<div slot="difficulty" class="mt-2 flex items-center gap-2">
<DifficultyLabel
diffName={item.difficulties[0]?.name ?? 'ExpertPlus'}
modeName={item.difficulties[0]?.characteristic ?? 'Standard'}
/> />
{:else}
<div class="h-full w-full flex items-center justify-center text-xs text-muted">No cover</div>
{/if}
</div>
<div class="p-3">
<div class="font-semibold truncate" title={metaByHash[item.hash]?.songName ?? item.hash}>
{metaByHash[item.hash]?.songName ?? item.hash}
</div>
{#if metaByHash[item.hash]?.mapper}
<div class="mt-0.5 text-xs text-muted truncate flex items-center justify-between">
<span>
{metaByHash[item.hash]?.mapper}
{#if starsByKey[`${item.hash}|${item.difficulties[0]?.name ?? 'ExpertPlus'}|${item.difficulties[0]?.characteristic ?? 'Standard'}`]?.stars}
<span class="ml-3" title="BeatLeader star rating">{starsByKey[`${item.hash}|${item.difficulties[0]?.name ?? 'ExpertPlus'}|${item.difficulties[0]?.characteristic ?? 'Standard'}`]?.stars?.toFixed(2)}</span>
{/if}
</span>
<span class="text-[11px] ml-2">{new Date(item.timeset * 1000).toLocaleDateString()}</span>
</div>
{/if}
<div class="mt-2 flex items-center gap-2">
<span class="rounded bg-white/10 px-2 py-0.5 text-[11px]">
{item.difficulties[0]?.characteristic ?? 'Standard'} ·
<span class="rounded px-1 ml-1" style="background-color: {difficultyToColor(item.difficulties[0]?.name)}; color: #fff;">
{item.difficulties[0]?.name}
</span>
</span>
<div class="flex-1"> <div class="flex-1">
<SongPlayer hash={item.hash} preferBeatLeader={true} /> <SongPlayer hash={item.hash} preferBeatLeader={true} />
</div> </div>
</div> </div>
<div class="mt-3 flex flex-wrap gap-2"> <div slot="actions" class="mt-3">
<a <MapActionButtons
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20" hash={item.hash}
href={item.leaderboardId leaderboardId={item.leaderboardId}
? `https://beatleader.com/leaderboard/global/${item.leaderboardId}` diffName={item.difficulties[0]?.name ?? 'ExpertPlus'}
: `https://beatleader.com/leaderboard/global/${item.hash}?diff=${encodeURIComponent(item.difficulties[0]?.name ?? 'ExpertPlus')}&mode=${encodeURIComponent(item.difficulties[0]?.characteristic ?? 'Standard')}`} modeName={item.difficulties[0]?.characteristic ?? 'Standard'}
target="_blank" beatsaverKey={metaByHash[item.hash]?.key}
rel="noopener" />
title="Open in BeatLeader"
>BL</a
>
<a
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
href={metaByHash[item.hash]?.key ? `https://beatsaver.com/maps/${metaByHash[item.hash]?.key}` : `https://beatsaver.com/search/hash/${item.hash}`}
target="_blank"
rel="noopener"
title="Open in BeatSaver"
>BS</a
>
<button
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20 disabled:opacity-50"
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="!bsr"
>!bsr</button>
</div> </div>
</div> </SongCard>
</article> </article>
{/each} {/each}
</div> </div>
@ -572,63 +472,8 @@
{/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>

View File

@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import SongPlayer from '$lib/components/SongPlayer.svelte'; import SongPlayer from '$lib/components/SongPlayer.svelte';
import MapActionButtons from '$lib/components/MapActionButtons.svelte';
import DifficultyLabel from '$lib/components/DifficultyLabel.svelte';
import SongCard from '$lib/components/SongCard.svelte';
type BeatLeaderScore = { type BeatLeaderScore = {
timeset?: string | number; timeset?: string | number;
@ -72,55 +75,6 @@
let metaByHash: Record<string, MapMeta> = {}; let metaByHash: Record<string, MapMeta> = {};
let loadingMeta = false; let loadingMeta = false;
// Toast notification state
let toastMessage = '';
let showToast = false;
let toastTimeout: ReturnType<typeof setTimeout> | 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');
}
}
function normalizeAccuracy(value: number | undefined): number | null { function normalizeAccuracy(value: number | undefined): number | null {
if (value === undefined || value === null) return null; if (value === undefined || value === null) return null;
return value <= 1 ? value * 100 : value; return value <= 1 ? value * 100 : value;
@ -642,84 +596,63 @@
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each pageItems as item} {#each pageItems as item}
<article class="card-surface overflow-hidden"> <article class="card-surface overflow-hidden">
<div class="aspect-square bg-black/30"> <SongCard
{#if metaByHash[item.hash]?.coverURL} hash={item.hash}
<img src={metaByHash[item.hash].coverURL} alt={metaByHash[item.hash]?.songName ?? item.hash} loading="lazy" class="h-full w-full object-cover" /> coverURL={metaByHash[item.hash]?.coverURL}
{:else} songName={metaByHash[item.hash]?.songName}
<div class="h-full w-full flex items-center justify-center text-xs text-muted">No cover</div> mapper={metaByHash[item.hash]?.mapper}
{/if} timeset={item.timeset}
</div> >
<div class="p-3"> <div slot="difficulty" class="mt-2">
<div class="font-semibold truncate" title={metaByHash[item.hash]?.songName ?? item.hash}>{metaByHash[item.hash]?.songName ?? item.hash}</div> <DifficultyLabel
{#if metaByHash[item.hash]?.mapper} diffName={item.diffName}
<div class="mt-0.5 text-xs text-muted truncate">{metaByHash[item.hash]?.mapper}</div> modeName={item.modeName}
{/if} />
<div class="mt-2 flex items-center justify-between text-[11px]">
<span class="rounded bg-white/10 px-2 py-0.5">
{item.modeName} · <span class="rounded px-1 ml-1" style="background-color: var(--neon-diff)">{item.diffName}</span>
</span>
<span class="text-muted">{new Date(item.timeset * 1000).toLocaleDateString()}</span>
</div> </div>
<div class="mt-3 grid grid-cols-2 gap-3 neon-surface"> <div slot="content">
<div class="player-card playerA {item.accA != null && item.accB != null && item.accA > item.accB ? 'winner' : ''}"> <div class="mt-3 grid grid-cols-2 gap-3 neon-surface">
<div class="label {item.accA != null && item.accB != null && item.accA > item.accB ? 'winner-label' : ''}">{idShortA}</div> <div class="player-card playerA {item.accA != null && item.accB != null && item.accA > item.accB ? 'winner' : ''}">
<div class="value {item.accA != null && item.accB != null && item.accA > item.accB ? 'winner-value' : ''}">{item.accA != null ? item.accA.toFixed(2) + '%' : '—'}</div> <div class="label {item.accA != null && item.accB != null && item.accA > item.accB ? 'winner-label' : ''}">{idShortA}</div>
<div class="sub">{item.rankA ? `Rank #${item.rankA}` : ''}</div> <div class="value {item.accA != null && item.accB != null && item.accA > item.accB ? 'winner-value' : ''}">{item.accA != null ? item.accA.toFixed(2) + '%' : '—'}</div>
<div class="sub">{item.rankA ? `Rank #${item.rankA}` : ''}</div>
</div>
<div class="player-card playerB {item.accA != null && item.accB != null && item.accB > item.accA ? 'winner' : ''}">
<div class="label {item.accA != null && item.accB != null && item.accB > item.accA ? 'winner-label' : ''}">{idShortB}</div>
<div class="value {item.accA != null && item.accB != null && item.accB > item.accA ? 'winner-value' : ''}">{item.accB != null ? item.accB.toFixed(2) + '%' : '—'}</div>
<div class="sub">{item.rankB ? `Rank #${item.rankB}` : ''}</div>
</div>
</div> </div>
<div class="player-card playerB {item.accA != null && item.accB != null && item.accB > item.accA ? 'winner' : ''}"> <div class="mt-2 text-center text-sm">
<div class="label {item.accA != null && item.accB != null && item.accB > item.accA ? 'winner-label' : ''}">{idShortB}</div> {#if item.accA != null && item.accB != null}
<div class="value {item.accA != null && item.accB != null && item.accB > item.accA ? 'winner-value' : ''}">{item.accB != null ? item.accB.toFixed(2) + '%' : '—'}</div> {#if item.accA === item.accB}
<div class="sub">{item.rankB ? `Rank #${item.rankB}` : ''}</div> <span class="chip chip-draw">TIE?!</span>
</div> {:else if item.accA > item.accB}
</div> <span class="chip chip-win-a">Winner: {idShortA}</span>
<div class="mt-2 text-center text-sm"> <span class="ml-2 text-muted margin-text {(item.accA - item.accB) > 1 ? 'margin-bold' : ''} {(item.accA - item.accB) > 2 ? 'margin-bright' : ''}">by {(item.accA - item.accB).toFixed(2)}%</span>
{#if item.accA != null && item.accB != null} {:else}
{#if item.accA === item.accB} <span class="chip chip-win-b">Winner: {idShortB}</span>
<span class="chip chip-draw">Tie</span> <span class="ml-2 text-muted margin-text {(item.accB - item.accA) > 1 ? 'margin-bold' : ''} {(item.accB - item.accA) > 2 ? 'margin-bright' : ''}">by {(item.accB - item.accA).toFixed(2)}%</span>
{:else if item.accA > item.accB} {/if}
<span class="chip chip-win-a">Winner: {idShortA}</span>
<span class="ml-2 text-muted margin-text {(item.accA - item.accB) > 1 ? 'margin-bold' : ''} {(item.accA - item.accB) > 2 ? 'margin-bright' : ''}">by {(item.accA - item.accB).toFixed(2)}%</span>
{:else} {:else}
<span class="chip chip-win-b">Winner: {idShortB}</span> <span class="chip">Incomplete</span>
<span class="ml-2 text-muted margin-text {(item.accB - item.accA) > 1 ? 'margin-bold' : ''} {(item.accB - item.accA) > 2 ? 'margin-bright' : ''}">by {(item.accB - item.accA).toFixed(2)}%</span>
{/if} {/if}
{:else} </div>
<span class="chip">Incomplete</span>
{/if}
</div> </div>
<div class="mt-3 flex items-center gap-2"> <div slot="actions" class="mt-3 flex items-center gap-2">
<div class="w-1/2 flex flex-wrap gap-2"> <div class="w-1/2">
<a <MapActionButtons
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20" hash={item.hash}
href={item.leaderboardId leaderboardId={item.leaderboardId}
? `https://beatleader.com/leaderboard/global/${item.leaderboardId}` diffName={item.diffName}
: `https://beatleader.com/leaderboard/global/${item.hash}?diff=${encodeURIComponent(item.diffName)}&mode=${encodeURIComponent(item.modeName)}`} modeName={item.modeName}
target="_blank" beatsaverKey={metaByHash[item.hash]?.key}
rel="noopener" />
title="Open in BeatLeader"
>BL</a
>
<a
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
href={metaByHash[item.hash]?.key ? `https://beatsaver.com/maps/${metaByHash[item.hash]?.key}` : `https://beatsaver.com/search/hash/${item.hash}`}
target="_blank"
rel="noopener"
title="Open in BeatSaver"
>BS</a
>
<button
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20 disabled:opacity-50"
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="!bsr"
>!bsr</button>
</div> </div>
<div class="w-1/2"> <div class="w-1/2">
<SongPlayer hash={item.hash} preferBeatLeader={true} /> <SongPlayer hash={item.hash} preferBeatLeader={true} />
</div> </div>
</div> </div>
</div> </SongCard>
</article> </article>
{/each} {/each}
</div> </div>
@ -734,13 +667,6 @@
{/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; }
@ -892,54 +818,6 @@
background: #0f172a; background: #0f172a;
color: #fff; color: #fff;
} }
/* 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>