Skip to content

Commit 452ae84

Browse files
authored
feature: ELO badge links to competitive all-time leaderboard (#352)
1 parent 026f550 commit 452ae84

5 files changed

Lines changed: 330 additions & 88 deletions

File tree

components/PlayerElo.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
<script lang="ts" setup>
2+
import { useMediaQuery } from "@vueuse/core";
23
import {
34
HoverCard,
45
HoverCardTrigger,
56
HoverCardContent,
67
} from "~/components/ui/hover-card";
8+
9+
// Touch surfaces have no usable hover; suppress the popover so a tap
10+
// doesn't leave a sticky card open over the row underneath.
11+
const isMobile = useMediaQuery("(max-width: 768px)");
712
</script>
813

914
<template>
@@ -29,6 +34,7 @@ import {
2934
</HoverCardTrigger>
3035

3136
<HoverCardContent
37+
v-if="!isMobile"
3238
:side-offset="8"
3339
:collision-padding="12"
3440
:class="cardClasses"

components/PlayerLeaderboardRank.vue

Lines changed: 25 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@
22
import { ref, watch, computed, onBeforeUnmount } from "vue";
33
import gql from "graphql-tag";
44
import { useApolloClient } from "@vue/apollo-composable";
5-
import {
6-
HoverCard,
7-
HoverCardTrigger,
8-
HoverCardContent,
9-
} from "~/components/ui/hover-card";
105
116
const props = defineProps<{
127
playerSteamId: string;
@@ -119,75 +114,32 @@ const rankValueClasses =
119114
120115
const percentileClasses =
121116
"font-mono text-[0.6rem] font-semibold uppercase tabular-nums tracking-[0.14em] text-[hsl(var(--tac-amber)/0.85)]";
122-
123-
const cardClasses = [
124-
"relative w-[260px] p-0 overflow-hidden",
125-
"border border-border/80 rounded-none",
126-
"bg-[hsl(240_8%_10%)] text-[hsl(var(--popover-foreground))]",
127-
"[font-family:'Oxanium',sans-serif]",
128-
"shadow-[inset_0_1px_0_hsl(0_0%_100%/0.04),0_0_0_1px_hsl(var(--tac-amber)/0.08),0_18px_40px_-12px_hsl(0_0%_0%/0.85)]",
129-
"[background-image:radial-gradient(circle_at_top_right,hsl(var(--tac-amber)/0.10)_0%,transparent_55%),linear-gradient(180deg,hsl(0_0%_100%/0.02)_0%,transparent_60%)]",
130-
].join(" ");
131-
132-
const cardHeaderClasses =
133-
"flex items-center justify-between px-3 pt-2.5 pb-2 border-b border-border/60 font-mono text-[0.6rem] font-semibold uppercase tracking-[0.26em] text-muted-foreground";
134-
135-
const cardHeaderAccent = "text-[hsl(var(--tac-amber))]";
136-
137-
const cardRowClasses =
138-
"flex items-center justify-between px-3 py-2 font-mono text-[0.7rem] uppercase tracking-[0.14em]";
139-
140-
const cardRowLabel = "text-muted-foreground";
141-
142-
const cardRowValue =
143-
"tabular-nums text-[hsl(var(--tac-amber))] [text-shadow:0_0_8px_hsl(var(--tac-amber)/0.35)]";
144117
</script>
145118

146119
<template>
147-
<HoverCard v-if="rankLabel" :open-delay="80" :close-delay="140">
148-
<HoverCardTrigger as-child>
149-
<button
150-
type="button"
151-
:class="triggerClasses"
152-
:aria-label="`${$t('pages.players.detail.global_rank')} ${rankLabel}`"
153-
>
154-
<span :class="notchClasses" aria-hidden="true"></span>
155-
<span :class="labelClasses">{{ $t("pages.players.detail.rank") }}</span>
156-
<span :class="sepClasses" aria-hidden="true"></span>
157-
<span :class="rankValueClasses">{{ rankLabel }}</span>
158-
<span v-if="percentile" :class="sepClasses" aria-hidden="true"></span>
159-
<span v-if="percentile" :class="percentileClasses">
160-
{{ $t("pages.players.detail.top_percent", { percent: percentile }) }}
161-
</span>
162-
</button>
163-
</HoverCardTrigger>
164-
<HoverCardContent
165-
:side-offset="8"
166-
:collision-padding="12"
167-
:class="cardClasses"
168-
>
169-
<header :class="cardHeaderClasses">
170-
<span>
171-
<span :class="cardHeaderAccent">◢</span>
172-
{{ $t("pages.players.detail.global_rank") }}
173-
</span>
174-
<span :class="cardHeaderAccent">
175-
{{ $t("pages.leaderboard.match_types.competitive") }}
176-
</span>
177-
</header>
178-
<div :class="cardRowClasses">
179-
<span :class="cardRowLabel">
180-
{{ $t("pages.leaderboard.time_periods.all_time") }}
181-
</span>
182-
<span :class="cardRowValue">
183-
{{
184-
$t("pages.players.detail.rank_of_total", {
185-
rank: rank!.toLocaleString(),
186-
total: total!.toLocaleString(),
187-
})
188-
}}
189-
</span>
190-
</div>
191-
</HoverCardContent>
192-
</HoverCard>
120+
<NuxtLink
121+
v-if="rankLabel"
122+
:to="{
123+
path: '/leaderboard',
124+
query: {
125+
tab: 'elo',
126+
period: '0',
127+
type: 'Competitive',
128+
player: playerSteamId,
129+
},
130+
}"
131+
:class="triggerClasses"
132+
:aria-label="`${$t('pages.players.detail.global_rank')} ${rankLabel}`"
133+
:title="$t('pages.players.detail.global_rank')"
134+
@click.stop
135+
>
136+
<span :class="notchClasses" aria-hidden="true"></span>
137+
<span :class="labelClasses">{{ $t("pages.players.detail.rank") }}</span>
138+
<span :class="sepClasses" aria-hidden="true"></span>
139+
<span :class="rankValueClasses">{{ rankLabel }}</span>
140+
<span v-if="percentile" :class="sepClasses" aria-hidden="true"></span>
141+
<span v-if="percentile" :class="percentileClasses">
142+
{{ $t("pages.players.detail.top_percent", { percent: percentile }) }}
143+
</span>
144+
</NuxtLink>
193145
</template>

components/clips/ClipPlayer.vue

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,24 @@ function clearControlsTimer() {
7575
controlsHideTimer = null;
7676
}
7777
}
78+
// Always schedule a hide when playing, and re-check at fire time. If the
79+
// intro grace is still up when the timer expires, reschedule rather than
80+
// hide. This is self-correcting on mobile, where the @play / intro-watch
81+
// ordering isn't deterministic enough to rely on the showIntroOverlay
82+
// watcher chain to kick off the hide.
7883
function bumpControls() {
7984
controlsVisible.value = true;
8085
clearControlsTimer();
81-
if (playing.value && !showIntroOverlay.value) {
82-
controlsHideTimer = setTimeout(() => {
83-
controlsVisible.value = false;
84-
controlsHideTimer = null;
85-
}, CONTROLS_HIDE_DELAY);
86-
}
86+
if (!playing.value) return;
87+
controlsHideTimer = setTimeout(() => {
88+
controlsHideTimer = null;
89+
if (!playing.value) return;
90+
if (showIntroOverlay.value) {
91+
bumpControls();
92+
return;
93+
}
94+
controlsVisible.value = false;
95+
}, CONTROLS_HIDE_DELAY);
8796
}
8897
function hideControls() {
8998
clearControlsTimer();
@@ -317,6 +326,7 @@ defineExpose({ play, pause, toggle, videoEl: videoRef, isFullscreen });
317326
"
318327
@mousemove="bumpControls"
319328
@mouseleave="hideControls"
329+
@touchstart="bumpControls"
320330
>
321331
<template #video>
322332
<video

pages/highlights/index.vue

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Clapperboard,
1111
ListVideo,
1212
Calendar,
13+
Crosshair,
1314
User,
1415
X,
1516
} from "lucide-vue-next";
@@ -88,6 +89,15 @@ const SINCE_OPTIONS: Array<{ value: SincePreset; label: string }> = [
8889
{ value: "90d", label: "Last 90 days" },
8990
];
9091
92+
type KillsPreset = "any" | "2" | "3" | "4" | "5";
93+
const KILLS_OPTIONS: Array<{ value: KillsPreset; label: string }> = [
94+
{ value: "any", label: "Any kills" },
95+
{ value: "2", label: "2+ kills" },
96+
{ value: "3", label: "3+ kills" },
97+
{ value: "4", label: "4+ kills" },
98+
{ value: "5", label: "5K (Ace)" },
99+
];
100+
91101
const playerFilter = computed<string | null>(() => {
92102
const v = route.query.player;
93103
return typeof v === "string" && v.length > 0 ? v : null;
@@ -100,6 +110,14 @@ const sinceFilter = computed<SincePreset>(() => {
100110
}
101111
return "all";
102112
});
113+
const killsFilter = computed<KillsPreset>(() => {
114+
const v = route.query.kills;
115+
if (typeof v === "string") {
116+
const match = KILLS_OPTIONS.find((o) => o.value === v);
117+
if (match) return match.value;
118+
}
119+
return "any";
120+
});
103121
104122
function setSince(v: SincePreset) {
105123
const next = { ...route.query } as Record<string, any>;
@@ -108,6 +126,13 @@ function setSince(v: SincePreset) {
108126
router.replace({ path: route.path, query: next, hash: route.hash });
109127
}
110128
129+
function setKills(v: KillsPreset) {
130+
const next = { ...route.query } as Record<string, any>;
131+
if (v === "any") delete next.kills;
132+
else next.kills = v;
133+
router.replace({ path: route.path, query: next, hash: route.hash });
134+
}
135+
111136
function clearPlayer() {
112137
pickedPlayerName.value = null;
113138
const next = { ...route.query } as Record<string, any>;
@@ -209,6 +234,11 @@ function subscribe() {
209234
if (cutoff) {
210235
conditions.push({ created_at: { _gte: cutoff } });
211236
}
237+
if (killsFilter.value !== "any") {
238+
conditions.push({
239+
kills_count: { _gte: parseInt(killsFilter.value, 10) },
240+
});
241+
}
212242
const where = conditions.length === 0 ? {} : { _and: conditions };
213243
const obs = getGraphqlClient().subscribe({
214244
query: generateSubscription({
@@ -235,7 +265,7 @@ function subscribe() {
235265
}
236266
subscribe();
237267
238-
watch([playerFilter, sinceFilter, isAdmin], () => subscribe());
268+
watch([playerFilter, sinceFilter, killsFilter, isAdmin], () => subscribe());
239269
onBeforeUnmount(() => {
240270
activeSub?.unsubscribe();
241271
pendingSub?.unsubscribe();
@@ -290,11 +320,24 @@ const hasClips = computed(() => filteredClips.value.length > 0);
290320
291321
// Multi-clip matches collapse to a MatchClipsGroupCard, singletons
292322
// stay as HighlightCards. Subscription capped at 200, so client-side
293-
// grouping is fine.
323+
// grouping is fine. When the user has filtered to a specific player,
324+
// skip grouping entirely — they're browsing that player's individual
325+
// plays, not the match-by-match digest.
294326
type GridItem =
295327
| { kind: "single"; clip: Clip; sortKey: string }
296328
| { kind: "group"; matchId: string; clips: Clip[]; sortKey: string };
297329
const gridItems = computed<GridItem[]>(() => {
330+
if (playerFilter.value) {
331+
return filteredClips.value
332+
.map<GridItem>((clip) => ({
333+
kind: "single",
334+
clip,
335+
sortKey: clip.created_at,
336+
}))
337+
.sort((a, b) =>
338+
a.sortKey < b.sortKey ? 1 : a.sortKey > b.sortKey ? -1 : 0,
339+
);
340+
}
298341
const byMatch = new Map<string, Clip[]>();
299342
const orphans: Clip[] = [];
300343
for (const c of filteredClips.value) {
@@ -491,6 +534,39 @@ const adminFilters: Array<{ value: Filter; label: string; icon?: any }> = [
491534
</SelectContent>
492535
</Select>
493536

537+
<Select
538+
:model-value="killsFilter"
539+
@update:model-value="(v) => setKills(v as KillsPreset)"
540+
>
541+
<SelectTrigger
542+
class="h-8 w-auto min-w-[9rem] gap-2 rounded-full border-border/60 bg-muted/30 px-3 text-xs"
543+
:class="
544+
killsFilter !== 'any'
545+
? 'border-[hsl(var(--tac-amber)/0.5)] bg-[hsl(var(--tac-amber)/0.12)] text-[hsl(var(--tac-amber))]'
546+
: ''
547+
"
548+
>
549+
<Crosshair
550+
class="h-3.5 w-3.5"
551+
:class="
552+
killsFilter !== 'any'
553+
? 'text-[hsl(var(--tac-amber))]'
554+
: 'text-muted-foreground'
555+
"
556+
/>
557+
<SelectValue />
558+
</SelectTrigger>
559+
<SelectContent>
560+
<SelectItem
561+
v-for="opt in KILLS_OPTIONS"
562+
:key="opt.value"
563+
:value="opt.value"
564+
>
565+
{{ opt.label }}
566+
</SelectItem>
567+
</SelectContent>
568+
</Select>
569+
494570
<span
495571
v-if="!loading"
496572
class="ml-auto font-mono text-[0.62rem] uppercase tracking-[0.16em] text-muted-foreground tabular-nums"
@@ -517,14 +593,16 @@ const adminFilters: Array<{ value: Filter; label: string; icon?: any }> = [
517593
<Empty>
518594
<EmptyTitle>
519595
{{
520-
playerFilter || sinceFilter !== "all"
596+
playerFilter || sinceFilter !== "all" || killsFilter !== "any"
521597
? "No clips match these filters"
522598
: "No clips here yet"
523599
}}
524600
</EmptyTitle>
525601
<EmptyDescription>
526-
<template v-if="playerFilter || sinceFilter !== 'all'">
527-
Try widening the date range or clearing the player filter.
602+
<template
603+
v-if="playerFilter || sinceFilter !== 'all' || killsFilter !== 'any'"
604+
>
605+
Try widening the filters or clearing one.
528606
</template>
529607
<template v-else-if="isAdmin">
530608
Try a different filter — or wait for new clips to render.
@@ -534,7 +612,7 @@ const adminFilters: Array<{ value: Filter; label: string; icon?: any }> = [
534612
</template>
535613
</EmptyDescription>
536614
<div
537-
v-if="playerFilter || sinceFilter !== 'all'"
615+
v-if="playerFilter || sinceFilter !== 'all' || killsFilter !== 'any'"
538616
class="mt-3 flex flex-wrap items-center justify-center gap-2"
539617
>
540618
<button
@@ -555,6 +633,15 @@ const adminFilters: Array<{ value: Filter; label: string; icon?: any }> = [
555633
<X class="h-3 w-3" />
556634
Clear date
557635
</button>
636+
<button
637+
v-if="killsFilter !== 'any'"
638+
type="button"
639+
:class="[tacticalFilterPillClasses]"
640+
@click="setKills('any')"
641+
>
642+
<X class="h-3 w-3" />
643+
Clear kills
644+
</button>
558645
</div>
559646
</Empty>
560647
</PageTransition>

0 commit comments

Comments
 (0)