add components for song metadata
This commit is contained in:
parent
0eb11db7d8
commit
c0a564393c
22
src/lib/components/DifficultyLabel.svelte
Normal file
22
src/lib/components/DifficultyLabel.svelte
Normal 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>
|
||||
|
||||
147
src/lib/components/MapActionButtons.svelte
Normal file
147
src/lib/components/MapActionButtons.svelte
Normal 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>
|
||||
|
||||
43
src/lib/components/SongCard.svelte
Normal file
43
src/lib/components/SongCard.svelte
Normal 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>
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from '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 = {
|
||||
timeset?: string | number;
|
||||
@ -85,55 +88,6 @@
|
||||
let starsByKey: Record<string, StarInfo> = {};
|
||||
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> {
|
||||
try {
|
||||
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`);
|
||||
@ -200,21 +154,6 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 {
|
||||
if (value === null || value === undefined) return 'ExpertPlus';
|
||||
if (typeof value === 'string') {
|
||||
@ -488,72 +427,33 @@
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{#each pageItems as item}
|
||||
<article class="card-surface overflow-hidden">
|
||||
<div class="aspect-square bg-black/30">
|
||||
{#if metaByHash[item.hash]?.coverURL}
|
||||
<img
|
||||
src={metaByHash[item.hash].coverURL}
|
||||
alt={metaByHash[item.hash]?.songName ?? item.hash}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
<SongCard
|
||||
hash={item.hash}
|
||||
coverURL={metaByHash[item.hash]?.coverURL}
|
||||
songName={metaByHash[item.hash]?.songName}
|
||||
mapper={metaByHash[item.hash]?.mapper}
|
||||
stars={starsByKey[`${item.hash}|${item.difficulties[0]?.name ?? 'ExpertPlus'}|${item.difficulties[0]?.characteristic ?? 'Standard'}`]?.stars}
|
||||
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">
|
||||
<SongPlayer hash={item.hash} preferBeatLeader={true} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<a
|
||||
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
|
||||
href={item.leaderboardId
|
||||
? `https://beatleader.com/leaderboard/global/${item.leaderboardId}`
|
||||
: `https://beatleader.com/leaderboard/global/${item.hash}?diff=${encodeURIComponent(item.difficulties[0]?.name ?? 'ExpertPlus')}&mode=${encodeURIComponent(item.difficulties[0]?.characteristic ?? 'Standard')}`}
|
||||
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={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 slot="actions" class="mt-3">
|
||||
<MapActionButtons
|
||||
hash={item.hash}
|
||||
leaderboardId={item.leaderboardId}
|
||||
diffName={item.difficulties[0]?.name ?? 'ExpertPlus'}
|
||||
modeName={item.difficulties[0]?.characteristic ?? 'Standard'}
|
||||
beatsaverKey={metaByHash[item.hash]?.key}
|
||||
/>
|
||||
</div>
|
||||
</SongCard>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
@ -572,63 +472,8 @@
|
||||
{/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>
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from '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 = {
|
||||
timeset?: string | number;
|
||||
@ -72,55 +75,6 @@
|
||||
let metaByHash: Record<string, MapMeta> = {};
|
||||
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 {
|
||||
if (value === undefined || value === null) return null;
|
||||
return value <= 1 ? value * 100 : value;
|
||||
@ -642,24 +596,20 @@
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{#each pageItems as item}
|
||||
<article class="card-surface overflow-hidden">
|
||||
<div class="aspect-square bg-black/30">
|
||||
{#if metaByHash[item.hash]?.coverURL}
|
||||
<img src={metaByHash[item.hash].coverURL} alt={metaByHash[item.hash]?.songName ?? item.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={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">{metaByHash[item.hash]?.mapper}</div>
|
||||
{/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>
|
||||
<SongCard
|
||||
hash={item.hash}
|
||||
coverURL={metaByHash[item.hash]?.coverURL}
|
||||
songName={metaByHash[item.hash]?.songName}
|
||||
mapper={metaByHash[item.hash]?.mapper}
|
||||
timeset={item.timeset}
|
||||
>
|
||||
<div slot="difficulty" class="mt-2">
|
||||
<DifficultyLabel
|
||||
diffName={item.diffName}
|
||||
modeName={item.modeName}
|
||||
/>
|
||||
</div>
|
||||
<div slot="content">
|
||||
<div class="mt-3 grid grid-cols-2 gap-3 neon-surface">
|
||||
<div class="player-card playerA {item.accA != null && item.accB != null && item.accA > item.accB ? 'winner' : ''}">
|
||||
<div class="label {item.accA != null && item.accB != null && item.accA > item.accB ? 'winner-label' : ''}">{idShortA}</div>
|
||||
@ -675,7 +625,7 @@
|
||||
<div class="mt-2 text-center text-sm">
|
||||
{#if item.accA != null && item.accB != null}
|
||||
{#if item.accA === item.accB}
|
||||
<span class="chip chip-draw">Tie</span>
|
||||
<span class="chip chip-draw">TIE?!</span>
|
||||
{:else if item.accA > item.accB}
|
||||
<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>
|
||||
@ -687,39 +637,22 @@
|
||||
<span class="chip">Incomplete</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<div class="w-1/2 flex flex-wrap gap-2">
|
||||
<a
|
||||
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
|
||||
href={item.leaderboardId
|
||||
? `https://beatleader.com/leaderboard/global/${item.leaderboardId}`
|
||||
: `https://beatleader.com/leaderboard/global/${item.hash}?diff=${encodeURIComponent(item.diffName)}&mode=${encodeURIComponent(item.modeName)}`}
|
||||
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={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 slot="actions" class="mt-3 flex items-center gap-2">
|
||||
<div class="w-1/2">
|
||||
<MapActionButtons
|
||||
hash={item.hash}
|
||||
leaderboardId={item.leaderboardId}
|
||||
diffName={item.diffName}
|
||||
modeName={item.modeName}
|
||||
beatsaverKey={metaByHash[item.hash]?.key}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<SongPlayer hash={item.hash} preferBeatLeader={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SongCard>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
@ -734,13 +667,6 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
{#if showToast}
|
||||
<div class="toast-notification" role="status" aria-live="polite">
|
||||
{toastMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.text-danger { color: #dc2626; }
|
||||
|
||||
@ -892,54 +818,6 @@
|
||||
background: #0f172a;
|
||||
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>
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user