Spaces:
Running
Running
Rich data table on home page, interactive bigger minimap, tactical-reveal player swap
Browse files- Replace home-page card grid with a TanStack data table (Rounds default / Maps / Matches via Tabs); global search, map filter, sort, pagination, column-visibility, deep-link to specific round
- Minimap: bigger badges + wider/fainter vision cones, halo on the active POV, click-to-switch with hover/focus ring (works in single + grid)
- Player swap tactical reveal: diagonal beam sweep, corner reticle brackets, edge vignette, HUD chip slide-in (bottom-left), and a clip-path wipe on the freeze-frame so the old POV transitions out instead of snapping
- Don't echo currentTime=0 from the freshly-mounted <video> back to the parent — kept the minimap and timeline from flashing to round-start during a swap; also don't drop bufferedRanges
- src/lib/components/map-preview.svelte +130 -16
- src/lib/components/match-map.svelte +14 -2
- src/lib/components/match-table/cells/date-cell.svelte +10 -0
- src/lib/components/match-table/cells/duration-cell.svelte +10 -0
- src/lib/components/match-table/cells/event-cell.svelte +14 -0
- src/lib/components/match-table/cells/map-cell.svelte +15 -0
- src/lib/components/match-table/cells/maps-list-cell.svelte +19 -0
- src/lib/components/match-table/cells/round-cell.svelte +12 -0
- src/lib/components/match-table/cells/rounds-played-cell.svelte +13 -0
- src/lib/components/match-table/cells/score-cell.svelte +17 -0
- src/lib/components/match-table/cells/teams-cell.svelte +31 -0
- src/lib/components/match-table/cells/winner-side-cell.svelte +26 -0
- src/lib/components/match-table/columns.ts +259 -0
- src/lib/components/match-table/data-table-pagination.svelte +93 -0
- src/lib/components/match-table/data-table-plain-header.svelte +21 -0
- src/lib/components/match-table/data-table-sort-header.svelte +43 -0
- src/lib/components/match-table/data-table-toolbar.svelte +101 -0
- src/lib/components/match-table/match-table.svelte +176 -0
- src/lib/components/match-table/rows.ts +129 -0
- src/lib/components/video-player/video-player.svelte +59 -2
- src/lib/components/video-stage.svelte +205 -21
- src/lib/utils/format.ts +10 -0
- src/routes/+page.svelte +81 -15
- src/routes/match/[matchId]/[mapName]/+page.svelte +6 -1
src/lib/components/map-preview.svelte
CHANGED
|
@@ -30,12 +30,24 @@
|
|
| 30 |
type Props = {
|
| 31 |
mapName: string;
|
| 32 |
players: Player[];
|
|
|
|
|
|
|
|
|
|
| 33 |
size?: number;
|
| 34 |
floor?: 'upper' | 'lower' | 'auto';
|
| 35 |
class?: string;
|
| 36 |
};
|
| 37 |
|
| 38 |
-
let {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
let allMaps = $state<Record<string, MapData> | null>(null);
|
| 41 |
let userFloor = $state<Floor | null>(null);
|
|
@@ -84,7 +96,7 @@
|
|
| 84 |
<img
|
| 85 |
src={radarUrl(mapName, displayedFloor)}
|
| 86 |
alt="{mapName} radar"
|
| 87 |
-
class="absolute inset-0 h-full w-full select-none rounded-md"
|
| 88 |
draggable="false"
|
| 89 |
/>
|
| 90 |
<svg
|
|
@@ -96,27 +108,96 @@
|
|
| 96 |
{@const { px, py } = worldToImage(map, p.X, p.Y)}
|
| 97 |
{@const playerFloor = floorForZ(map, p.Z)}
|
| 98 |
{@const onDisplayed = !lowerAvailable || playerFloor === displayedFloor}
|
| 99 |
-
{@const baseOpacity = onDisplayed ? 1 : 0.
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
<title>{p.name}{p.slot !== undefined ? ` (#${p.slot})` : ''}</title>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
{#if p.is_alive}
|
| 103 |
{#if p.yaw !== undefined}
|
|
|
|
|
|
|
| 104 |
<polygon
|
| 105 |
-
points="0,-
|
| 106 |
fill={p.color}
|
| 107 |
-
fill-opacity="0.
|
| 108 |
stroke={p.color}
|
| 109 |
-
stroke-
|
|
|
|
| 110 |
transform="translate({px} {py}) rotate({yawToScreenDeg(p.yaw)})"
|
| 111 |
/>
|
| 112 |
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
<circle
|
| 114 |
cx={px}
|
| 115 |
cy={py}
|
| 116 |
-
r
|
| 117 |
fill={p.color}
|
| 118 |
stroke="#0b0f14"
|
| 119 |
-
stroke-width=
|
| 120 |
/>
|
| 121 |
{#if p.slot !== undefined}
|
| 122 |
<text
|
|
@@ -124,11 +205,11 @@
|
|
| 124 |
y={py}
|
| 125 |
text-anchor="middle"
|
| 126 |
dominant-baseline="central"
|
| 127 |
-
font-size=
|
| 128 |
-
font-weight="
|
| 129 |
fill="#fff"
|
| 130 |
stroke="#0b0f14"
|
| 131 |
-
stroke-width="0.
|
| 132 |
paint-order="stroke"
|
| 133 |
style="pointer-events: none; user-select: none;"
|
| 134 |
>
|
|
@@ -140,12 +221,12 @@
|
|
| 140 |
opacity={baseOpacity * 0.55}
|
| 141 |
transform="translate({px} {py})"
|
| 142 |
stroke={p.color}
|
| 143 |
-
stroke-width="
|
| 144 |
fill="none"
|
| 145 |
>
|
| 146 |
-
<circle r="
|
| 147 |
-
<line x1="-
|
| 148 |
-
<line x1="-
|
| 149 |
</g>
|
| 150 |
{/if}
|
| 151 |
</g>
|
|
@@ -177,3 +258,36 @@
|
|
| 177 |
{/if}
|
| 178 |
{/if}
|
| 179 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
type Props = {
|
| 31 |
mapName: string;
|
| 32 |
players: Player[];
|
| 33 |
+
activePlayer?: number | null;
|
| 34 |
+
availablePlayers?: Set<number> | null;
|
| 35 |
+
onSelect?: (player: number) => void;
|
| 36 |
size?: number;
|
| 37 |
floor?: 'upper' | 'lower' | 'auto';
|
| 38 |
class?: string;
|
| 39 |
};
|
| 40 |
|
| 41 |
+
let {
|
| 42 |
+
mapName,
|
| 43 |
+
players,
|
| 44 |
+
activePlayer = null,
|
| 45 |
+
availablePlayers = null,
|
| 46 |
+
onSelect,
|
| 47 |
+
size,
|
| 48 |
+
floor = 'auto',
|
| 49 |
+
class: className = ''
|
| 50 |
+
}: Props = $props();
|
| 51 |
|
| 52 |
let allMaps = $state<Record<string, MapData> | null>(null);
|
| 53 |
let userFloor = $state<Floor | null>(null);
|
|
|
|
| 96 |
<img
|
| 97 |
src={radarUrl(mapName, displayedFloor)}
|
| 98 |
alt="{mapName} radar"
|
| 99 |
+
class="absolute inset-0 h-full w-full select-none rounded-md opacity-70"
|
| 100 |
draggable="false"
|
| 101 |
/>
|
| 102 |
<svg
|
|
|
|
| 108 |
{@const { px, py } = worldToImage(map, p.X, p.Y)}
|
| 109 |
{@const playerFloor = floorForZ(map, p.Z)}
|
| 110 |
{@const onDisplayed = !lowerAvailable || playerFloor === displayedFloor}
|
| 111 |
+
{@const baseOpacity = onDisplayed ? 1 : 0.35}
|
| 112 |
+
{@const isActive = activePlayer !== null && p.slot === activePlayer}
|
| 113 |
+
{@const r = isActive ? 42 : 32}
|
| 114 |
+
{@const fontSize = isActive ? 38 : 30}
|
| 115 |
+
{@const coneLen = isActive ? 110 : 90}
|
| 116 |
+
{@const coneHalf = isActive ? 38 : 32}
|
| 117 |
+
{@const slotKnown = p.slot !== undefined}
|
| 118 |
+
{@const available =
|
| 119 |
+
!availablePlayers || (slotKnown && availablePlayers.has(p.slot as number))}
|
| 120 |
+
{@const clickable = !!onSelect && slotKnown && available}
|
| 121 |
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
| 122 |
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
| 123 |
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
| 124 |
+
<g
|
| 125 |
+
class="mp-marker"
|
| 126 |
+
class:mp-marker--clickable={clickable}
|
| 127 |
+
class:mp-marker--unavailable={!!onSelect && slotKnown && !available}
|
| 128 |
+
class:mp-marker--active={isActive}
|
| 129 |
+
opacity={baseOpacity}
|
| 130 |
+
role={clickable ? 'button' : undefined}
|
| 131 |
+
tabindex={clickable ? 0 : undefined}
|
| 132 |
+
aria-label={slotKnown ? `Switch to player ${p.slot}` : undefined}
|
| 133 |
+
onclick={clickable ? () => onSelect?.(p.slot as number) : undefined}
|
| 134 |
+
onkeydown={clickable
|
| 135 |
+
? (e: KeyboardEvent) => {
|
| 136 |
+
if (e.key === 'Enter' || e.key === ' ') {
|
| 137 |
+
e.preventDefault();
|
| 138 |
+
onSelect?.(p.slot as number);
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
: undefined}
|
| 142 |
+
>
|
| 143 |
<title>{p.name}{p.slot !== undefined ? ` (#${p.slot})` : ''}</title>
|
| 144 |
+
{#if clickable || isActive}
|
| 145 |
+
<!-- Hit-target + hover ring. Transparent stroke by default; the
|
| 146 |
+
:hover style flips it on. Larger than the badge so the
|
| 147 |
+
click area is generous. -->
|
| 148 |
+
<circle
|
| 149 |
+
class="mp-marker__hit"
|
| 150 |
+
cx={px}
|
| 151 |
+
cy={py}
|
| 152 |
+
r={r + 14}
|
| 153 |
+
fill="transparent"
|
| 154 |
+
stroke={p.color}
|
| 155 |
+
stroke-opacity="0"
|
| 156 |
+
stroke-width="3"
|
| 157 |
+
/>
|
| 158 |
+
{/if}
|
| 159 |
{#if p.is_alive}
|
| 160 |
{#if p.yaw !== undefined}
|
| 161 |
+
<!-- Vision cone: wide + faint fill, soft edge. Reads at a
|
| 162 |
+
glance without overpowering the badge. -->
|
| 163 |
<polygon
|
| 164 |
+
points="0,-{coneHalf} {coneLen},0 0,{coneHalf}"
|
| 165 |
fill={p.color}
|
| 166 |
+
fill-opacity="0.18"
|
| 167 |
stroke={p.color}
|
| 168 |
+
stroke-opacity="0.55"
|
| 169 |
+
stroke-width="2"
|
| 170 |
transform="translate({px} {py}) rotate({yawToScreenDeg(p.yaw)})"
|
| 171 |
/>
|
| 172 |
{/if}
|
| 173 |
+
{#if isActive}
|
| 174 |
+
<!-- Active player halo: a white inner ring + colored outer ring. -->
|
| 175 |
+
<circle
|
| 176 |
+
cx={px}
|
| 177 |
+
cy={py}
|
| 178 |
+
r={r + 7}
|
| 179 |
+
fill="none"
|
| 180 |
+
stroke="#fff"
|
| 181 |
+
stroke-width="2.5"
|
| 182 |
+
stroke-opacity="0.9"
|
| 183 |
+
/>
|
| 184 |
+
<circle
|
| 185 |
+
cx={px}
|
| 186 |
+
cy={py}
|
| 187 |
+
r={r + 12}
|
| 188 |
+
fill="none"
|
| 189 |
+
stroke={p.color}
|
| 190 |
+
stroke-width="2.5"
|
| 191 |
+
stroke-opacity="0.75"
|
| 192 |
+
/>
|
| 193 |
+
{/if}
|
| 194 |
<circle
|
| 195 |
cx={px}
|
| 196 |
cy={py}
|
| 197 |
+
{r}
|
| 198 |
fill={p.color}
|
| 199 |
stroke="#0b0f14"
|
| 200 |
+
stroke-width={isActive ? 4 : 3}
|
| 201 |
/>
|
| 202 |
{#if p.slot !== undefined}
|
| 203 |
<text
|
|
|
|
| 205 |
y={py}
|
| 206 |
text-anchor="middle"
|
| 207 |
dominant-baseline="central"
|
| 208 |
+
font-size={fontSize}
|
| 209 |
+
font-weight="800"
|
| 210 |
fill="#fff"
|
| 211 |
stroke="#0b0f14"
|
| 212 |
+
stroke-width="0.9"
|
| 213 |
paint-order="stroke"
|
| 214 |
style="pointer-events: none; user-select: none;"
|
| 215 |
>
|
|
|
|
| 221 |
opacity={baseOpacity * 0.55}
|
| 222 |
transform="translate({px} {py})"
|
| 223 |
stroke={p.color}
|
| 224 |
+
stroke-width="3.5"
|
| 225 |
fill="none"
|
| 226 |
>
|
| 227 |
+
<circle r="18" />
|
| 228 |
+
<line x1="-10" y1="-10" x2="10" y2="10" />
|
| 229 |
+
<line x1="-10" y1="10" x2="10" y2="-10" />
|
| 230 |
</g>
|
| 231 |
{/if}
|
| 232 |
</g>
|
|
|
|
| 258 |
{/if}
|
| 259 |
{/if}
|
| 260 |
</div>
|
| 261 |
+
|
| 262 |
+
<style>
|
| 263 |
+
.mp-marker--clickable {
|
| 264 |
+
cursor: pointer;
|
| 265 |
+
}
|
| 266 |
+
.mp-marker--unavailable {
|
| 267 |
+
cursor: not-allowed;
|
| 268 |
+
}
|
| 269 |
+
/* Hover & focus on a clickable marker: light up the hit-ring and lift the
|
| 270 |
+
group with a subtle drop-shadow so it feels selectable. */
|
| 271 |
+
.mp-marker--clickable:hover .mp-marker__hit,
|
| 272 |
+
.mp-marker--clickable:focus-visible .mp-marker__hit {
|
| 273 |
+
stroke-opacity: 0.9;
|
| 274 |
+
}
|
| 275 |
+
.mp-marker--clickable {
|
| 276 |
+
transition: filter 120ms ease-out;
|
| 277 |
+
}
|
| 278 |
+
.mp-marker--clickable:hover,
|
| 279 |
+
.mp-marker--clickable:focus-visible {
|
| 280 |
+
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
|
| 281 |
+
}
|
| 282 |
+
.mp-marker:focus {
|
| 283 |
+
outline: none;
|
| 284 |
+
}
|
| 285 |
+
.mp-marker:focus-visible {
|
| 286 |
+
outline: none;
|
| 287 |
+
}
|
| 288 |
+
@media (prefers-reduced-motion: reduce) {
|
| 289 |
+
.mp-marker--clickable {
|
| 290 |
+
transition: none;
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
</style>
|
src/lib/components/match-map.svelte
CHANGED
|
@@ -11,9 +11,21 @@
|
|
| 11 |
round: number;
|
| 12 |
chunks: PreviewChunk[];
|
| 13 |
virtualTime: number;
|
|
|
|
|
|
|
|
|
|
| 14 |
};
|
| 15 |
|
| 16 |
-
let {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
let world = $state<RoundWorld | null>(null);
|
| 19 |
let error = $state<string | null>(null);
|
|
@@ -70,5 +82,5 @@
|
|
| 70 |
{:else if !world}
|
| 71 |
<Skeleton class="aspect-square w-full rounded-md" />
|
| 72 |
{:else}
|
| 73 |
-
<MapPreview {mapName} {players} />
|
| 74 |
{/if}
|
|
|
|
| 11 |
round: number;
|
| 12 |
chunks: PreviewChunk[];
|
| 13 |
virtualTime: number;
|
| 14 |
+
activePlayer?: number | null;
|
| 15 |
+
availablePlayers?: Set<number> | null;
|
| 16 |
+
onSelect?: (player: number) => void;
|
| 17 |
};
|
| 18 |
|
| 19 |
+
let {
|
| 20 |
+
matchId,
|
| 21 |
+
mapName,
|
| 22 |
+
round,
|
| 23 |
+
chunks,
|
| 24 |
+
virtualTime,
|
| 25 |
+
activePlayer = null,
|
| 26 |
+
availablePlayers = null,
|
| 27 |
+
onSelect
|
| 28 |
+
}: Props = $props();
|
| 29 |
|
| 30 |
let world = $state<RoundWorld | null>(null);
|
| 31 |
let error = $state<string | null>(null);
|
|
|
|
| 82 |
{:else if !world}
|
| 83 |
<Skeleton class="aspect-square w-full rounded-md" />
|
| 84 |
{:else}
|
| 85 |
+
<MapPreview {mapName} {players} {activePlayer} {availablePlayers} {onSelect} />
|
| 86 |
{/if}
|
src/lib/components/match-table/cells/date-cell.svelte
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { formatDate } from '$lib/utils/format';
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
iso: string;
|
| 6 |
+
}
|
| 7 |
+
let { iso }: Props = $props();
|
| 8 |
+
</script>
|
| 9 |
+
|
| 10 |
+
<span class="text-muted-foreground text-xs whitespace-nowrap">{formatDate(iso)}</span>
|
src/lib/components/match-table/cells/duration-cell.svelte
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { formatLongDuration } from '$lib/utils/format';
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
seconds: number;
|
| 6 |
+
}
|
| 7 |
+
let { seconds }: Props = $props();
|
| 8 |
+
</script>
|
| 9 |
+
|
| 10 |
+
<span class="font-mono tabular-nums text-xs text-muted-foreground">{formatLongDuration(seconds)}</span>
|
src/lib/components/match-table/cells/event-cell.svelte
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
interface Props {
|
| 3 |
+
event: string;
|
| 4 |
+
format?: string;
|
| 5 |
+
}
|
| 6 |
+
let { event, format }: Props = $props();
|
| 7 |
+
</script>
|
| 8 |
+
|
| 9 |
+
<div class="flex min-w-0 flex-col">
|
| 10 |
+
<span class="truncate text-sm font-medium" title={event}>{event}</span>
|
| 11 |
+
{#if format}
|
| 12 |
+
<span class="text-muted-foreground/70 text-[0.65rem] uppercase tracking-wide">{format}</span>
|
| 13 |
+
{/if}
|
| 14 |
+
</div>
|
src/lib/components/match-table/cells/map-cell.svelte
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { Badge } from '$lib/components/ui/badge';
|
| 3 |
+
import MapPin from 'phosphor-svelte/lib/MapPin';
|
| 4 |
+
import { mapColorClasses } from '$lib/utils/map-colors';
|
| 5 |
+
import { prettyMap } from '$lib/utils/format';
|
| 6 |
+
|
| 7 |
+
interface Props {
|
| 8 |
+
map: string;
|
| 9 |
+
}
|
| 10 |
+
let { map }: Props = $props();
|
| 11 |
+
</script>
|
| 12 |
+
|
| 13 |
+
<Badge class="gap-1 border capitalize {mapColorClasses(map)}">
|
| 14 |
+
<MapPin size={11} weight="duotone" />{prettyMap(map)}
|
| 15 |
+
</Badge>
|
src/lib/components/match-table/cells/maps-list-cell.svelte
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { Badge } from '$lib/components/ui/badge';
|
| 3 |
+
import MapPin from 'phosphor-svelte/lib/MapPin';
|
| 4 |
+
import { mapColorClasses } from '$lib/utils/map-colors';
|
| 5 |
+
import { prettyMap } from '$lib/utils/format';
|
| 6 |
+
|
| 7 |
+
interface Props {
|
| 8 |
+
maps: string[];
|
| 9 |
+
}
|
| 10 |
+
let { maps }: Props = $props();
|
| 11 |
+
</script>
|
| 12 |
+
|
| 13 |
+
<div class="flex flex-wrap items-center gap-1">
|
| 14 |
+
{#each maps as map (map)}
|
| 15 |
+
<Badge class="gap-1 border capitalize text-[0.65rem] {mapColorClasses(map)}">
|
| 16 |
+
<MapPin size={10} weight="duotone" />{prettyMap(map)}
|
| 17 |
+
</Badge>
|
| 18 |
+
{/each}
|
| 19 |
+
</div>
|
src/lib/components/match-table/cells/round-cell.svelte
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
interface Props {
|
| 3 |
+
round: number;
|
| 4 |
+
total: number;
|
| 5 |
+
}
|
| 6 |
+
let { round, total }: Props = $props();
|
| 7 |
+
</script>
|
| 8 |
+
|
| 9 |
+
<div class="font-mono text-xs tabular-nums">
|
| 10 |
+
<span class="text-foreground">{round}</span>
|
| 11 |
+
<span class="text-muted-foreground/60">/{total}</span>
|
| 12 |
+
</div>
|
src/lib/components/match-table/cells/rounds-played-cell.svelte
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import FilmSlate from 'phosphor-svelte/lib/FilmSlate';
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
rounds: number;
|
| 6 |
+
}
|
| 7 |
+
let { rounds }: Props = $props();
|
| 8 |
+
</script>
|
| 9 |
+
|
| 10 |
+
<span class="text-muted-foreground inline-flex items-center gap-1 font-mono text-xs tabular-nums">
|
| 11 |
+
<FilmSlate size={11} weight="duotone" />
|
| 12 |
+
{rounds}
|
| 13 |
+
</span>
|
src/lib/components/match-table/cells/score-cell.svelte
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
interface Props {
|
| 3 |
+
score1: number;
|
| 4 |
+
score2: number;
|
| 5 |
+
winner: string;
|
| 6 |
+
}
|
| 7 |
+
let { score1, score2, winner }: Props = $props();
|
| 8 |
+
|
| 9 |
+
const team1Wins = $derived(winner === 'team1');
|
| 10 |
+
const team2Wins = $derived(winner === 'team2');
|
| 11 |
+
</script>
|
| 12 |
+
|
| 13 |
+
<div class="flex items-center justify-center gap-1 font-mono text-sm tabular-nums">
|
| 14 |
+
<span class={team1Wins ? 'text-primary font-semibold' : 'text-muted-foreground'}>{score1}</span>
|
| 15 |
+
<span class="text-muted-foreground/40 text-xs">:</span>
|
| 16 |
+
<span class={team2Wins ? 'text-primary font-semibold' : 'text-muted-foreground'}>{score2}</span>
|
| 17 |
+
</div>
|
src/lib/components/match-table/cells/teams-cell.svelte
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
interface Props {
|
| 3 |
+
team1: string;
|
| 4 |
+
team2: string;
|
| 5 |
+
winner: string;
|
| 6 |
+
}
|
| 7 |
+
let { team1, team2, winner }: Props = $props();
|
| 8 |
+
|
| 9 |
+
const team1Wins = $derived(winner === 'team1');
|
| 10 |
+
const team2Wins = $derived(winner === 'team2');
|
| 11 |
+
</script>
|
| 12 |
+
|
| 13 |
+
<div class="flex min-w-0 items-center gap-1.5 text-xs">
|
| 14 |
+
<span
|
| 15 |
+
class="truncate font-medium {team1Wins
|
| 16 |
+
? 'text-foreground'
|
| 17 |
+
: 'text-muted-foreground'}"
|
| 18 |
+
title={team1}
|
| 19 |
+
>
|
| 20 |
+
{team1}
|
| 21 |
+
</span>
|
| 22 |
+
<span class="text-muted-foreground/40 shrink-0">vs</span>
|
| 23 |
+
<span
|
| 24 |
+
class="truncate font-medium {team2Wins
|
| 25 |
+
? 'text-foreground'
|
| 26 |
+
: 'text-muted-foreground'}"
|
| 27 |
+
title={team2}
|
| 28 |
+
>
|
| 29 |
+
{team2}
|
| 30 |
+
</span>
|
| 31 |
+
</div>
|
src/lib/components/match-table/cells/winner-side-cell.svelte
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { Badge } from '$lib/components/ui/badge';
|
| 3 |
+
import Shield from 'phosphor-svelte/lib/Shield';
|
| 4 |
+
import Sword from 'phosphor-svelte/lib/Sword';
|
| 5 |
+
|
| 6 |
+
interface Props {
|
| 7 |
+
side: string | null | undefined;
|
| 8 |
+
}
|
| 9 |
+
let { side }: Props = $props();
|
| 10 |
+
|
| 11 |
+
const norm = $derived((side ?? '').toLowerCase());
|
| 12 |
+
</script>
|
| 13 |
+
|
| 14 |
+
{#if norm === 'ct'}
|
| 15 |
+
<Badge class="gap-1 border border-sky-500/40 bg-sky-500/15 text-sky-700 dark:text-sky-300">
|
| 16 |
+
<Shield size={11} weight="duotone" /> CT
|
| 17 |
+
</Badge>
|
| 18 |
+
{:else if norm === 't'}
|
| 19 |
+
<Badge
|
| 20 |
+
class="gap-1 border border-amber-500/40 bg-amber-500/15 text-amber-700 dark:text-amber-300"
|
| 21 |
+
>
|
| 22 |
+
<Sword size={11} weight="duotone" /> T
|
| 23 |
+
</Badge>
|
| 24 |
+
{:else}
|
| 25 |
+
<span class="text-muted-foreground/60 text-xs">—</span>
|
| 26 |
+
{/if}
|
src/lib/components/match-table/columns.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ColumnDef, RowData } from '@tanstack/table-core';
|
| 2 |
+
import { renderComponent } from '$lib/components/ui/data-table';
|
| 3 |
+
import SortHeader from './data-table-sort-header.svelte';
|
| 4 |
+
import PlainHeader from './data-table-plain-header.svelte';
|
| 5 |
+
import EventCell from './cells/event-cell.svelte';
|
| 6 |
+
import TeamsCell from './cells/teams-cell.svelte';
|
| 7 |
+
import ScoreCell from './cells/score-cell.svelte';
|
| 8 |
+
import MapCell from './cells/map-cell.svelte';
|
| 9 |
+
import MapsListCell from './cells/maps-list-cell.svelte';
|
| 10 |
+
import WinnerSideCell from './cells/winner-side-cell.svelte';
|
| 11 |
+
import DurationCell from './cells/duration-cell.svelte';
|
| 12 |
+
import DateCell from './cells/date-cell.svelte';
|
| 13 |
+
import RoundCell from './cells/round-cell.svelte';
|
| 14 |
+
import RoundsPlayedCell from './cells/rounds-played-cell.svelte';
|
| 15 |
+
import type { MapRow, MatchRow, RoundRow } from './rows';
|
| 16 |
+
import type { Match } from '$lib/types';
|
| 17 |
+
|
| 18 |
+
declare module '@tanstack/table-core' {
|
| 19 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 20 |
+
interface ColumnMeta<TData extends RowData, TValue> {
|
| 21 |
+
label?: string;
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const dateSort = (a: { match_date: string }, b: { match_date: string }) =>
|
| 26 |
+
new Date(a.match_date).getTime() - new Date(b.match_date).getTime();
|
| 27 |
+
|
| 28 |
+
// Cells use `accessorFn` returning the underlying value so global filtering and
|
| 29 |
+
// sorting see the right thing; visual rendering happens in `cell` via components.
|
| 30 |
+
|
| 31 |
+
export const roundColumns: ColumnDef<RoundRow>[] = [
|
| 32 |
+
{
|
| 33 |
+
id: 'event',
|
| 34 |
+
accessorKey: 'event',
|
| 35 |
+
header: ({ column }) => renderComponent(SortHeader, { column, label: 'Event' }),
|
| 36 |
+
cell: ({ row }) =>
|
| 37 |
+
renderComponent(EventCell, { event: row.original.event, format: row.original.format }),
|
| 38 |
+
meta: { label: 'Event' }
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
id: 'map_name',
|
| 42 |
+
accessorKey: 'map_name',
|
| 43 |
+
header: () => renderComponent(PlainHeader, { label: 'Map' }),
|
| 44 |
+
cell: ({ row }) => renderComponent(MapCell, { map: row.original.map_name }),
|
| 45 |
+
enableSorting: false,
|
| 46 |
+
filterFn: (row, id, value) => {
|
| 47 |
+
if (!value) return true;
|
| 48 |
+
return row.getValue<string>(id) === value;
|
| 49 |
+
},
|
| 50 |
+
meta: { label: 'Map' }
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
id: 'teams',
|
| 54 |
+
accessorFn: (r) => `${r.team1} ${r.team2}`,
|
| 55 |
+
header: () => renderComponent(PlainHeader, { label: 'Teams' }),
|
| 56 |
+
cell: ({ row }) =>
|
| 57 |
+
renderComponent(TeamsCell, {
|
| 58 |
+
team1: row.original.team1,
|
| 59 |
+
team2: row.original.team2,
|
| 60 |
+
winner: row.original.winner
|
| 61 |
+
}),
|
| 62 |
+
enableSorting: false,
|
| 63 |
+
meta: { label: 'Teams' }
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
id: 'round',
|
| 67 |
+
accessorKey: 'round',
|
| 68 |
+
header: ({ column }) =>
|
| 69 |
+
renderComponent(SortHeader, { column, label: 'Round' }),
|
| 70 |
+
cell: ({ row }) =>
|
| 71 |
+
renderComponent(RoundCell, { round: row.original.round, total: row.original.rounds_played }),
|
| 72 |
+
enableGlobalFilter: false,
|
| 73 |
+
meta: { label: 'Round' }
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
id: 'duration_s',
|
| 77 |
+
accessorKey: 'duration_s',
|
| 78 |
+
header: ({ column }) =>
|
| 79 |
+
renderComponent(SortHeader, { column, label: 'Duration' }),
|
| 80 |
+
cell: ({ row }) => renderComponent(DurationCell, { seconds: row.original.duration_s }),
|
| 81 |
+
enableGlobalFilter: false,
|
| 82 |
+
meta: { label: 'Duration' }
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
id: 'match_date',
|
| 86 |
+
accessorFn: (r) => new Date(r.match_date).getTime(),
|
| 87 |
+
sortingFn: (a, b) => dateSort(a.original, b.original),
|
| 88 |
+
header: ({ column }) =>
|
| 89 |
+
renderComponent(SortHeader, { column, label: 'Date' }),
|
| 90 |
+
cell: ({ row }) => renderComponent(DateCell, { iso: row.original.match_date }),
|
| 91 |
+
enableGlobalFilter: false,
|
| 92 |
+
meta: { label: 'Date' }
|
| 93 |
+
}
|
| 94 |
+
];
|
| 95 |
+
|
| 96 |
+
export const mapColumns: ColumnDef<MapRow>[] = [
|
| 97 |
+
{
|
| 98 |
+
id: 'event',
|
| 99 |
+
accessorKey: 'event',
|
| 100 |
+
header: ({ column }) => renderComponent(SortHeader, { column, label: 'Event' }),
|
| 101 |
+
cell: ({ row }) =>
|
| 102 |
+
renderComponent(EventCell, { event: row.original.event, format: row.original.format }),
|
| 103 |
+
meta: { label: 'Event' }
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
id: 'map_name',
|
| 107 |
+
accessorKey: 'map_name',
|
| 108 |
+
header: () => renderComponent(PlainHeader, { label: 'Map' }),
|
| 109 |
+
cell: ({ row }) => renderComponent(MapCell, { map: row.original.map_name }),
|
| 110 |
+
enableSorting: false,
|
| 111 |
+
filterFn: (row, id, value) => {
|
| 112 |
+
if (!value) return true;
|
| 113 |
+
return row.getValue<string>(id) === value;
|
| 114 |
+
},
|
| 115 |
+
meta: { label: 'Map' }
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
id: 'teams',
|
| 119 |
+
accessorFn: (r) => `${r.team1} ${r.team2}`,
|
| 120 |
+
header: () => renderComponent(PlainHeader, { label: 'Teams' }),
|
| 121 |
+
cell: ({ row }) =>
|
| 122 |
+
renderComponent(TeamsCell, {
|
| 123 |
+
team1: row.original.team1,
|
| 124 |
+
team2: row.original.team2,
|
| 125 |
+
winner: row.original.winner
|
| 126 |
+
}),
|
| 127 |
+
enableSorting: false,
|
| 128 |
+
meta: { label: 'Teams' }
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
id: 'score',
|
| 132 |
+
accessorFn: (r) => r.score1 + r.score2,
|
| 133 |
+
header: ({ column }) =>
|
| 134 |
+
renderComponent(SortHeader, { column, label: 'Score', align: 'start' }),
|
| 135 |
+
cell: ({ row }) =>
|
| 136 |
+
renderComponent(ScoreCell, {
|
| 137 |
+
score1: row.original.score1,
|
| 138 |
+
score2: row.original.score2,
|
| 139 |
+
winner: row.original.winner
|
| 140 |
+
}),
|
| 141 |
+
enableGlobalFilter: false,
|
| 142 |
+
meta: { label: 'Score' }
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
id: 'winner_side',
|
| 146 |
+
accessorKey: 'winner_side',
|
| 147 |
+
header: () => renderComponent(PlainHeader, { label: 'Won by' }),
|
| 148 |
+
cell: ({ row }) =>
|
| 149 |
+
renderComponent(WinnerSideCell, { side: (row.original as Match).winner_side }),
|
| 150 |
+
enableSorting: false,
|
| 151 |
+
filterFn: (row, id, value) => {
|
| 152 |
+
if (!value) return true;
|
| 153 |
+
return ((row.getValue<string>(id) ?? '') + '').toLowerCase() === value;
|
| 154 |
+
},
|
| 155 |
+
meta: { label: 'Won by' }
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
id: 'rounds_played',
|
| 159 |
+
accessorKey: 'rounds_played',
|
| 160 |
+
header: ({ column }) =>
|
| 161 |
+
renderComponent(SortHeader, { column, label: 'Rounds' }),
|
| 162 |
+
cell: ({ row }) => renderComponent(RoundsPlayedCell, { rounds: row.original.rounds_played }),
|
| 163 |
+
enableGlobalFilter: false,
|
| 164 |
+
meta: { label: 'Rounds' }
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
id: 'duration_s',
|
| 168 |
+
accessorKey: 'duration_s',
|
| 169 |
+
header: ({ column }) =>
|
| 170 |
+
renderComponent(SortHeader, { column, label: 'Duration' }),
|
| 171 |
+
cell: ({ row }) => renderComponent(DurationCell, { seconds: row.original.duration_s }),
|
| 172 |
+
enableGlobalFilter: false,
|
| 173 |
+
meta: { label: 'Duration' }
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
id: 'match_date',
|
| 177 |
+
accessorFn: (r) => new Date(r.match_date).getTime(),
|
| 178 |
+
sortingFn: (a, b) => dateSort(a.original, b.original),
|
| 179 |
+
header: ({ column }) => renderComponent(SortHeader, { column, label: 'Date' }),
|
| 180 |
+
cell: ({ row }) => renderComponent(DateCell, { iso: row.original.match_date }),
|
| 181 |
+
enableGlobalFilter: false,
|
| 182 |
+
meta: { label: 'Date' }
|
| 183 |
+
}
|
| 184 |
+
];
|
| 185 |
+
|
| 186 |
+
export const matchColumns: ColumnDef<MatchRow>[] = [
|
| 187 |
+
{
|
| 188 |
+
id: 'event',
|
| 189 |
+
accessorKey: 'event',
|
| 190 |
+
header: ({ column }) =>
|
| 191 |
+
renderComponent(SortHeader, { column, label: 'Event' }),
|
| 192 |
+
cell: ({ row }) =>
|
| 193 |
+
renderComponent(EventCell, { event: row.original.event, format: row.original.format }),
|
| 194 |
+
meta: { label: 'Event' }
|
| 195 |
+
},
|
| 196 |
+
{
|
| 197 |
+
id: 'teams',
|
| 198 |
+
accessorFn: (r) => `${r.team1} ${r.team2}`,
|
| 199 |
+
header: () => renderComponent(PlainHeader, { label: 'Teams' }),
|
| 200 |
+
cell: ({ row }) =>
|
| 201 |
+
renderComponent(TeamsCell, {
|
| 202 |
+
team1: row.original.team1,
|
| 203 |
+
team2: row.original.team2,
|
| 204 |
+
winner: row.original.winner
|
| 205 |
+
}),
|
| 206 |
+
enableSorting: false,
|
| 207 |
+
meta: { label: 'Teams' }
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
id: 'score',
|
| 211 |
+
accessorFn: (r) => r.score1 + r.score2,
|
| 212 |
+
header: ({ column }) =>
|
| 213 |
+
renderComponent(SortHeader, { column, label: 'Maps' }),
|
| 214 |
+
cell: ({ row }) =>
|
| 215 |
+
renderComponent(ScoreCell, {
|
| 216 |
+
score1: row.original.score1,
|
| 217 |
+
score2: row.original.score2,
|
| 218 |
+
winner: row.original.winner
|
| 219 |
+
}),
|
| 220 |
+
enableGlobalFilter: false,
|
| 221 |
+
meta: { label: 'Maps won' }
|
| 222 |
+
},
|
| 223 |
+
{
|
| 224 |
+
id: 'maps',
|
| 225 |
+
accessorFn: (r) => r.maps.join(' '),
|
| 226 |
+
header: () => renderComponent(PlainHeader, { label: 'Map pool' }),
|
| 227 |
+
cell: ({ row }) => renderComponent(MapsListCell, { maps: row.original.maps }),
|
| 228 |
+
enableSorting: false,
|
| 229 |
+
meta: { label: 'Map pool' }
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
id: 'rounds_played',
|
| 233 |
+
accessorKey: 'rounds_played',
|
| 234 |
+
header: ({ column }) =>
|
| 235 |
+
renderComponent(SortHeader, { column, label: 'Rounds' }),
|
| 236 |
+
cell: ({ row }) => renderComponent(RoundsPlayedCell, { rounds: row.original.rounds_played }),
|
| 237 |
+
enableGlobalFilter: false,
|
| 238 |
+
meta: { label: 'Rounds' }
|
| 239 |
+
},
|
| 240 |
+
{
|
| 241 |
+
id: 'duration_s',
|
| 242 |
+
accessorKey: 'duration_s',
|
| 243 |
+
header: ({ column }) =>
|
| 244 |
+
renderComponent(SortHeader, { column, label: 'Duration' }),
|
| 245 |
+
cell: ({ row }) => renderComponent(DurationCell, { seconds: row.original.duration_s }),
|
| 246 |
+
enableGlobalFilter: false,
|
| 247 |
+
meta: { label: 'Duration' }
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
id: 'match_date',
|
| 251 |
+
accessorFn: (r) => new Date(r.match_date).getTime(),
|
| 252 |
+
sortingFn: (a, b) => dateSort(a.original, b.original),
|
| 253 |
+
header: ({ column }) =>
|
| 254 |
+
renderComponent(SortHeader, { column, label: 'Date' }),
|
| 255 |
+
cell: ({ row }) => renderComponent(DateCell, { iso: row.original.match_date }),
|
| 256 |
+
enableGlobalFilter: false,
|
| 257 |
+
meta: { label: 'Date' }
|
| 258 |
+
}
|
| 259 |
+
];
|
src/lib/components/match-table/data-table-pagination.svelte
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts" generics="TData">
|
| 2 |
+
import type { Table } from '@tanstack/table-core';
|
| 3 |
+
import { Button } from '$lib/components/ui/button';
|
| 4 |
+
import * as Select from '$lib/components/ui/select';
|
| 5 |
+
import CaretLeft from 'phosphor-svelte/lib/CaretLeft';
|
| 6 |
+
import CaretRight from 'phosphor-svelte/lib/CaretRight';
|
| 7 |
+
import CaretDoubleLeft from 'phosphor-svelte/lib/CaretDoubleLeft';
|
| 8 |
+
import CaretDoubleRight from 'phosphor-svelte/lib/CaretDoubleRight';
|
| 9 |
+
|
| 10 |
+
interface Props {
|
| 11 |
+
table: Table<TData>;
|
| 12 |
+
pageSizes?: number[];
|
| 13 |
+
}
|
| 14 |
+
let { table, pageSizes = [10, 25, 50, 100] }: Props = $props();
|
| 15 |
+
|
| 16 |
+
const pageIndex = $derived(table.getState().pagination.pageIndex);
|
| 17 |
+
const pageSize = $derived(table.getState().pagination.pageSize);
|
| 18 |
+
const pageCount = $derived(table.getPageCount());
|
| 19 |
+
const totalRows = $derived(table.getFilteredRowModel().rows.length);
|
| 20 |
+
</script>
|
| 21 |
+
|
| 22 |
+
<div class="flex flex-wrap items-center justify-between gap-3 pt-3 text-xs">
|
| 23 |
+
<div class="text-muted-foreground">
|
| 24 |
+
{totalRows.toLocaleString()} row{totalRows === 1 ? '' : 's'}
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<div class="flex flex-wrap items-center gap-4">
|
| 28 |
+
<div class="flex items-center gap-2">
|
| 29 |
+
<span class="text-muted-foreground">Rows per page</span>
|
| 30 |
+
<Select.Root
|
| 31 |
+
type="single"
|
| 32 |
+
value={String(pageSize)}
|
| 33 |
+
onValueChange={(v) => table.setPageSize(Number(v))}
|
| 34 |
+
>
|
| 35 |
+
<Select.Trigger size="sm" class="h-7 w-[70px]">
|
| 36 |
+
{pageSize}
|
| 37 |
+
</Select.Trigger>
|
| 38 |
+
<Select.Content>
|
| 39 |
+
{#each pageSizes as n (n)}
|
| 40 |
+
<Select.Item value={String(n)}>{n}</Select.Item>
|
| 41 |
+
{/each}
|
| 42 |
+
</Select.Content>
|
| 43 |
+
</Select.Root>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div class="text-muted-foreground tabular-nums">
|
| 47 |
+
Page {pageIndex + 1} of {Math.max(1, pageCount)}
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<div class="flex items-center gap-1">
|
| 51 |
+
<Button
|
| 52 |
+
variant="outline"
|
| 53 |
+
size="icon-sm"
|
| 54 |
+
class="size-7"
|
| 55 |
+
onclick={() => table.setPageIndex(0)}
|
| 56 |
+
disabled={!table.getCanPreviousPage()}
|
| 57 |
+
aria-label="First page"
|
| 58 |
+
>
|
| 59 |
+
<CaretDoubleLeft size={12} weight="bold" />
|
| 60 |
+
</Button>
|
| 61 |
+
<Button
|
| 62 |
+
variant="outline"
|
| 63 |
+
size="icon-sm"
|
| 64 |
+
class="size-7"
|
| 65 |
+
onclick={() => table.previousPage()}
|
| 66 |
+
disabled={!table.getCanPreviousPage()}
|
| 67 |
+
aria-label="Previous page"
|
| 68 |
+
>
|
| 69 |
+
<CaretLeft size={12} weight="bold" />
|
| 70 |
+
</Button>
|
| 71 |
+
<Button
|
| 72 |
+
variant="outline"
|
| 73 |
+
size="icon-sm"
|
| 74 |
+
class="size-7"
|
| 75 |
+
onclick={() => table.nextPage()}
|
| 76 |
+
disabled={!table.getCanNextPage()}
|
| 77 |
+
aria-label="Next page"
|
| 78 |
+
>
|
| 79 |
+
<CaretRight size={12} weight="bold" />
|
| 80 |
+
</Button>
|
| 81 |
+
<Button
|
| 82 |
+
variant="outline"
|
| 83 |
+
size="icon-sm"
|
| 84 |
+
class="size-7"
|
| 85 |
+
onclick={() => table.setPageIndex(pageCount - 1)}
|
| 86 |
+
disabled={!table.getCanNextPage()}
|
| 87 |
+
aria-label="Last page"
|
| 88 |
+
>
|
| 89 |
+
<CaretDoubleRight size={12} weight="bold" />
|
| 90 |
+
</Button>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
src/lib/components/match-table/data-table-plain-header.svelte
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { cn } from '$lib/utils';
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
label: string;
|
| 6 |
+
align?: 'start' | 'end';
|
| 7 |
+
class?: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
let { label, align = 'start', class: className }: Props = $props();
|
| 11 |
+
</script>
|
| 12 |
+
|
| 13 |
+
<span
|
| 14 |
+
class={cn(
|
| 15 |
+
'text-muted-foreground inline-block px-2 text-[0.7rem] font-medium uppercase tracking-wide',
|
| 16 |
+
align === 'end' && 'w-full text-right',
|
| 17 |
+
className
|
| 18 |
+
)}
|
| 19 |
+
>
|
| 20 |
+
{label}
|
| 21 |
+
</span>
|
src/lib/components/match-table/data-table-sort-header.svelte
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { Button } from '$lib/components/ui/button';
|
| 3 |
+
import { cn } from '$lib/utils';
|
| 4 |
+
import { CaretUp, CaretDown, CaretUpDown } from 'phosphor-svelte';
|
| 5 |
+
|
| 6 |
+
// `column` comes from the TanStack header context. Its `TData/TValue` generics
|
| 7 |
+
// vary across columns, so we type it permissively here and only touch the
|
| 8 |
+
// methods we actually use.
|
| 9 |
+
interface SortableColumn {
|
| 10 |
+
getIsSorted(): false | 'asc' | 'desc';
|
| 11 |
+
toggleSorting(desc?: boolean): void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface Props {
|
| 15 |
+
column: SortableColumn;
|
| 16 |
+
label: string;
|
| 17 |
+
align?: 'start' | 'end';
|
| 18 |
+
class?: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
let { column, label, align = 'start', class: className }: Props = $props();
|
| 22 |
+
const sort = $derived(column.getIsSorted());
|
| 23 |
+
</script>
|
| 24 |
+
|
| 25 |
+
<Button
|
| 26 |
+
variant="ghost"
|
| 27 |
+
size="sm"
|
| 28 |
+
onclick={() => column.toggleSorting(sort === 'asc')}
|
| 29 |
+
class={cn(
|
| 30 |
+
'-ml-2 h-7 px-2 font-medium tracking-wide uppercase text-[0.7rem] text-muted-foreground hover:text-foreground',
|
| 31 |
+
align === 'end' && 'ml-0 -mr-2 w-full justify-end',
|
| 32 |
+
className
|
| 33 |
+
)}
|
| 34 |
+
>
|
| 35 |
+
{label}
|
| 36 |
+
{#if sort === 'asc'}
|
| 37 |
+
<CaretUp size={12} weight="bold" class="ml-1" />
|
| 38 |
+
{:else if sort === 'desc'}
|
| 39 |
+
<CaretDown size={12} weight="bold" class="ml-1" />
|
| 40 |
+
{:else}
|
| 41 |
+
<CaretUpDown size={12} weight="bold" class="ml-1 opacity-60" />
|
| 42 |
+
{/if}
|
| 43 |
+
</Button>
|
src/lib/components/match-table/data-table-toolbar.svelte
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts" generics="TData">
|
| 2 |
+
import type { Table } from '@tanstack/table-core';
|
| 3 |
+
import { Input } from '$lib/components/ui/input';
|
| 4 |
+
import { Button } from '$lib/components/ui/button';
|
| 5 |
+
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
| 6 |
+
import * as Select from '$lib/components/ui/select';
|
| 7 |
+
import { MagnifyingGlass, SlidersHorizontal, X } from 'phosphor-svelte';
|
| 8 |
+
import { prettyMap } from '$lib/utils/format';
|
| 9 |
+
|
| 10 |
+
interface Props {
|
| 11 |
+
table: Table<TData>;
|
| 12 |
+
searchPlaceholder?: string;
|
| 13 |
+
mapOptions?: string[];
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
let { table, searchPlaceholder = 'Search…', mapOptions = [] }: Props = $props();
|
| 17 |
+
|
| 18 |
+
const globalFilter = $derived((table.getState().globalFilter as string) ?? '');
|
| 19 |
+
const mapFilter = $derived((table.getColumn('map_name')?.getFilterValue() as string) ?? '__all');
|
| 20 |
+
const isFiltered = $derived(
|
| 21 |
+
(table.getState().globalFilter as string)?.length > 0 ||
|
| 22 |
+
table.getState().columnFilters.length > 0
|
| 23 |
+
);
|
| 24 |
+
</script>
|
| 25 |
+
|
| 26 |
+
<div class="flex flex-wrap items-center gap-2 py-3">
|
| 27 |
+
<div class="relative">
|
| 28 |
+
<MagnifyingGlass
|
| 29 |
+
size={14}
|
| 30 |
+
weight="bold"
|
| 31 |
+
class="text-muted-foreground absolute left-2.5 top-1/2 -translate-y-1/2"
|
| 32 |
+
/>
|
| 33 |
+
<Input
|
| 34 |
+
placeholder={searchPlaceholder}
|
| 35 |
+
value={globalFilter}
|
| 36 |
+
oninput={(e) => table.setGlobalFilter(e.currentTarget.value)}
|
| 37 |
+
class="h-8 w-[200px] pl-8 lg:w-[260px]"
|
| 38 |
+
/>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
{#if mapOptions.length > 0 && table.getColumn('map_name')}
|
| 42 |
+
<Select.Root
|
| 43 |
+
type="single"
|
| 44 |
+
value={mapFilter}
|
| 45 |
+
onValueChange={(v) => {
|
| 46 |
+
table.getColumn('map_name')?.setFilterValue(v === '__all' ? undefined : v);
|
| 47 |
+
}}
|
| 48 |
+
>
|
| 49 |
+
<Select.Trigger size="sm" class="h-8 min-w-[130px] capitalize">
|
| 50 |
+
{mapFilter === '__all' ? 'All maps' : prettyMap(mapFilter)}
|
| 51 |
+
</Select.Trigger>
|
| 52 |
+
<Select.Content>
|
| 53 |
+
<Select.Item value="__all">All maps</Select.Item>
|
| 54 |
+
{#each mapOptions as map (map)}
|
| 55 |
+
<Select.Item value={map} class="capitalize">{prettyMap(map)}</Select.Item>
|
| 56 |
+
{/each}
|
| 57 |
+
</Select.Content>
|
| 58 |
+
</Select.Root>
|
| 59 |
+
{/if}
|
| 60 |
+
|
| 61 |
+
{#if isFiltered}
|
| 62 |
+
<Button
|
| 63 |
+
variant="ghost"
|
| 64 |
+
size="sm"
|
| 65 |
+
class="h-8 px-2"
|
| 66 |
+
onclick={() => {
|
| 67 |
+
table.setGlobalFilter('');
|
| 68 |
+
table.resetColumnFilters();
|
| 69 |
+
}}
|
| 70 |
+
>
|
| 71 |
+
Reset
|
| 72 |
+
<X size={12} weight="bold" class="ml-1" />
|
| 73 |
+
</Button>
|
| 74 |
+
{/if}
|
| 75 |
+
|
| 76 |
+
<div class="ml-auto flex items-center gap-2">
|
| 77 |
+
<DropdownMenu.Root>
|
| 78 |
+
<DropdownMenu.Trigger>
|
| 79 |
+
{#snippet child({ props })}
|
| 80 |
+
<Button {...props} variant="outline" size="sm" class="h-8">
|
| 81 |
+
<SlidersHorizontal size={12} weight="bold" />
|
| 82 |
+
View
|
| 83 |
+
</Button>
|
| 84 |
+
{/snippet}
|
| 85 |
+
</DropdownMenu.Trigger>
|
| 86 |
+
<DropdownMenu.Content align="end" class="w-[180px]">
|
| 87 |
+
<DropdownMenu.Label>Toggle columns</DropdownMenu.Label>
|
| 88 |
+
<DropdownMenu.Separator />
|
| 89 |
+
{#each table.getAllColumns().filter((c) => c.getCanHide()) as column (column.id)}
|
| 90 |
+
<DropdownMenu.CheckboxItem
|
| 91 |
+
class="capitalize"
|
| 92 |
+
checked={column.getIsVisible()}
|
| 93 |
+
onCheckedChange={(v) => column.toggleVisibility(!!v)}
|
| 94 |
+
>
|
| 95 |
+
{(column.columnDef.meta as { label?: string } | undefined)?.label ?? column.id.replace(/_/g, ' ')}
|
| 96 |
+
</DropdownMenu.CheckboxItem>
|
| 97 |
+
{/each}
|
| 98 |
+
</DropdownMenu.Content>
|
| 99 |
+
</DropdownMenu.Root>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
src/lib/components/match-table/match-table.svelte
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts" generics="TData">
|
| 2 |
+
import {
|
| 3 |
+
type ColumnDef,
|
| 4 |
+
type ColumnFiltersState,
|
| 5 |
+
type PaginationState,
|
| 6 |
+
type SortingState,
|
| 7 |
+
type VisibilityState,
|
| 8 |
+
getCoreRowModel,
|
| 9 |
+
getFilteredRowModel,
|
| 10 |
+
getPaginationRowModel,
|
| 11 |
+
getSortedRowModel
|
| 12 |
+
} from '@tanstack/table-core';
|
| 13 |
+
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table';
|
| 14 |
+
import * as Table from '$lib/components/ui/table';
|
| 15 |
+
import DataTableToolbar from './data-table-toolbar.svelte';
|
| 16 |
+
import DataTablePagination from './data-table-pagination.svelte';
|
| 17 |
+
import { goto } from '$app/navigation';
|
| 18 |
+
import { cn } from '$lib/utils';
|
| 19 |
+
import { untrack } from 'svelte';
|
| 20 |
+
|
| 21 |
+
interface Props {
|
| 22 |
+
data: TData[];
|
| 23 |
+
columns: ColumnDef<TData>[];
|
| 24 |
+
searchPlaceholder?: string;
|
| 25 |
+
mapOptions?: string[];
|
| 26 |
+
initialSorting?: SortingState;
|
| 27 |
+
initialPageSize?: number;
|
| 28 |
+
getRowHref?: (row: TData) => string;
|
| 29 |
+
emptyMessage?: string;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
let {
|
| 33 |
+
data,
|
| 34 |
+
columns,
|
| 35 |
+
searchPlaceholder,
|
| 36 |
+
mapOptions = [],
|
| 37 |
+
initialSorting = [],
|
| 38 |
+
initialPageSize = 25,
|
| 39 |
+
getRowHref,
|
| 40 |
+
emptyMessage = 'No results.'
|
| 41 |
+
}: Props = $props();
|
| 42 |
+
|
| 43 |
+
// svelte-ignore state_referenced_locally
|
| 44 |
+
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: initialPageSize });
|
| 45 |
+
// svelte-ignore state_referenced_locally
|
| 46 |
+
let sorting = $state<SortingState>(initialSorting);
|
| 47 |
+
let columnFilters = $state<ColumnFiltersState>([]);
|
| 48 |
+
let columnVisibility = $state<VisibilityState>({});
|
| 49 |
+
let globalFilter = $state('');
|
| 50 |
+
|
| 51 |
+
// Reset to first page whenever filters change so users don't end up on an
|
| 52 |
+
// empty page after narrowing the result set.
|
| 53 |
+
$effect(() => {
|
| 54 |
+
void globalFilter;
|
| 55 |
+
void columnFilters;
|
| 56 |
+
untrack(() => {
|
| 57 |
+
if (pagination.pageIndex !== 0) {
|
| 58 |
+
pagination = { ...pagination, pageIndex: 0 };
|
| 59 |
+
}
|
| 60 |
+
});
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
const table = createSvelteTable<TData>({
|
| 64 |
+
get data() {
|
| 65 |
+
return data;
|
| 66 |
+
},
|
| 67 |
+
get columns() {
|
| 68 |
+
return columns;
|
| 69 |
+
},
|
| 70 |
+
state: {
|
| 71 |
+
get pagination() {
|
| 72 |
+
return pagination;
|
| 73 |
+
},
|
| 74 |
+
get sorting() {
|
| 75 |
+
return sorting;
|
| 76 |
+
},
|
| 77 |
+
get columnFilters() {
|
| 78 |
+
return columnFilters;
|
| 79 |
+
},
|
| 80 |
+
get columnVisibility() {
|
| 81 |
+
return columnVisibility;
|
| 82 |
+
},
|
| 83 |
+
get globalFilter() {
|
| 84 |
+
return globalFilter;
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
globalFilterFn: 'includesString',
|
| 88 |
+
getCoreRowModel: getCoreRowModel(),
|
| 89 |
+
getSortedRowModel: getSortedRowModel(),
|
| 90 |
+
getFilteredRowModel: getFilteredRowModel(),
|
| 91 |
+
getPaginationRowModel: getPaginationRowModel(),
|
| 92 |
+
onPaginationChange: (u) => {
|
| 93 |
+
pagination = typeof u === 'function' ? u(pagination) : u;
|
| 94 |
+
},
|
| 95 |
+
onSortingChange: (u) => {
|
| 96 |
+
sorting = typeof u === 'function' ? u(sorting) : u;
|
| 97 |
+
},
|
| 98 |
+
onColumnFiltersChange: (u) => {
|
| 99 |
+
columnFilters = typeof u === 'function' ? u(columnFilters) : u;
|
| 100 |
+
},
|
| 101 |
+
onColumnVisibilityChange: (u) => {
|
| 102 |
+
columnVisibility = typeof u === 'function' ? u(columnVisibility) : u;
|
| 103 |
+
},
|
| 104 |
+
onGlobalFilterChange: (u) => {
|
| 105 |
+
globalFilter = typeof u === 'function' ? u(globalFilter) : u;
|
| 106 |
+
}
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
function handleRowClick(row: TData, e: MouseEvent | KeyboardEvent) {
|
| 110 |
+
if (!getRowHref) return;
|
| 111 |
+
const href = getRowHref(row);
|
| 112 |
+
if (e instanceof MouseEvent && (e.metaKey || e.ctrlKey || e.button === 1)) {
|
| 113 |
+
window.open(href, '_blank', 'noopener,noreferrer');
|
| 114 |
+
return;
|
| 115 |
+
}
|
| 116 |
+
goto(href);
|
| 117 |
+
}
|
| 118 |
+
</script>
|
| 119 |
+
|
| 120 |
+
<div>
|
| 121 |
+
<DataTableToolbar {table} {searchPlaceholder} {mapOptions} />
|
| 122 |
+
|
| 123 |
+
<div class="rounded-md border">
|
| 124 |
+
<Table.Root>
|
| 125 |
+
<Table.Header>
|
| 126 |
+
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
| 127 |
+
<Table.Row class="bg-muted/40 hover:bg-muted/40">
|
| 128 |
+
{#each headerGroup.headers as header (header.id)}
|
| 129 |
+
<Table.Head class="h-9 text-xs">
|
| 130 |
+
{#if !header.isPlaceholder}
|
| 131 |
+
<FlexRender
|
| 132 |
+
content={header.column.columnDef.header}
|
| 133 |
+
context={header.getContext()}
|
| 134 |
+
/>
|
| 135 |
+
{/if}
|
| 136 |
+
</Table.Head>
|
| 137 |
+
{/each}
|
| 138 |
+
</Table.Row>
|
| 139 |
+
{/each}
|
| 140 |
+
</Table.Header>
|
| 141 |
+
<Table.Body>
|
| 142 |
+
{#each table.getRowModel().rows as row (row.id)}
|
| 143 |
+
<Table.Row
|
| 144 |
+
class={cn(getRowHref && 'cursor-pointer')}
|
| 145 |
+
onclick={(e: MouseEvent) => handleRowClick(row.original, e)}
|
| 146 |
+
onkeydown={(e: KeyboardEvent) => {
|
| 147 |
+
if (e.key === 'Enter' || e.key === ' ') {
|
| 148 |
+
e.preventDefault();
|
| 149 |
+
handleRowClick(row.original, e);
|
| 150 |
+
}
|
| 151 |
+
}}
|
| 152 |
+
tabindex={getRowHref ? 0 : undefined}
|
| 153 |
+
role={getRowHref ? 'link' : undefined}
|
| 154 |
+
>
|
| 155 |
+
{#each row.getVisibleCells() as cell (cell.id)}
|
| 156 |
+
<Table.Cell class="py-2 align-middle">
|
| 157 |
+
<FlexRender
|
| 158 |
+
content={cell.column.columnDef.cell}
|
| 159 |
+
context={cell.getContext()}
|
| 160 |
+
/>
|
| 161 |
+
</Table.Cell>
|
| 162 |
+
{/each}
|
| 163 |
+
</Table.Row>
|
| 164 |
+
{:else}
|
| 165 |
+
<Table.Row>
|
| 166 |
+
<Table.Cell colspan={columns.length} class="text-muted-foreground h-24 text-center text-sm">
|
| 167 |
+
{emptyMessage}
|
| 168 |
+
</Table.Cell>
|
| 169 |
+
</Table.Row>
|
| 170 |
+
{/each}
|
| 171 |
+
</Table.Body>
|
| 172 |
+
</Table.Root>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<DataTablePagination {table} />
|
| 176 |
+
</div>
|
src/lib/components/match-table/rows.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Match, Round } from '$lib/types';
|
| 2 |
+
|
| 3 |
+
const TICK_RATE = 64;
|
| 4 |
+
|
| 5 |
+
export type MapRow = Match & {
|
| 6 |
+
duration_s: number;
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export type RoundRow = {
|
| 10 |
+
match_id: number;
|
| 11 |
+
map_name: string;
|
| 12 |
+
round: number;
|
| 13 |
+
demo_round: number;
|
| 14 |
+
duration_s: number;
|
| 15 |
+
event: string;
|
| 16 |
+
team1: string;
|
| 17 |
+
team2: string;
|
| 18 |
+
score1: number;
|
| 19 |
+
score2: number;
|
| 20 |
+
winner: string;
|
| 21 |
+
format: string;
|
| 22 |
+
match_date: string;
|
| 23 |
+
rounds_played: number;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export type MatchRow = {
|
| 27 |
+
match_id: number;
|
| 28 |
+
event: string;
|
| 29 |
+
team1: string;
|
| 30 |
+
team2: string;
|
| 31 |
+
score1: number;
|
| 32 |
+
score2: number;
|
| 33 |
+
winner: string;
|
| 34 |
+
format: string;
|
| 35 |
+
match_date: string;
|
| 36 |
+
maps: string[];
|
| 37 |
+
maps_played: number;
|
| 38 |
+
first_map: string;
|
| 39 |
+
rounds_played: number;
|
| 40 |
+
duration_s: number;
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
export function buildMapRows(matches: Match[], rounds: Round[]): MapRow[] {
|
| 44 |
+
const durationByMap = new Map<string, number>();
|
| 45 |
+
for (const r of rounds) {
|
| 46 |
+
const k = `${r.match_id}|${r.map_name}`;
|
| 47 |
+
durationByMap.set(k, (durationByMap.get(k) ?? 0) + (r.round_duration_ticks || 0) / TICK_RATE);
|
| 48 |
+
}
|
| 49 |
+
return matches.map((m) => ({
|
| 50 |
+
...m,
|
| 51 |
+
duration_s: durationByMap.get(`${m.match_id}|${m.map_name}`) ?? 0
|
| 52 |
+
}));
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function buildRoundRows(matches: Match[], rounds: Round[]): RoundRow[] {
|
| 56 |
+
const matchByMap = new Map<string, Match>();
|
| 57 |
+
for (const m of matches) matchByMap.set(`${m.match_id}|${m.map_name}`, m);
|
| 58 |
+
|
| 59 |
+
const out: RoundRow[] = [];
|
| 60 |
+
for (const r of rounds) {
|
| 61 |
+
const m = matchByMap.get(`${r.match_id}|${r.map_name}`);
|
| 62 |
+
if (!m) continue;
|
| 63 |
+
out.push({
|
| 64 |
+
match_id: r.match_id,
|
| 65 |
+
map_name: r.map_name,
|
| 66 |
+
round: r.round,
|
| 67 |
+
demo_round: r.demo_round,
|
| 68 |
+
duration_s: (r.round_duration_ticks || 0) / TICK_RATE,
|
| 69 |
+
event: m.event,
|
| 70 |
+
team1: m.team1,
|
| 71 |
+
team2: m.team2,
|
| 72 |
+
score1: m.score1,
|
| 73 |
+
score2: m.score2,
|
| 74 |
+
winner: m.winner,
|
| 75 |
+
format: m.format,
|
| 76 |
+
match_date: m.match_date,
|
| 77 |
+
rounds_played: m.rounds_played
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
return out;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export function buildMatchRows(matches: Match[], rounds: Round[]): MatchRow[] {
|
| 84 |
+
const durationByMap = new Map<string, number>();
|
| 85 |
+
for (const r of rounds) {
|
| 86 |
+
const k = `${r.match_id}|${r.map_name}`;
|
| 87 |
+
durationByMap.set(k, (durationByMap.get(k) ?? 0) + (r.round_duration_ticks || 0) / TICK_RATE);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const grouped = new Map<number, Match[]>();
|
| 91 |
+
for (const m of matches) {
|
| 92 |
+
const arr = grouped.get(m.match_id);
|
| 93 |
+
if (arr) arr.push(m);
|
| 94 |
+
else grouped.set(m.match_id, [m]);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const rows: MatchRow[] = [];
|
| 98 |
+
for (const [matchId, mapsForMatch] of grouped) {
|
| 99 |
+
const sorted = [...mapsForMatch].sort((a, b) => (a.map_index ?? 0) - (b.map_index ?? 0));
|
| 100 |
+
const head = sorted[0];
|
| 101 |
+
const team1MapWins = sorted.filter((m) => m.winner === 'team1').length;
|
| 102 |
+
const team2MapWins = sorted.filter((m) => m.winner === 'team2').length;
|
| 103 |
+
const totalRounds = sorted.reduce((acc, m) => acc + m.rounds_played, 0);
|
| 104 |
+
const totalDuration = sorted.reduce(
|
| 105 |
+
(acc, m) => acc + (durationByMap.get(`${m.match_id}|${m.map_name}`) ?? 0),
|
| 106 |
+
0
|
| 107 |
+
);
|
| 108 |
+
rows.push({
|
| 109 |
+
match_id: matchId,
|
| 110 |
+
event: head.event,
|
| 111 |
+
team1: head.team1,
|
| 112 |
+
team2: head.team2,
|
| 113 |
+
score1: team1MapWins,
|
| 114 |
+
score2: team2MapWins,
|
| 115 |
+
winner: team1MapWins > team2MapWins ? 'team1' : team2MapWins > team1MapWins ? 'team2' : '',
|
| 116 |
+
format: head.format,
|
| 117 |
+
match_date: head.match_date,
|
| 118 |
+
maps: sorted.map((m) => m.map_name),
|
| 119 |
+
maps_played: sorted.length,
|
| 120 |
+
first_map: head.map_name,
|
| 121 |
+
rounds_played: totalRounds,
|
| 122 |
+
duration_s: totalDuration
|
| 123 |
+
});
|
| 124 |
+
}
|
| 125 |
+
rows.sort(
|
| 126 |
+
(a, b) => new Date(b.match_date).getTime() - new Date(a.match_date).getTime() || b.match_id - a.match_id
|
| 127 |
+
);
|
| 128 |
+
return rows;
|
| 129 |
+
}
|
src/lib/components/video-player/video-player.svelte
CHANGED
|
@@ -55,7 +55,13 @@
|
|
| 55 |
let pendingSeek: number | null = null;
|
| 56 |
let prevSig: string | null = null;
|
| 57 |
let showFreezeFrame = $state(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
let freezeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
|
| 59 |
|
| 60 |
function locateChunkAt(t: number): number {
|
| 61 |
if (!chunks.length) return 0;
|
|
@@ -76,6 +82,12 @@
|
|
| 76 |
let lastEmitted = -1;
|
| 77 |
$effect(() => {
|
| 78 |
const t = currentTime;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
if (Math.abs(t - lastEmitted) >= 0.2) {
|
| 80 |
lastEmitted = t;
|
| 81 |
onTimeUpdate?.(t);
|
|
@@ -97,6 +109,12 @@
|
|
| 97 |
try {
|
| 98 |
ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height);
|
| 99 |
showFreezeFrame = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
if (freezeTimer) clearTimeout(freezeTimer);
|
| 101 |
freezeTimer = setTimeout(() => {
|
| 102 |
showFreezeFrame = false;
|
|
@@ -108,11 +126,20 @@
|
|
| 108 |
}
|
| 109 |
|
| 110 |
function clearFreezeFrame() {
|
| 111 |
-
showFreezeFrame
|
|
|
|
|
|
|
|
|
|
| 112 |
if (freezeTimer) {
|
| 113 |
clearTimeout(freezeTimer);
|
| 114 |
freezeTimer = null;
|
| 115 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
$effect(() => {
|
|
@@ -248,6 +275,7 @@
|
|
| 248 |
mse?.destroy();
|
| 249 |
if (videoSrc) URL.revokeObjectURL(videoSrc);
|
| 250 |
if (freezeTimer) clearTimeout(freezeTimer);
|
|
|
|
| 251 |
};
|
| 252 |
});
|
| 253 |
|
|
@@ -291,8 +319,9 @@
|
|
| 291 |
|
| 292 |
<canvas
|
| 293 |
bind:this={canvasEl}
|
| 294 |
-
class="pointer-events-none absolute inset-0 z-10 h-full w-full object-contain"
|
| 295 |
class:hidden={!showFreezeFrame}
|
|
|
|
| 296 |
aria-hidden="true"
|
| 297 |
></canvas>
|
| 298 |
|
|
@@ -315,3 +344,31 @@
|
|
| 315 |
</div>
|
| 316 |
{/if}
|
| 317 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
let pendingSeek: number | null = null;
|
| 56 |
let prevSig: string | null = null;
|
| 57 |
let showFreezeFrame = $state(false);
|
| 58 |
+
// `dissolveFreeze` triggers a CSS clip-path wipe on the freeze canvas so
|
| 59 |
+
// the old frame visibly transitions out instead of snapping. The wipe is
|
| 60 |
+
// L→R diagonal to feel cohesive with the VideoStage beam sweep.
|
| 61 |
+
let dissolveFreeze = $state(false);
|
| 62 |
let freezeTimer: ReturnType<typeof setTimeout> | null = null;
|
| 63 |
+
let dissolveTimer: ReturnType<typeof setTimeout> | null = null;
|
| 64 |
+
const FREEZE_WIPE_MS = 480;
|
| 65 |
|
| 66 |
function locateChunkAt(t: number): number {
|
| 67 |
if (!chunks.length) return 0;
|
|
|
|
| 82 |
let lastEmitted = -1;
|
| 83 |
$effect(() => {
|
| 84 |
const t = currentTime;
|
| 85 |
+
// During a chunk swap the new <video> element transiently reports
|
| 86 |
+
// currentTime=0 before its loadedmetadata seek; echoing that to the
|
| 87 |
+
// parent would snap virtualTime (and therefore the minimap + timeline)
|
| 88 |
+
// back to round-start for ~1 frame. Hold off until the new clip's
|
| 89 |
+
// data has actually loaded.
|
| 90 |
+
if (!isVideoData) return;
|
| 91 |
if (Math.abs(t - lastEmitted) >= 0.2) {
|
| 92 |
lastEmitted = t;
|
| 93 |
onTimeUpdate?.(t);
|
|
|
|
| 109 |
try {
|
| 110 |
ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height);
|
| 111 |
showFreezeFrame = true;
|
| 112 |
+
// New capture aborts any in-flight wipe so the next swap starts clean.
|
| 113 |
+
dissolveFreeze = false;
|
| 114 |
+
if (dissolveTimer) {
|
| 115 |
+
clearTimeout(dissolveTimer);
|
| 116 |
+
dissolveTimer = null;
|
| 117 |
+
}
|
| 118 |
if (freezeTimer) clearTimeout(freezeTimer);
|
| 119 |
freezeTimer = setTimeout(() => {
|
| 120 |
showFreezeFrame = false;
|
|
|
|
| 126 |
}
|
| 127 |
|
| 128 |
function clearFreezeFrame() {
|
| 129 |
+
if (!showFreezeFrame || dissolveFreeze) return;
|
| 130 |
+
// Trigger the wipe; the canvas stays mounted so the keyframes can run,
|
| 131 |
+
// then we drop it once the animation is done.
|
| 132 |
+
dissolveFreeze = true;
|
| 133 |
if (freezeTimer) {
|
| 134 |
clearTimeout(freezeTimer);
|
| 135 |
freezeTimer = null;
|
| 136 |
}
|
| 137 |
+
if (dissolveTimer) clearTimeout(dissolveTimer);
|
| 138 |
+
dissolveTimer = setTimeout(() => {
|
| 139 |
+
showFreezeFrame = false;
|
| 140 |
+
dissolveFreeze = false;
|
| 141 |
+
dissolveTimer = null;
|
| 142 |
+
}, FREEZE_WIPE_MS);
|
| 143 |
}
|
| 144 |
|
| 145 |
$effect(() => {
|
|
|
|
| 275 |
mse?.destroy();
|
| 276 |
if (videoSrc) URL.revokeObjectURL(videoSrc);
|
| 277 |
if (freezeTimer) clearTimeout(freezeTimer);
|
| 278 |
+
if (dissolveTimer) clearTimeout(dissolveTimer);
|
| 279 |
};
|
| 280 |
});
|
| 281 |
|
|
|
|
| 319 |
|
| 320 |
<canvas
|
| 321 |
bind:this={canvasEl}
|
| 322 |
+
class="vp-freeze pointer-events-none absolute inset-0 z-10 h-full w-full object-contain"
|
| 323 |
class:hidden={!showFreezeFrame}
|
| 324 |
+
class:vp-freeze--wipe={dissolveFreeze}
|
| 325 |
aria-hidden="true"
|
| 326 |
></canvas>
|
| 327 |
|
|
|
|
| 344 |
</div>
|
| 345 |
{/if}
|
| 346 |
</div>
|
| 347 |
+
|
| 348 |
+
<style>
|
| 349 |
+
/* Diagonal wipe of the freeze-frame on player swap. The clip-path
|
| 350 |
+
parallelogram travels L→R, eating the canvas from the left side first
|
| 351 |
+
so the new <video> underneath is progressively revealed. The slight
|
| 352 |
+
brightness/saturation boost mid-wipe gives a subtle "energize" feel. */
|
| 353 |
+
.vp-freeze--wipe {
|
| 354 |
+
animation: vp-freeze-wipe 480ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
|
| 355 |
+
}
|
| 356 |
+
@keyframes vp-freeze-wipe {
|
| 357 |
+
0% {
|
| 358 |
+
clip-path: polygon(-20% 0, 110% 0, 110% 100%, -45% 100%);
|
| 359 |
+
filter: none;
|
| 360 |
+
}
|
| 361 |
+
55% {
|
| 362 |
+
filter: brightness(1.18) saturate(1.2) contrast(1.05);
|
| 363 |
+
}
|
| 364 |
+
100% {
|
| 365 |
+
clip-path: polygon(125% 0, 110% 0, 110% 100%, 100% 100%);
|
| 366 |
+
filter: brightness(1.5) saturate(1.3) contrast(1.05);
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
@media (prefers-reduced-motion: reduce) {
|
| 370 |
+
.vp-freeze--wipe {
|
| 371 |
+
animation-duration: 1ms;
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
</style>
|
src/lib/components/video-stage.svelte
CHANGED
|
@@ -42,22 +42,32 @@
|
|
| 42 |
|
| 43 |
const sideMeta = $derived(chunks[0] ?? null);
|
| 44 |
|
| 45 |
-
//
|
| 46 |
-
//
|
| 47 |
-
//
|
|
|
|
|
|
|
| 48 |
let prevPlayer = $state<number | null>(null);
|
| 49 |
-
let switchPing = $state<{ player: number; side: string } | null>(
|
|
|
|
|
|
|
| 50 |
let switchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
| 51 |
$effect(() => {
|
| 52 |
const cur = sideMeta?.player;
|
| 53 |
if (cur === undefined || cur === null) return;
|
| 54 |
-
if (prevPlayer !== null && cur !== prevPlayer) {
|
| 55 |
-
switchPing = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
if (switchTimer) clearTimeout(switchTimer);
|
| 57 |
switchTimer = setTimeout(() => {
|
| 58 |
switchPing = null;
|
| 59 |
switchTimer = null;
|
| 60 |
-
},
|
| 61 |
}
|
| 62 |
prevPlayer = cur;
|
| 63 |
});
|
|
@@ -117,25 +127,199 @@
|
|
| 117 |
{/if}
|
| 118 |
|
| 119 |
{#if switchPing}
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
>
|
| 124 |
<div
|
| 125 |
-
class="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
>
|
| 127 |
-
<
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
>
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
</div>
|
| 137 |
-
|
| 138 |
{/if}
|
| 139 |
</AspectRatio>
|
| 140 |
|
| 141 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
const sideMeta = $derived(chunks[0] ?? null);
|
| 44 |
|
| 45 |
+
// Tactical reveal overlay on player swap: freezes the previous frame in the
|
| 46 |
+
// underlying VideoPlayer, plus we paint a player-colored beam sweep + corner
|
| 47 |
+
// brackets + edge vignette on top to make the ~0.5–1s clip swap feel like an
|
| 48 |
+
// intentional cut rather than a buffering hiccup. The `key` field bumps on
|
| 49 |
+
// every swap so the CSS animations re-trigger even on rapid back-and-forth.
|
| 50 |
let prevPlayer = $state<number | null>(null);
|
| 51 |
+
let switchPing = $state<{ player: number; side: string; color: string; key: number } | null>(
|
| 52 |
+
null
|
| 53 |
+
);
|
| 54 |
let switchTimer: ReturnType<typeof setTimeout> | null = null;
|
| 55 |
+
let switchSeq = 0;
|
| 56 |
$effect(() => {
|
| 57 |
const cur = sideMeta?.player;
|
| 58 |
if (cur === undefined || cur === null) return;
|
| 59 |
+
if (prevPlayer !== null && cur !== prevPlayer && !loading) {
|
| 60 |
+
switchPing = {
|
| 61 |
+
player: cur,
|
| 62 |
+
side: sideMeta?.player_side ?? '',
|
| 63 |
+
color: playerColor(cur),
|
| 64 |
+
key: ++switchSeq
|
| 65 |
+
};
|
| 66 |
if (switchTimer) clearTimeout(switchTimer);
|
| 67 |
switchTimer = setTimeout(() => {
|
| 68 |
switchPing = null;
|
| 69 |
switchTimer = null;
|
| 70 |
+
}, 600);
|
| 71 |
}
|
| 72 |
prevPlayer = cur;
|
| 73 |
});
|
|
|
|
| 127 |
{/if}
|
| 128 |
|
| 129 |
{#if switchPing}
|
| 130 |
+
{#key switchPing.key}
|
| 131 |
+
<!-- Tactical reveal: edge vignette + diagonal beam + corner brackets.
|
| 132 |
+
All overlays are pointer-events:none so they don't steal clicks. -->
|
|
|
|
| 133 |
<div
|
| 134 |
+
class="vs-vignette pointer-events-none absolute inset-0 z-30"
|
| 135 |
+
style="--ping-color: {switchPing.color};"
|
| 136 |
+
></div>
|
| 137 |
+
<div
|
| 138 |
+
class="vs-beam-wrap pointer-events-none absolute inset-0 z-30 overflow-hidden"
|
| 139 |
+
style="--ping-color: {switchPing.color};"
|
| 140 |
>
|
| 141 |
+
<div class="vs-beam"></div>
|
| 142 |
+
</div>
|
| 143 |
+
<div
|
| 144 |
+
class="vs-brackets pointer-events-none absolute inset-3 z-30"
|
| 145 |
+
style="--ping-color: {switchPing.color};"
|
| 146 |
+
>
|
| 147 |
+
<span class="vs-bracket vs-bracket--tl"></span>
|
| 148 |
+
<span class="vs-bracket vs-bracket--tr"></span>
|
| 149 |
+
<span class="vs-bracket vs-bracket--bl"></span>
|
| 150 |
+
<span class="vs-bracket vs-bracket--br"></span>
|
| 151 |
+
</div>
|
| 152 |
+
<div class="vs-chip pointer-events-none absolute bottom-4 left-4 z-40 select-none">
|
| 153 |
+
<div
|
| 154 |
+
class="flex items-center gap-2 rounded-md bg-black/75 px-2.5 py-1.5 shadow-lg ring-1 ring-white/10 backdrop-blur-sm"
|
| 155 |
+
style="box-shadow: 0 0 24px {switchPing.color}55;"
|
| 156 |
>
|
| 157 |
+
<span
|
| 158 |
+
class="inline-flex h-5 w-5 items-center justify-center rounded-sm font-mono text-[11px] font-bold text-white"
|
| 159 |
+
style="background-color: {switchPing.color};"
|
| 160 |
+
>
|
| 161 |
+
{switchPing.player}
|
| 162 |
+
</span>
|
| 163 |
+
<span
|
| 164 |
+
class="border-l border-white/15 pl-2 text-[10px] font-semibold uppercase tracking-wide text-white/70"
|
| 165 |
+
>
|
| 166 |
+
{switchPing.side || 'POV'}
|
| 167 |
+
</span>
|
| 168 |
+
</div>
|
| 169 |
</div>
|
| 170 |
+
{/key}
|
| 171 |
{/if}
|
| 172 |
</AspectRatio>
|
| 173 |
|
| 174 |
</div>
|
| 175 |
+
|
| 176 |
+
<style>
|
| 177 |
+
/* Diagonal player-colored beam swept across the stage. The bright white
|
| 178 |
+
edges + colored core + screen blend produce the "neon scan" look. */
|
| 179 |
+
.vs-beam {
|
| 180 |
+
position: absolute;
|
| 181 |
+
top: -25%;
|
| 182 |
+
bottom: -25%;
|
| 183 |
+
left: -40%;
|
| 184 |
+
width: 50%;
|
| 185 |
+
background: linear-gradient(
|
| 186 |
+
110deg,
|
| 187 |
+
transparent 0%,
|
| 188 |
+
transparent 35%,
|
| 189 |
+
rgba(255, 255, 255, 0.55) 47%,
|
| 190 |
+
var(--ping-color) 50%,
|
| 191 |
+
rgba(255, 255, 255, 0.55) 53%,
|
| 192 |
+
transparent 65%,
|
| 193 |
+
transparent 100%
|
| 194 |
+
);
|
| 195 |
+
filter: blur(0.5px);
|
| 196 |
+
mix-blend-mode: screen;
|
| 197 |
+
opacity: 0;
|
| 198 |
+
transform: translateX(-30%) skewX(-18deg);
|
| 199 |
+
animation: vs-beam-sweep 520ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
|
| 200 |
+
}
|
| 201 |
+
@keyframes vs-beam-sweep {
|
| 202 |
+
0% {
|
| 203 |
+
opacity: 0;
|
| 204 |
+
transform: translateX(-30%) skewX(-18deg);
|
| 205 |
+
}
|
| 206 |
+
18% {
|
| 207 |
+
opacity: 1;
|
| 208 |
+
}
|
| 209 |
+
82% {
|
| 210 |
+
opacity: 1;
|
| 211 |
+
}
|
| 212 |
+
100% {
|
| 213 |
+
opacity: 0;
|
| 214 |
+
transform: translateX(380%) skewX(-18deg);
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
/* Soft player-colored vignette around the edges, like a HUD glow. */
|
| 219 |
+
.vs-vignette {
|
| 220 |
+
background: radial-gradient(
|
| 221 |
+
ellipse at center,
|
| 222 |
+
transparent 55%,
|
| 223 |
+
color-mix(in srgb, var(--ping-color) 40%, transparent) 100%
|
| 224 |
+
);
|
| 225 |
+
opacity: 0;
|
| 226 |
+
animation: vs-vignette-pulse 520ms ease-out forwards;
|
| 227 |
+
}
|
| 228 |
+
@keyframes vs-vignette-pulse {
|
| 229 |
+
0% {
|
| 230 |
+
opacity: 0;
|
| 231 |
+
}
|
| 232 |
+
25% {
|
| 233 |
+
opacity: 1;
|
| 234 |
+
}
|
| 235 |
+
100% {
|
| 236 |
+
opacity: 0;
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/* Four reticle-style corner brackets in the player color. */
|
| 241 |
+
.vs-bracket {
|
| 242 |
+
position: absolute;
|
| 243 |
+
width: 26px;
|
| 244 |
+
height: 26px;
|
| 245 |
+
border: 2px solid var(--ping-color);
|
| 246 |
+
opacity: 0;
|
| 247 |
+
filter: drop-shadow(0 0 4px var(--ping-color));
|
| 248 |
+
animation: vs-bracket-pulse 520ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
| 249 |
+
}
|
| 250 |
+
.vs-bracket--tl {
|
| 251 |
+
top: 0;
|
| 252 |
+
left: 0;
|
| 253 |
+
border-right: 0;
|
| 254 |
+
border-bottom: 0;
|
| 255 |
+
}
|
| 256 |
+
.vs-bracket--tr {
|
| 257 |
+
top: 0;
|
| 258 |
+
right: 0;
|
| 259 |
+
border-left: 0;
|
| 260 |
+
border-bottom: 0;
|
| 261 |
+
}
|
| 262 |
+
.vs-bracket--bl {
|
| 263 |
+
bottom: 0;
|
| 264 |
+
left: 0;
|
| 265 |
+
border-right: 0;
|
| 266 |
+
border-top: 0;
|
| 267 |
+
}
|
| 268 |
+
.vs-bracket--br {
|
| 269 |
+
bottom: 0;
|
| 270 |
+
right: 0;
|
| 271 |
+
border-left: 0;
|
| 272 |
+
border-top: 0;
|
| 273 |
+
}
|
| 274 |
+
@keyframes vs-bracket-pulse {
|
| 275 |
+
0% {
|
| 276 |
+
opacity: 0;
|
| 277 |
+
transform: scale(1.45);
|
| 278 |
+
}
|
| 279 |
+
25% {
|
| 280 |
+
opacity: 1;
|
| 281 |
+
transform: scale(1);
|
| 282 |
+
}
|
| 283 |
+
70% {
|
| 284 |
+
opacity: 1;
|
| 285 |
+
}
|
| 286 |
+
100% {
|
| 287 |
+
opacity: 0;
|
| 288 |
+
transform: scale(1);
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
/* HUD chip: slides up from below the stage edge. */
|
| 293 |
+
.vs-chip {
|
| 294 |
+
animation: vs-chip-in 520ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
| 295 |
+
}
|
| 296 |
+
@keyframes vs-chip-in {
|
| 297 |
+
0% {
|
| 298 |
+
opacity: 0;
|
| 299 |
+
transform: translateY(14px);
|
| 300 |
+
}
|
| 301 |
+
22% {
|
| 302 |
+
opacity: 1;
|
| 303 |
+
transform: translateY(-2px);
|
| 304 |
+
}
|
| 305 |
+
38% {
|
| 306 |
+
transform: translateY(0);
|
| 307 |
+
}
|
| 308 |
+
78% {
|
| 309 |
+
opacity: 1;
|
| 310 |
+
}
|
| 311 |
+
100% {
|
| 312 |
+
opacity: 0;
|
| 313 |
+
transform: translateY(0);
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
@media (prefers-reduced-motion: reduce) {
|
| 318 |
+
.vs-beam,
|
| 319 |
+
.vs-vignette,
|
| 320 |
+
.vs-bracket,
|
| 321 |
+
.vs-chip {
|
| 322 |
+
animation-duration: 1ms;
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
</style>
|
src/lib/utils/format.ts
CHANGED
|
@@ -24,6 +24,16 @@ export function formatDuration(seconds: number): string {
|
|
| 24 |
return `${m}:${s.toString().padStart(2, '0')}`;
|
| 25 |
}
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
export function ticksToSeconds(ticks: number, tickRate = 64): number {
|
| 28 |
return ticks / tickRate;
|
| 29 |
}
|
|
|
|
| 24 |
return `${m}:${s.toString().padStart(2, '0')}`;
|
| 25 |
}
|
| 26 |
|
| 27 |
+
export function formatLongDuration(seconds: number): string {
|
| 28 |
+
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
|
| 29 |
+
const total = Math.round(seconds);
|
| 30 |
+
const h = Math.floor(total / 3600);
|
| 31 |
+
const m = Math.floor((total % 3600) / 60);
|
| 32 |
+
const s = total % 60;
|
| 33 |
+
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
| 34 |
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
export function ticksToSeconds(ticks: number, tickRate = 64): number {
|
| 38 |
return ticks / tickRate;
|
| 39 |
}
|
src/routes/+page.svelte
CHANGED
|
@@ -1,7 +1,18 @@
|
|
| 1 |
<script lang="ts">
|
| 2 |
import Header from '$lib/components/header.svelte';
|
| 3 |
-
import MatchCard from '$lib/components/match-card.svelte';
|
| 4 |
import { Skeleton } from '$lib/components/ui/skeleton';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import type { PageData } from './$types';
|
| 6 |
|
| 7 |
let { data }: { data: PageData } = $props();
|
|
@@ -24,6 +35,25 @@
|
|
| 24 |
PERSPECTIVES_PER_ROUND
|
| 25 |
);
|
| 26 |
const totalHours = $derived(totalSeconds / 3600);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</script>
|
| 28 |
|
| 29 |
<Header />
|
|
@@ -66,20 +96,56 @@
|
|
| 66 |
</section>
|
| 67 |
|
| 68 |
<section>
|
| 69 |
-
<
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
<
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
{:else}
|
| 78 |
-
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
| 79 |
-
{#each data.matches as match (match.match_id + '-' + match.map_name)}
|
| 80 |
-
<MatchCard {match} />
|
| 81 |
-
{/each}
|
| 82 |
</div>
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
</section>
|
| 85 |
</main>
|
|
|
|
| 1 |
<script lang="ts">
|
| 2 |
import Header from '$lib/components/header.svelte';
|
|
|
|
| 3 |
import { Skeleton } from '$lib/components/ui/skeleton';
|
| 4 |
+
import * as Tabs from '$lib/components/ui/tabs';
|
| 5 |
+
import MatchTable from '$lib/components/match-table/match-table.svelte';
|
| 6 |
+
import {
|
| 7 |
+
buildMapRows,
|
| 8 |
+
buildMatchRows,
|
| 9 |
+
buildRoundRows
|
| 10 |
+
} from '$lib/components/match-table/rows';
|
| 11 |
+
import {
|
| 12 |
+
mapColumns,
|
| 13 |
+
matchColumns,
|
| 14 |
+
roundColumns
|
| 15 |
+
} from '$lib/components/match-table/columns';
|
| 16 |
import type { PageData } from './$types';
|
| 17 |
|
| 18 |
let { data }: { data: PageData } = $props();
|
|
|
|
| 35 |
PERSPECTIVES_PER_ROUND
|
| 36 |
);
|
| 37 |
const totalHours = $derived(totalSeconds / 3600);
|
| 38 |
+
|
| 39 |
+
const roundRows = $derived(buildRoundRows(data.matches, data.rounds));
|
| 40 |
+
const mapRows = $derived(buildMapRows(data.matches, data.rounds));
|
| 41 |
+
const matchRows = $derived(buildMatchRows(data.matches, data.rounds));
|
| 42 |
+
|
| 43 |
+
const mapOptions = $derived(
|
| 44 |
+
Array.from(new Set(data.matches.map((m) => m.map_name))).sort()
|
| 45 |
+
);
|
| 46 |
+
|
| 47 |
+
const linkToMap = (m: { match_id: number; map_name: string }) =>
|
| 48 |
+
`/match/${encodeURIComponent(m.match_id)}/${encodeURIComponent(m.map_name)}`;
|
| 49 |
+
|
| 50 |
+
const linkToRound = (r: { match_id: number; map_name: string; round: number }) =>
|
| 51 |
+
`${linkToMap(r)}?round=${r.round}`;
|
| 52 |
+
|
| 53 |
+
const linkToFirstMap = (m: { match_id: number; first_map: string }) =>
|
| 54 |
+
`/match/${encodeURIComponent(m.match_id)}/${encodeURIComponent(m.first_map)}`;
|
| 55 |
+
|
| 56 |
+
let view = $state<'rounds' | 'maps' | 'matches'>('rounds');
|
| 57 |
</script>
|
| 58 |
|
| 59 |
<Header />
|
|
|
|
| 96 |
</section>
|
| 97 |
|
| 98 |
<section>
|
| 99 |
+
<Tabs.Root bind:value={view} class="w-full">
|
| 100 |
+
<div class="mb-2 flex items-center justify-between gap-2">
|
| 101 |
+
<h2 class="font-heading text-lg font-semibold tracking-tight">Browse the archive</h2>
|
| 102 |
+
<Tabs.List>
|
| 103 |
+
<Tabs.Trigger value="rounds">Rounds</Tabs.Trigger>
|
| 104 |
+
<Tabs.Trigger value="maps">Maps</Tabs.Trigger>
|
| 105 |
+
<Tabs.Trigger value="matches">Matches</Tabs.Trigger>
|
| 106 |
+
</Tabs.List>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
</div>
|
| 108 |
+
|
| 109 |
+
{#if data.matches.length === 0}
|
| 110 |
+
<div class="space-y-2">
|
| 111 |
+
{#each Array(8) as _, i (i)}
|
| 112 |
+
<Skeleton class="h-10 w-full" />
|
| 113 |
+
{/each}
|
| 114 |
+
</div>
|
| 115 |
+
{:else}
|
| 116 |
+
<Tabs.Content value="rounds">
|
| 117 |
+
<MatchTable
|
| 118 |
+
data={roundRows}
|
| 119 |
+
columns={roundColumns}
|
| 120 |
+
mapOptions={mapOptions}
|
| 121 |
+
searchPlaceholder="Search event, team, map…"
|
| 122 |
+
initialSorting={[{ id: 'match_date', desc: true }]}
|
| 123 |
+
getRowHref={linkToRound}
|
| 124 |
+
emptyMessage="No rounds yet. Round indexes load from the rounds parquet shards."
|
| 125 |
+
/>
|
| 126 |
+
</Tabs.Content>
|
| 127 |
+
|
| 128 |
+
<Tabs.Content value="maps">
|
| 129 |
+
<MatchTable
|
| 130 |
+
data={mapRows}
|
| 131 |
+
columns={mapColumns}
|
| 132 |
+
mapOptions={mapOptions}
|
| 133 |
+
searchPlaceholder="Search event, team, map…"
|
| 134 |
+
initialSorting={[{ id: 'match_date', desc: true }]}
|
| 135 |
+
getRowHref={linkToMap}
|
| 136 |
+
/>
|
| 137 |
+
</Tabs.Content>
|
| 138 |
+
|
| 139 |
+
<Tabs.Content value="matches">
|
| 140 |
+
<MatchTable
|
| 141 |
+
data={matchRows}
|
| 142 |
+
columns={matchColumns}
|
| 143 |
+
searchPlaceholder="Search event or team…"
|
| 144 |
+
initialSorting={[{ id: 'match_date', desc: true }]}
|
| 145 |
+
getRowHref={linkToFirstMap}
|
| 146 |
+
/>
|
| 147 |
+
</Tabs.Content>
|
| 148 |
+
{/if}
|
| 149 |
+
</Tabs.Root>
|
| 150 |
</section>
|
| 151 |
</main>
|
src/routes/match/[matchId]/[mapName]/+page.svelte
CHANGED
|
@@ -199,7 +199,9 @@
|
|
| 199 |
// New active video; let it drive the clock again unless we're
|
| 200 |
// already past its own duration (rare — guarded by availablePlayers).
|
| 201 |
activeVideoEnded = virtualTime >= (playerDurations.get(player) ?? 0) - 0.05;
|
| 202 |
-
|
|
|
|
|
|
|
| 203 |
markPlayerReady(player);
|
| 204 |
syncUrl();
|
| 205 |
}
|
|
@@ -521,6 +523,9 @@
|
|
| 521 |
round={currentRoundNum}
|
| 522 |
chunks={previews}
|
| 523 |
{virtualTime}
|
|
|
|
|
|
|
|
|
|
| 524 |
/>
|
| 525 |
</Card.Content>
|
| 526 |
</Card.Root>
|
|
|
|
| 199 |
// New active video; let it drive the clock again unless we're
|
| 200 |
// already past its own duration (rare — guarded by availablePlayers).
|
| 201 |
activeVideoEnded = virtualTime >= (playerDurations.get(player) ?? 0) - 0.05;
|
| 202 |
+
// Keep `bufferedRanges` as-is; the new video's onProgress will
|
| 203 |
+
// overwrite them within a frame or two. Clearing first makes the
|
| 204 |
+
// timeline buffered indicator flash off mid-swap.
|
| 205 |
markPlayerReady(player);
|
| 206 |
syncUrl();
|
| 207 |
}
|
|
|
|
| 523 |
round={currentRoundNum}
|
| 524 |
chunks={previews}
|
| 525 |
{virtualTime}
|
| 526 |
+
activePlayer={currentPlayer}
|
| 527 |
+
{availablePlayers}
|
| 528 |
+
onSelect={selectPlayer}
|
| 529 |
/>
|
| 530 |
</Card.Content>
|
| 531 |
</Card.Root>
|