Refactor score bar into a component

This commit is contained in:
pleb 2025-11-03 11:25:56 -08:00
parent 17890e42e5
commit 5caf656baf
3 changed files with 162 additions and 41 deletions

View File

@ -52,7 +52,11 @@ export let showPublished = true;
{/if} {/if}
<div class:compact-row={compact} class="meta"> <div class:compact-row={compact} class="meta">
<div class:compact-leading={compact} class="meta-leading">
<slot name="meta-leading">
<DifficultyLabel {diffName} {modeName} /> <DifficultyLabel {diffName} {modeName} />
</slot>
</div>
<div class="player"> <div class="player">
<SongPlayer {hash} preferBeatLeader={true} /> <SongPlayer {hash} preferBeatLeader={true} />
</div> </div>
@ -179,6 +183,22 @@ export let showPublished = true;
gap: 0.5rem; gap: 0.5rem;
} }
.meta-leading {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
flex: 1;
}
.meta-leading.compact-leading {
width: 100%;
}
.meta-leading :global(.score-meter) {
width: 100%;
}
.meta.compact-row { .meta.compact-row {
margin-top: 0.25rem; margin-top: 0.25rem;
gap: 0.35rem; gap: 0.35rem;
@ -217,6 +237,10 @@ export let showPublished = true;
gap: 0.2rem; gap: 0.2rem;
} }
.meta.compact-row .meta-leading {
width: 100%;
}
.meta.compact-row .player { .meta.compact-row .player {
width: 100%; width: 100%;
} }

View File

@ -0,0 +1,117 @@
<script lang="ts">
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export let value: number | null | undefined = null;
export let min = 0;
export let max = 1;
export let decimals = 1;
export let showLabel = false;
export let label: string | null = null;
export let emptyLabel = '—';
export let size: 'sm' | 'md' | 'lg' = 'md';
export let className = '';
const { class: restClass = '', ...restProps } = $$restProps;
const effectiveRange = () => {
const span = max - min;
return span === 0 ? 1 : Math.abs(span);
};
const normaliseValue = (input: number) => min + clamp(input - min, 0, effectiveRange());
const percentFromValue = (input: number | null | undefined) => {
if (typeof input !== 'number' || !Number.isFinite(input)) return null;
const bounded = clamp((input - min) / effectiveRange(), 0, 1);
return bounded * 100;
};
$: percent = percentFromValue(value);
$: hasValue = percent !== null;
$: fill = hasValue ? `${percent!.toFixed(2)}%` : '0%';
$: displayLabel = label ?? (hasValue ? `${percent!.toFixed(decimals)}%` : emptyLabel);
$: ariaValueNow = hasValue ? normaliseValue(value as number) : undefined;
$: ariaValueText = hasValue ? `${percent!.toFixed(decimals)} percent` : 'Not available';
$: classes = [`score-meter`, `score-meter--${size}`, restClass, className].filter(Boolean).join(' ');
</script>
<div
{...restProps}
class={classes}
class:score-meter--empty={!hasValue}
data-testid="score-meter"
>
<div
class="score-meter__track"
role="meter"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={ariaValueNow}
aria-valuetext={ariaValueText}
style={`--score-meter-fill:${fill}`}
></div>
{#if showLabel}
<span class="score-meter__label">{displayLabel}</span>
{/if}
</div>
<style>
.score-meter {
--score-meter-height: 0.5rem;
--score-meter-track: rgba(34, 211, 238, 0.15);
--score-meter-gradient: linear-gradient(90deg, var(--color-neon-fuchsia), var(--color-neon));
--score-meter-label-color: rgba(148, 163, 184, 0.92);
display: inline-flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-width: 0;
}
.score-meter--sm {
--score-meter-height: 0.45rem;
}
.score-meter--lg {
--score-meter-height: 0.6rem;
}
.score-meter__track {
position: relative;
flex: 1;
min-width: 60px;
height: var(--score-meter-height);
border-radius: 999px;
background-color: var(--score-meter-track);
overflow: hidden;
}
.score-meter__track::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--score-meter-gradient);
-webkit-mask-image: linear-gradient(
90deg,
#000 0 var(--score-meter-fill, 0%),
transparent var(--score-meter-fill, 0%) 100%
);
mask-image: linear-gradient(90deg, #000 0 var(--score-meter-fill, 0%), transparent var(--score-meter-fill, 0%) 100%);
clip-path: inset(0 calc(100% - var(--score-meter-fill, 0%)) 0 0 round 999px);
transition: -webkit-mask-image 0.3s ease, mask-image 0.3s ease, clip-path 0.3s ease;
}
.score-meter__label {
font-size: 0.85rem;
color: var(--score-meter-label-color);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.score-meter--empty .score-meter__label {
opacity: 0.6;
}
</style>

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import HasToolAccess from '$lib/components/HasToolAccess.svelte'; import HasToolAccess from '$lib/components/HasToolAccess.svelte';
import MapCard from '$lib/components/MapCard.svelte'; import MapCard from '$lib/components/MapCard.svelte';
import ScoreBar from '$lib/components/ScoreBar.svelte';
import type { MapDetailWithOrder, MapDetail, MapVersion, MapDifficulty, PlaylistFull } from '$lib/server/beatsaver'; import type { MapDetailWithOrder, MapDetail, MapVersion, MapDifficulty, PlaylistFull } from '$lib/server/beatsaver';
import type { BeatLeaderPlayerProfile } from '$lib/utils/plebsaber-utils'; import type { BeatLeaderPlayerProfile } from '$lib/utils/plebsaber-utils';
import { TOOL_REQUIREMENTS } from '$lib/utils/plebsaber-utils'; import { TOOL_REQUIREMENTS } from '$lib/utils/plebsaber-utils';
@ -18,6 +19,7 @@
modeName: string; modeName: string;
beatsaverKey?: string; beatsaverKey?: string;
publishedLabel: string; publishedLabel: string;
score?: number | null;
}; };
type PlaylistState = { type PlaylistState = {
@ -158,20 +160,13 @@ function togglePlaylist(id: number) {
diffName, diffName,
modeName, modeName,
beatsaverKey, beatsaverKey,
publishedLabel: formatDate(published) publishedLabel: formatDate(published),
score: typeof rawMap.stats?.score === 'number' ? rawMap.stats.score : null
}; };
} }
function formatScore(avgScore: number | undefined | null): string | null { function isFiniteScore(value: number | undefined | null): value is number {
if (typeof avgScore !== 'number' || !Number.isFinite(avgScore)) return null; return typeof value === 'number' && Number.isFinite(value);
return `${(avgScore * 100).toFixed(1)}%`;
}
function scorePercent(avgScore: number | undefined | null): number | null {
if (typeof avgScore !== 'number' || !Number.isFinite(avgScore)) return null;
const pct = avgScore * 100;
if (!Number.isFinite(pct)) return null;
return Math.min(100, Math.max(0, pct));
} }
function playlistLink(id: number | string | undefined): string { function playlistLink(id: number | string | undefined): string {
@ -225,10 +220,9 @@ function scorePercent(avgScore: number | undefined | null): number | null {
</a> </a>
<span class="map-count">{playlist.stats?.totalMaps ?? 0} maps</span> <span class="map-count">{playlist.stats?.totalMaps ?? 0} maps</span>
</div> </div>
{#if scorePercent(playlist.stats?.avgScore) !== null} {#if isFiniteScore(playlist.stats?.avgScore)}
{@const pct = scorePercent(playlist.stats?.avgScore) ?? 0}
<div class="row-score"> <div class="row-score">
<div class="score-bar" style={`--score-fill:${pct}%`}></div> <ScoreBar value={playlist.stats?.avgScore ?? null} size="sm" />
</div> </div>
{/if} {/if}
<div class="row-sub"> <div class="row-sub">
@ -273,7 +267,16 @@ function scorePercent(avgScore: number | undefined | null): number | null {
compact={true} compact={true}
showActions={false} showActions={false}
showPublished={false} showPublished={false}
>
<ScoreBar
slot="meta-leading"
value={card.score ?? null}
size="sm"
decimals={1}
showLabel={true}
emptyLabel="No score"
/> />
</MapCard>
{/each} {/each}
</div> </div>
{#if state.total && state.total > SONGS_PER_PAGE} {#if state.total && state.total > SONGS_PER_PAGE}
@ -420,33 +423,10 @@ function scorePercent(avgScore: number | undefined | null): number | null {
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
max-width: 220px; max-width: 220px;
--score-default: rgba(148, 163, 184, 0.35);
} }
.score-bar { .row-score :global(.score-meter) {
position: relative;
width: 100%; width: 100%;
height: 0.5rem;
border-radius: 999px;
background-color: rgba(34, 211, 238, 0.15);
overflow: hidden;
}
.score-bar::before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
border-radius: inherit;
background: linear-gradient(90deg, var(--color-neon-fuchsia), var(--color-neon));
/* Reveal only the first N% of the full-width gradient */
-webkit-mask-image: linear-gradient(90deg, #000 0 var(--score-fill, 0%), transparent var(--score-fill, 0%) 100%);
mask-image: linear-gradient(90deg, #000 0 var(--score-fill, 0%), transparent var(--score-fill, 0%) 100%);
/* Fallback for environments without mask-image support */
clip-path: inset(0 calc(100% - var(--score-fill, 0%)) 0 0 round 999px);
transition: -webkit-mask-image 0.3s ease, mask-image 0.3s ease, clip-path 0.3s ease;
} }
.row-arrow { .row-arrow {