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">
|
<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)}`);
|
||||||
@ -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 {
|
function normalizeDifficultyName(value: number | string | null | undefined): string {
|
||||||
if (value === null || value === undefined) return 'ExpertPlus';
|
if (value === null || value === undefined) return 'ExpertPlus';
|
||||||
if (typeof value === 'string') {
|
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">
|
<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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user