All checks were successful
Deploy production / deploy (push) Successful in 1m14s
499 lines
14 KiB
Svelte
499 lines
14 KiB
Svelte
<script lang="ts">
|
||
import { enhance } from '$app/forms';
|
||
import Target from 'lucide-svelte/icons/target';
|
||
import Trophy from 'lucide-svelte/icons/trophy';
|
||
import CircleCheck from 'lucide-svelte/icons/circle-check';
|
||
import Star from 'lucide-svelte/icons/star';
|
||
import Clock from 'lucide-svelte/icons/clock';
|
||
import Music from 'lucide-svelte/icons/music';
|
||
import EyeOff from 'lucide-svelte/icons/eye-off';
|
||
import FastForward from 'lucide-svelte/icons/fast-forward';
|
||
import Waves from 'lucide-svelte/icons/waves';
|
||
import Gauge from 'lucide-svelte/icons/gauge';
|
||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||
import CircleHelp from 'lucide-svelte/icons/circle-help';
|
||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||
import { FOCUS_LABELS, formatLength } from '#lib/game-tracker/osu-goals.js';
|
||
import type { OsuGoalView } from '#lib/server/osu-goals/load.js';
|
||
|
||
// Maps I want to pass or FC (games.osu_goals, moved from the old list), with osu! API
|
||
// metadata and my best score. Refreshed hourly by the in-app poller.
|
||
let { goals, owner }: { goals: OsuGoalView[]; owner: boolean } = $props();
|
||
|
||
const matched = $derived(goals.filter((g) => g.matchStatus === 'matched'));
|
||
const open = $derived(goals.filter((g) => g.matchStatus !== 'matched'));
|
||
const done = $derived(goals.filter((g) => g.achievedAt).length);
|
||
const passedCount = $derived(matched.filter((g) => g.passed).length);
|
||
const fcCount = $derived(matched.filter((g) => g.fc).length);
|
||
const lastCheck = $derived(
|
||
goals
|
||
.map((g) => g.scoresAt)
|
||
.filter(Boolean)
|
||
.sort()
|
||
.at(-1)
|
||
);
|
||
|
||
// osu!'s own star rating colour spectrum.
|
||
const STOPS: [number, string][] = [
|
||
[0.1, '#4290fb'],
|
||
[1.25, '#4fc0ff'],
|
||
[2, '#4fffd5'],
|
||
[2.5, '#7cff4f'],
|
||
[3.3, '#f6f05c'],
|
||
[4.2, '#ff8068'],
|
||
[4.9, '#ff4e6f'],
|
||
[5.8, '#c645b8'],
|
||
[6.7, '#6563de'],
|
||
[7.7, '#18158e'],
|
||
[9, '#000000']
|
||
];
|
||
function starColour(s: number) {
|
||
if (s <= STOPS[0][0]) return STOPS[0][1];
|
||
for (let i = 1; i < STOPS.length; i++) {
|
||
const [x1, c1] = STOPS[i];
|
||
const [x0, c0] = STOPS[i - 1];
|
||
if (s <= x1) {
|
||
const t = (s - x0) / (x1 - x0);
|
||
const ch = (c: string, k: number) => parseInt(c.slice(1 + k * 2, 3 + k * 2), 16);
|
||
const mix = [0, 1, 2].map((k) => Math.round(ch(c0, k) + (ch(c1, k) - ch(c0, k)) * t));
|
||
return `rgb(${mix.join(',')})`;
|
||
}
|
||
}
|
||
return '#000';
|
||
}
|
||
const rankClass = (r: string | null) =>
|
||
!r
|
||
? ''
|
||
: r.startsWith('X') || r === 'SS' || r === 'SSH'
|
||
? 'rank-x'
|
||
: `rank-${r[0].toLowerCase()}`;
|
||
const rankLabel = (r: string | null) =>
|
||
r === 'X' ? 'SS' : r === 'XH' ? 'SS' : r === 'SH' ? 'S' : (r ?? '');
|
||
const silver = (r: string | null) => r === 'XH' || r === 'SH';
|
||
const goalText = (g: OsuGoalView) =>
|
||
g.goal === 'fc' ? 'FC' : g.goal === 'pass' ? 'pass' : 'pass / FC';
|
||
const nf = (n: number | null, d = 0) =>
|
||
n == null
|
||
? '–'
|
||
: n.toLocaleString('en-GB', { maximumFractionDigits: d, minimumFractionDigits: d });
|
||
const day = (iso: string | null) =>
|
||
iso
|
||
? new Date(iso).toLocaleDateString('en-GB', {
|
||
day: 'numeric',
|
||
month: 'short',
|
||
year: 'numeric'
|
||
})
|
||
: '';
|
||
const focusIcon: Record<string, typeof EyeOff> = {
|
||
hd: EyeOff,
|
||
dt: FastForward,
|
||
streams: Waves,
|
||
'fast-bpm': Gauge
|
||
};
|
||
</script>
|
||
|
||
<section id="goals" class="mb-10 scroll-mt-24">
|
||
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||
<div>
|
||
<h2 class="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||
<span class="icon-tile"><Target size={18} aria-hidden="true" /></span>goals: to pass / FC
|
||
</h2>
|
||
<p class="mt-1 text-sm text-[var(--site-muted)]">
|
||
{goals.length} maps · {passedCount} passed · {fcCount} FC’d{#if done}
|
||
· {done} goal{done === 1 ? '' : 's'} done{/if}{#if lastCheck}
|
||
· scores checked {day(lastCheck)}{/if}
|
||
</p>
|
||
</div>
|
||
{#if owner}
|
||
<form method="POST" action="?/osuGoalsRefresh" use:enhance>
|
||
<button class="refresh" type="submit"
|
||
><RefreshCw size={14} aria-hidden="true" /> refresh scores</button
|
||
>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
|
||
<ul class="goals">
|
||
{#each matched as g (g.id)}
|
||
<li class={['goal', g.achievedAt && 'achieved']}>
|
||
<a
|
||
class="thumb"
|
||
href="https://osu.ppy.sh/beatmaps/{g.beatmapId}"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
aria-label="{g.artist} – {g.title} on osu!"
|
||
>
|
||
{#if g.listUrl}<img src={g.listUrl} alt="" loading="lazy" width="64" height="64" />{/if}
|
||
</a>
|
||
<div class="min-w-0">
|
||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||
<a
|
||
class="truncate font-medium hover:underline"
|
||
href="https://osu.ppy.sh/beatmaps/{g.beatmapId}"
|
||
target="_blank"
|
||
rel="noopener noreferrer">{g.artist} – {g.title}</a
|
||
>
|
||
<span class={['goal-badge', g.goal ?? 'unset', g.achievedAt && 'done']}
|
||
>{#if g.achievedAt}<CircleCheck size={12} aria-hidden="true" />{:else}<Trophy
|
||
size={12}
|
||
aria-hidden="true"
|
||
/>{/if}{goalText(g)}{#if g.mods.length} +{g.mods.join('')}{/if}</span
|
||
>
|
||
</div>
|
||
<div class="truncate text-sm text-[var(--site-muted)]">
|
||
[{g.version}] · {g.mapper}{#if g.notes}
|
||
· {g.notes}{/if}
|
||
</div>
|
||
<div class="meta mt-1.5">
|
||
{#if g.stars != null}
|
||
<span class="stars" style:--star={starColour(g.stars)}
|
||
><Star size={11} aria-hidden="true" />{nf(g.stars, 2)}</span
|
||
>
|
||
{/if}
|
||
{#if g.starsDt != null}<span class="dim" title="star rating with DT"
|
||
>DT {nf(g.starsDt, 2)}★</span
|
||
>{/if}
|
||
{#if g.starsHr != null}<span class="dim" title="star rating with HR"
|
||
>HR {nf(g.starsHr, 2)}★</span
|
||
>{/if}
|
||
<span
|
||
title={g.bpmMin != null && g.bpmMax != null && g.bpmMax - g.bpmMin >= 1
|
||
? `BPM ${nf(g.bpmMin)}–${nf(g.bpmMax)}`
|
||
: 'BPM'}><Music size={12} aria-hidden="true" />{nf(g.bpm)}</span
|
||
>
|
||
<span title="length"
|
||
><Clock size={12} aria-hidden="true" />{formatLength(g.lengthSeconds)}</span
|
||
>
|
||
<span class="dim">AR {nf(g.ar, 1)} · OD {nf(g.od, 1)} · CS {nf(g.cs, 1)}</span>
|
||
{#each g.focus as f (f)}
|
||
{@const Icon = focusIcon[f]}
|
||
<span class="focus focus-{f}"
|
||
><Icon size={11} aria-hidden="true" />{FOCUS_LABELS[f] ?? f}</span
|
||
>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
<div class="best">
|
||
{#if g.bestRank}
|
||
<a
|
||
class="flex items-center gap-2"
|
||
href="https://osu.ppy.sh/scores/{g.bestScoreId}"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
title="my best score{g.bestAt ? `, ${day(g.bestAt)}` : ''}"
|
||
>
|
||
<span class={['rank', rankClass(g.bestRank), silver(g.bestRank) && 'silver']}
|
||
>{rankLabel(g.bestRank)}</span
|
||
>
|
||
<span class="text-right leading-tight">
|
||
<span class="block font-medium tabular-nums"
|
||
>{nf(g.bestAccuracy, 2)}%{#if g.bestMods.length}<span class="mods"
|
||
>+{g.bestMods.join('')}</span
|
||
>{/if}</span
|
||
>
|
||
<span class="block text-xs text-[var(--site-muted)] tabular-nums"
|
||
>{nf(g.bestPp)}pp · {nf(g.bestCombo)}/{nf(g.maxCombo)}x{#if g.bestFc}
|
||
· FC{:else if g.bestMisses}
|
||
· {g.bestMisses}❌{/if}</span
|
||
>
|
||
</span>
|
||
</a>
|
||
{:else}
|
||
<span class="text-sm text-[var(--site-muted)]">no pass yet</span>
|
||
{/if}
|
||
{#if owner}
|
||
<form method="POST" action="?/osuGoalSet" use:enhance class="mt-1">
|
||
<input type="hidden" name="id" value={g.id} />
|
||
<select
|
||
name="goal"
|
||
class="goal-select"
|
||
aria-label="goal for {g.title}"
|
||
value={g.goal ?? ''}
|
||
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||
>
|
||
<option value="">pass / FC (not set)</option>
|
||
<option value="pass">pass</option>
|
||
<option value="fc">FC</option>
|
||
</select>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
</li>
|
||
{/each}
|
||
{#each open as g (g.id)}
|
||
<li class="goal unmatched">
|
||
<span class="thumb grid place-items-center text-[var(--site-muted)]"
|
||
><CircleHelp size={22} aria-hidden="true" /></span
|
||
>
|
||
<div class="min-w-0">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<span class="font-medium">{g.name}</span>
|
||
<span class="goal-badge unset"
|
||
><Trophy size={12} aria-hidden="true" />{goalText(
|
||
g
|
||
)}{#if g.mods.length} +{g.mods.join('')}{/if}</span
|
||
>
|
||
<span class="text-xs text-[var(--site-muted)]"
|
||
>{g.matchStatus === 'ambiguous' ? 'which difficulty?' : 'no map found'}</span
|
||
>
|
||
</div>
|
||
{#if g.matchNote}<p class="text-sm text-[var(--site-muted)]">{g.matchNote}</p>{/if}
|
||
{#if g.candidates.length}
|
||
<ul class="mt-1.5 flex flex-wrap gap-1.5">
|
||
{#each g.candidates as c (c.beatmapId)}
|
||
<li class="candidate">
|
||
<a
|
||
href="https://osu.ppy.sh/beatmaps/{c.beatmapId}"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>{c.label}{#if c.stars != null}
|
||
· {nf(c.stars, 2)}★{/if}{#if c.myPlays}
|
||
· {c.myPlays} plays{/if}<ExternalLink size={11} aria-hidden="true" /></a
|
||
>
|
||
{#if owner}
|
||
<form method="POST" action="?/osuGoalPick" use:enhance>
|
||
<input type="hidden" name="id" value={g.id} />
|
||
<input type="hidden" name="beatmapId" value={c.beatmapId} />
|
||
<button type="submit" class="pick">this one</button>
|
||
</form>
|
||
{/if}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</div>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
<p class="mt-2 text-xs text-[var(--site-muted)]">
|
||
Map data and scores from the <a
|
||
class="underline"
|
||
href="https://osu.ppy.sh/docs/"
|
||
target="_blank"
|
||
rel="noopener noreferrer">osu! API</a
|
||
>.
|
||
</p>
|
||
</section>
|
||
|
||
<style>
|
||
.icon-tile {
|
||
display: inline-grid;
|
||
place-items: center;
|
||
width: 1.9rem;
|
||
height: 1.9rem;
|
||
border-radius: 0.5rem;
|
||
color: #fff;
|
||
background-image: linear-gradient(135deg, #ff66aa, #ff3d7f);
|
||
}
|
||
.refresh,
|
||
.pick {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent);
|
||
border-radius: 999px;
|
||
padding: 0.25rem 0.75rem;
|
||
font-size: 0.8rem;
|
||
color: var(--site-muted);
|
||
transition:
|
||
color 160ms ease,
|
||
background-color 160ms ease;
|
||
}
|
||
.pick {
|
||
padding: 0.05rem 0.5rem;
|
||
font-size: 0.72rem;
|
||
}
|
||
.refresh:hover,
|
||
.pick:hover {
|
||
color: var(--foreground);
|
||
background: color-mix(in oklab, var(--foreground) 7%, transparent);
|
||
}
|
||
.goals {
|
||
display: grid;
|
||
gap: 0.5rem;
|
||
}
|
||
.goal {
|
||
display: grid;
|
||
grid-template-columns: 4rem minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 0.9rem;
|
||
padding: 0.6rem 0.9rem 0.6rem 0.6rem;
|
||
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
|
||
border-radius: 0.9rem;
|
||
background: color-mix(in oklab, var(--foreground) 2%, transparent);
|
||
transition:
|
||
background-color 160ms ease,
|
||
border-color 160ms ease;
|
||
}
|
||
.goal:hover {
|
||
background: color-mix(in oklab, var(--foreground) 5%, transparent);
|
||
border-color: color-mix(in oklab, var(--foreground) 18%, transparent);
|
||
}
|
||
.goal.achieved {
|
||
border-color: color-mix(in oklab, #22c55e 45%, transparent);
|
||
}
|
||
.goal.unmatched {
|
||
border-style: dashed;
|
||
}
|
||
.thumb {
|
||
width: 4rem;
|
||
height: 4rem;
|
||
border-radius: 0.6rem;
|
||
overflow: hidden;
|
||
background: color-mix(in oklab, var(--foreground) 6%, transparent);
|
||
}
|
||
.thumb img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.3rem 0.75rem;
|
||
font-size: 0.78rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.meta > span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
}
|
||
.meta .dim {
|
||
color: var(--site-muted);
|
||
}
|
||
.stars {
|
||
padding: 0.05rem 0.45rem;
|
||
border-radius: 999px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
background: var(--star);
|
||
text-shadow: 0 1px 1px rgb(0 0 0 / 0.35);
|
||
}
|
||
.focus {
|
||
padding: 0.05rem 0.45rem;
|
||
border-radius: 999px;
|
||
font-weight: 500;
|
||
border: 1px solid currentColor;
|
||
}
|
||
.focus-hd {
|
||
color: #f59e0b;
|
||
}
|
||
.focus-dt {
|
||
color: #a855f7;
|
||
}
|
||
.focus-streams {
|
||
color: #06b6d4;
|
||
}
|
||
.focus-fast-bpm {
|
||
color: #f43f5e;
|
||
}
|
||
.goal-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
padding: 0.05rem 0.5rem;
|
||
border-radius: 999px;
|
||
font-size: 0.72rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
border: 1px solid color-mix(in oklab, var(--foreground) 18%, transparent);
|
||
}
|
||
.goal-badge.fc {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #ff66aa, #ff3d7f);
|
||
border-color: transparent;
|
||
}
|
||
.goal-badge.pass {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||
border-color: transparent;
|
||
}
|
||
.goal-badge.unset {
|
||
color: var(--site-muted);
|
||
}
|
||
.goal-badge.done {
|
||
color: #fff;
|
||
background: linear-gradient(135deg, #22c55e, #10b981);
|
||
border-color: transparent;
|
||
}
|
||
.best {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
min-width: 9rem;
|
||
}
|
||
.rank {
|
||
display: inline-grid;
|
||
place-items: center;
|
||
width: 2rem;
|
||
height: 2rem;
|
||
border-radius: 0.5rem;
|
||
font-weight: 800;
|
||
font-size: 1rem;
|
||
color: #fff;
|
||
}
|
||
.rank-x,
|
||
.rank-s {
|
||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||
}
|
||
.rank.silver {
|
||
background: linear-gradient(135deg, #e5e7eb, #9ca3af);
|
||
color: #111;
|
||
}
|
||
.rank-a {
|
||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||
}
|
||
.rank-b {
|
||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||
}
|
||
.rank-c {
|
||
background: linear-gradient(135deg, #a855f7, #9333ea);
|
||
}
|
||
.rank-d {
|
||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||
}
|
||
.mods {
|
||
margin-left: 0.3rem;
|
||
font-size: 0.75rem;
|
||
color: var(--site-muted);
|
||
}
|
||
.goal-select {
|
||
font-size: 0.72rem;
|
||
background: transparent;
|
||
color: var(--site-muted);
|
||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||
border-radius: 0.4rem;
|
||
padding: 0.05rem 0.3rem;
|
||
}
|
||
.candidate {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
padding: 0.15rem 0.3rem 0.15rem 0.6rem;
|
||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||
border-radius: 999px;
|
||
font-size: 0.78rem;
|
||
}
|
||
.candidate a {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
}
|
||
@media (max-width: 640px) {
|
||
.goal {
|
||
grid-template-columns: 3rem minmax(0, 1fr);
|
||
}
|
||
.thumb {
|
||
width: 3rem;
|
||
height: 3rem;
|
||
}
|
||
.best {
|
||
grid-column: 2;
|
||
align-items: flex-start;
|
||
}
|
||
}
|
||
</style>
|