Files
esports/src/lib/components/DataTable.svelte
Stan Runge d5448d9a94
Some checks failed
Test and deploy staging / staging (push) Failing after 14s
feat!: upgrade to SvelteKit 3 and adapter-bun
- run sv migrate sveltekit-3: config moves into vite.config.ts, $lib becomes
  #lib, $env/dynamic becomes $app/env/private via src/env.ts, $app/environment
  becomes $app/env, hook types move to @sveltejs/kit/hooks
- env vars stay optional (the migrator defaulted them to ''), and BANCHO_MODE,
  which it missed, is declared and passed to simulatorEnabled
- handleError only logs and masks unknown errors; Kit 3 now routes 404s and
  error(...) through it too
- json()/text() become Response.json()/new Response(); goto keepFocus/noScroll
  becomes reset: false; invalidateAll becomes refreshAll; page.url is copied
  before mutating
- replace adapter-node with @sveltejs/adapter-bun; prod-server.ts wraps
  Bun.serve to add native WebSockets (bunSockets) for /ws paths, since the
  generated server has no websocket option
- drop the Kit readdir patch: Kit 3 sorts route entries itself
- match view and broadcast pages use the live score modules

BREAKING CHANGE: requires SvelteKit 3, TypeScript 6 and Bun 1.4; the build
output is build/index.js from adapter-bun, started through prod-server.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:02:53 +00:00

258 lines
7.2 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" generics="T">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { Snippet } from 'svelte';
import type { TableMeta } from '#lib/server/table.js';
interface Column {
key: string;
label: string;
sortable?: boolean;
class?: string;
}
const {
data,
meta,
columns,
row,
searchPlaceholder = 'Search...',
layout = 'table'
}: {
data: T[];
meta: TableMeta;
columns: Column[];
row: Snippet<[T, number]>;
searchPlaceholder?: string;
/** `list` renders rows as cards (the row snippet gives each card), with sort buttons instead of a header. */
layout?: 'table' | 'list';
} = $props();
let searchInput = $derived.by(() => meta.search);
let debounceTimer: ReturnType<typeof setTimeout>;
const offset = $derived((meta.page - 1) * meta.limit);
const showingStart = $derived(meta.total === 0 ? 0 : offset + 1);
const showingEnd = $derived(Math.min(offset + meta.limit, meta.total));
const pageButtons = $derived.by(() => {
const total = meta.totalPages;
const current = meta.page;
const pages: (number | '...')[] = [];
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i);
} else {
pages.push(1);
if (current > 3) pages.push('...');
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
pages.push(i);
}
if (current < total - 2) pages.push('...');
pages.push(total);
}
return pages;
});
function updateParams(updates: Record<string, string>) {
const url = new URL(page.url.href);
for (const [key, value] of Object.entries(updates)) {
if (value) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
}
// reset to page 1 when changing search/sort/filter
if (!('page' in updates)) {
url.searchParams.set('page', '1');
}
goto(url.toString(), { reset: false });
}
function handleSearch() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
updateParams({ search: searchInput });
}, 300);
}
function toggleSort(key: string) {
if (meta.sortBy === key) {
updateParams({ sortBy: key, sortDir: meta.sortDir === 'asc' ? 'desc' : 'asc' });
} else {
updateParams({ sortBy: key, sortDir: 'desc' });
}
}
function goToPage(p: number) {
updateParams({ page: String(p) });
}
</script>
<!-- search bar -->
<div class="mb-4 flex items-center gap-3">
<div class="relative flex-1">
<svg
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-text-secondary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
bind:value={searchInput}
oninput={handleSearch}
placeholder={searchPlaceholder}
class="w-full rounded-md border border-border bg-surface-800 py-2 pr-3 pl-9 text-sm text-text-primary transition-colors outline-none placeholder:text-text-secondary focus:border-text-secondary"
/>
</div>
<span class="shrink-0 text-xs text-text-secondary"
>{meta.total} result{meta.total !== 1 ? 's' : ''}</span
>
</div>
{#if layout === 'list'}
{#if columns.some((col) => col.sortable)}
<div class="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-text-secondary">
<span>Sort by</span>
{#each columns.filter((col) => col.sortable) as col (col.key)}
<button
class="rounded-md border px-2 py-1 transition-colors {meta.sortBy === col.key
? 'border-white/20 bg-white/5 text-text-primary'
: 'border-transparent hover:text-text-primary'}"
onclick={() => toggleSort(col.key)}
>{col.label}{meta.sortBy === col.key
? meta.sortDir === 'asc'
? ' ↑'
: ' ↓'
: ''}</button
>
{/each}
</div>
{/if}
<div class="flex flex-col gap-2">
{#each data as item, i}
{@render row(item, i)}
{:else}
<p
class="rounded-xl border border-dashed border-white/10 px-4 py-8 text-center text-sm text-text-secondary"
>
{meta.search ? `No results for "${meta.search}"` : 'Nothing here yet'}
</p>
{/each}
</div>
{:else}
<!-- table -->
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full">
<thead>
<tr class="border-b border-border bg-surface-800">
{#each columns as col}
<th
class="{col.class ??
'px-4 text-left'} py-3 text-[11px] font-semibold tracking-wider text-text-secondary uppercase"
>
{#if col.sortable}
<button
class="inline-flex items-center gap-1 transition-colors hover:text-text-primary"
onclick={() => toggleSort(col.key)}
>
{col.label}
{#if meta.sortBy === col.key}
<svg
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="3"
>
{#if meta.sortDir === 'asc'}
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
{/if}
</svg>
{:else}
<svg
class="h-3 w-3 opacity-30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
/>
</svg>
{/if}
</button>
{:else}
{col.label}
{/if}
</th>
{/each}
</tr>
</thead>
<tbody class="divide-y divide-border/50">
{#each data as item, i}
{@render row(item, i)}
{:else}
<tr>
<td colspan={columns.length} class="px-4 py-8 text-center text-sm text-text-secondary">
{meta.search ? `No results for "${meta.search}"` : 'No data'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- pagination -->
{#if meta.totalPages > 1}
<div class="mt-4 flex items-center justify-between">
<span class="text-xs text-text-secondary">
Showing {showingStart}–{showingEnd} of {meta.total}
</span>
<div class="flex items-center gap-1">
<button
disabled={meta.page <= 1}
onclick={() => goToPage(meta.page - 1)}
class="rounded-md px-2.5 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-700 hover:text-text-primary disabled:pointer-events-none disabled:opacity-30"
>
Prev
</button>
{#each pageButtons as p}
{#if p === '...'}
<span class="px-1 text-xs text-text-secondary">...</span>
{:else}
<button
onclick={() => goToPage(p as number)}
class="rounded-md px-2.5 py-1.5 text-xs transition-colors {p === meta.page
? 'bg-accent font-semibold text-surface-900'
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
>
{p}
</button>
{/if}
{/each}
<button
disabled={meta.page >= meta.totalPages}
onclick={() => goToPage(meta.page + 1)}
class="rounded-md px-2.5 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-700 hover:text-text-primary disabled:pointer-events-none disabled:opacity-30"
>
Next
</button>
</div>
</div>
{/if}