Compare commits
10 Commits
bb6111e64f
...
ac890b8e55
| Author | SHA1 | Date | |
|---|---|---|---|
| ac890b8e55 | |||
| baff05a37b | |||
| 3a5c8570ee | |||
| 1ea168807f | |||
| 8d71480e9a | |||
| ae4a77f5dd | |||
| 25d2d1af4a | |||
| d76e8922cc | |||
| cd050f9974 | |||
| fec1e7c2f9 |
43
.env.example
43
.env.example
@@ -1,31 +1,52 @@
|
|||||||
|
# Copy this file to .env and fill in the values before running the application.
|
||||||
|
|
||||||
|
# The origin URL of the frontend application, used for CORS and constructing links
|
||||||
|
# In production, set it to your frontend URL, e.g. https://esports.vash.software
|
||||||
|
ORIGIN="http://localhost:5173"
|
||||||
|
|
||||||
|
# The email of the root admin user, with global full permissions.
|
||||||
ROOT_ADMIN_EMAIL=""
|
ROOT_ADMIN_EMAIL=""
|
||||||
|
|
||||||
# Docker Compose database password (used in docker-compose.yml)
|
# Docker Compose database password (used in docker-compose.yml)
|
||||||
DB_PASSWORD=""
|
DB_PASSWORD=""
|
||||||
|
|
||||||
DATABASE_URL="postgres://user:password@host:port/db-name"
|
DATABASE_URL="postgres://user:password@host:port/db-name"
|
||||||
|
|
||||||
ORIGIN=""
|
# Better Auth secret key, used for signing JWTs and other cryptographic operations.
|
||||||
|
|
||||||
# Better Auth
|
|
||||||
# For production use 32 characters and generated with high entropy
|
# For production use 32 characters and generated with high entropy
|
||||||
# https://www.better-auth.com/docs/installation
|
# https://www.better-auth.com/docs/installation
|
||||||
BETTER_AUTH_SECRET=""
|
BETTER_AUTH_SECRET=""
|
||||||
|
|
||||||
OSU_CLIENT_ID=""
|
|
||||||
OSU_CLIENT_SECRET=""
|
|
||||||
|
|
||||||
# Discord notifications
|
|
||||||
DISCORD_BOT_TOKEN=""
|
|
||||||
DISCORD_MATCH_CHANNEL_ID=""
|
|
||||||
|
|
||||||
# Cloudflare R2 image storage
|
# Cloudflare R2 image storage
|
||||||
|
# Create a new bucket and credentials at https://dash.cloudflare.com/r2
|
||||||
|
# Make sure to set the bucket has public access
|
||||||
R2_ACCOUNT_ID="" # Your Cloudflare Account ID
|
R2_ACCOUNT_ID="" # Your Cloudflare Account ID
|
||||||
R2_ACCESS_KEY_ID="" # R2 API token Access Key ID
|
R2_ACCESS_KEY_ID="" # R2 API token Access Key ID
|
||||||
R2_SECRET_ACCESS_KEY="" # R2 API token Secret Access Key
|
R2_SECRET_ACCESS_KEY="" # R2 API token Secret Access Key
|
||||||
R2_BUCKET="vash-esports-images" # Bucket name, e.g. vash-esports-images
|
R2_BUCKET="vash-esports-images" # Bucket name, e.g. vash-esports-images
|
||||||
R2_PUBLIC_URL="https://cdn.esports.vash.software" # Public URL for the bucket, e.g. https://cdn.esports.vash.software
|
R2_PUBLIC_URL="https://cdn.esports.vash.software" # Public URL for the bucket, e.g. https://cdn.esports.vash.software
|
||||||
|
|
||||||
|
# osu!-related
|
||||||
|
# Create an osu! API application here: https://osu.ppy.sh/home/account/edit#oauth
|
||||||
|
OSU_CLIENT_ID=""
|
||||||
|
OSU_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
# Discord-related
|
||||||
|
# Create a Discord application here: https://discord.com/developers/applications
|
||||||
|
DISCORD_CLIENT_ID=""
|
||||||
|
DISCORD_CLIENT_SECRET=""
|
||||||
|
DISCORD_BOT_TOKEN=""
|
||||||
|
DISCORD_MATCH_CHANNEL_ID=""
|
||||||
|
|
||||||
|
# GitHub-related
|
||||||
|
# Create a GitHub OAuth app here: https://github.com/settings/developers
|
||||||
|
GITHUB_CLIENT_ID=""
|
||||||
|
GITHUB_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
# Google-related
|
||||||
|
# Create a Google OAuth app here: https://console.cloud.google.com/apis/credentials
|
||||||
|
GOOGLE_CLIENT_ID=""
|
||||||
|
GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
# Sentry error tracking (https://sentry.io)
|
# Sentry error tracking (https://sentry.io)
|
||||||
PUBLIC_SENTRY_DSN="" # DSN is write-only, safe to expose client-side
|
PUBLIC_SENTRY_DSN="" # DSN is write-only, safe to expose client-side
|
||||||
SENTRY_AUTH_TOKEN="" # Only needed in CI for source map uploads
|
SENTRY_AUTH_TOKEN="" # Only needed in CI for source map uploads
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,3 +27,5 @@ test-results
|
|||||||
|
|
||||||
# Dozzle
|
# Dozzle
|
||||||
dozzle-users.yml
|
dozzle-users.yml
|
||||||
|
|
||||||
|
nvim.log
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ services:
|
|||||||
R2_PUBLIC_URL: ${R2_PUBLIC_URL:-https://cdn.esports.vash.software}
|
R2_PUBLIC_URL: ${R2_PUBLIC_URL:-https://cdn.esports.vash.software}
|
||||||
ROOT_ADMIN_EMAIL: ${ROOT_ADMIN_EMAIL}
|
ROOT_ADMIN_EMAIL: ${ROOT_ADMIN_EMAIL}
|
||||||
PUBLIC_SENTRY_DSN: ${PUBLIC_SENTRY_DSN}
|
PUBLIC_SENTRY_DSN: ${PUBLIC_SENTRY_DSN}
|
||||||
|
ADDRESS_HEADER: CF-Connecting-IP
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
env_file:
|
env_file:
|
||||||
- .env.staging
|
- .env.staging
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ services:
|
|||||||
OSU_IRC_USERNAME: ${OSU_IRC_USERNAME}
|
OSU_IRC_USERNAME: ${OSU_IRC_USERNAME}
|
||||||
OSU_IRC_PASSWORD: ${OSU_IRC_PASSWORD}
|
OSU_IRC_PASSWORD: ${OSU_IRC_PASSWORD}
|
||||||
PUBLIC_SENTRY_DSN: ${PUBLIC_SENTRY_DSN}
|
PUBLIC_SENTRY_DSN: ${PUBLIC_SENTRY_DSN}
|
||||||
|
ADDRESS_HEADER: CF-Connecting-IP
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|||||||
51
package.json
51
package.json
@@ -23,44 +23,44 @@
|
|||||||
"auth:schema": "bun x @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
|
"auth:schema": "bun x @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^20.4.4",
|
"@commitlint/cli": "^20.5.0",
|
||||||
"@commitlint/config-conventional": "^20.4.4",
|
"@commitlint/config-conventional": "^20.5.0",
|
||||||
"@eslint/compat": "^2.0.2",
|
"@eslint/compat": "^2.0.3",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.58.1",
|
"@playwright/test": "^1.58.2",
|
||||||
"@sentry/sveltekit": "^10.43.0",
|
"@sentry/sveltekit": "^10.46.0",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/kit": "^2.55.0",
|
"@sveltejs/kit": "^2.55.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/bun": "^1.3.9",
|
"@types/bun": "^1.3.11",
|
||||||
"@types/node": "^22",
|
"@types/node": "^25.5.0",
|
||||||
"@vitest/browser-playwright": "^4.1.0",
|
"@vitest/browser-playwright": "^4.1.1",
|
||||||
"better-auth": "^1.4.18",
|
"better-auth": "^1.5.6",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.10",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^10.1.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.14.0",
|
"eslint-plugin-svelte": "^3.16.0",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.4.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.3.3",
|
"lint-staged": "^16.4.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"playwright": "^1.58.1",
|
"playwright": "^1.58.2",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"svelte": "^5.51.0",
|
"svelte": "^5.55.0",
|
||||||
"svelte-check": "^4.4.5",
|
"svelte-check": "^4.4.5",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "~5.8",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.57.2",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.2",
|
||||||
"vite-plugin-devtools-json": "^1.0.0",
|
"vite-plugin-devtools-json": "^1.0.0",
|
||||||
"vitest": "^4.1.0",
|
"vitest": "^4.1.1",
|
||||||
"vitest-browser-svelte": "^2.1.0"
|
"vitest-browser-svelte": "^2.1.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
@@ -74,6 +74,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bancho.js": "^0.12.3",
|
"bancho.js": "^0.12.3",
|
||||||
"discord.js": "^14.25.1"
|
"discord.js": "^14.25.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -898,12 +898,10 @@ async function seedMappools(users: UserData[]): Promise<PoolData[]> {
|
|||||||
|
|
||||||
function selectPoolForElo(pools: PoolData[], avgElo: number): PoolData {
|
function selectPoolForElo(pools: PoolData[], avgElo: number): PoolData {
|
||||||
const targetSr = Math.max(2, Math.min(8, 2 + avgElo / 700));
|
const targetSr = Math.max(2, Math.min(8, 2 + avgElo / 700));
|
||||||
let best = pools[0];
|
let bestDist = Math.abs(pools[0].avgSr - targetSr);
|
||||||
let bestDist = Math.abs(best.avgSr - targetSr);
|
|
||||||
for (const p of pools) {
|
for (const p of pools) {
|
||||||
const dist = Math.abs(p.avgSr - targetSr);
|
const dist = Math.abs(p.avgSr - targetSr);
|
||||||
if (dist < bestDist) {
|
if (dist < bestDist) {
|
||||||
best = p;
|
|
||||||
bestDist = dist;
|
bestDist = dist;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -996,8 +994,8 @@ async function seedMatches(
|
|||||||
let matchState: string;
|
let matchState: string;
|
||||||
const winnerId: string | null = null;
|
const winnerId: string | null = null;
|
||||||
let finishedAt: Date | null = null;
|
let finishedAt: Date | null = null;
|
||||||
let startedAt: Date | null = null;
|
let startedAt: Date | null;
|
||||||
let gamesToPlay = 0;
|
let gamesToPlay: number;
|
||||||
|
|
||||||
if (state === 'FINISHED') {
|
if (state === 'FINISHED') {
|
||||||
matchState = 'FINISHED';
|
matchState = 'FINISHED';
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ if (!building) {
|
|||||||
.then(({ initDMHandler }) => initDMHandler())
|
.then(({ initDMHandler }) => initDMHandler())
|
||||||
.catch((err) => log.dm.warn({ err: err.message }, 'DM handler init skipped'));
|
.catch((err) => log.dm.warn({ err: err.message }, 'DM handler init skipped'));
|
||||||
|
|
||||||
|
import('$lib/server/tournament/scheduler')
|
||||||
|
.then(({ startTournamentScheduler }) => startTournamentScheduler())
|
||||||
|
.catch((err) => log.tournament.warn({ err: err.message }, 'Tournament scheduler init skipped'));
|
||||||
|
|
||||||
|
import('$lib/server/discord/client')
|
||||||
|
.then(({ initDiscordClient }) => initDiscordClient())
|
||||||
|
.catch((err) => log.discord.warn({ err: err.message }, 'Discord client init skipped'));
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
log.http.error({ err: reason }, 'Unhandled rejection');
|
log.http.error({ err: reason }, 'Unhandled rejection');
|
||||||
});
|
});
|
||||||
@@ -38,6 +46,15 @@ if (!building) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Safe IP getter — returns null for internal requests missing the ADDRESS_HEADER */
|
||||||
|
function getIp(event: Parameters<Handle>[0]['event']): string | null {
|
||||||
|
try {
|
||||||
|
return event.getClientAddress();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Request logging middleware ──
|
// ── Request logging middleware ──
|
||||||
// Paths that are too noisy to log every time (healthchecks, polling)
|
// Paths that are too noisy to log every time (healthchecks, polling)
|
||||||
const SILENT_PATHS = new Set(['/api/health', '/api/queue']);
|
const SILENT_PATHS = new Set(['/api/health', '/api/queue']);
|
||||||
@@ -70,7 +87,7 @@ const handleRequestLogging: Handle = async ({ event, resolve }) => {
|
|||||||
status,
|
status,
|
||||||
duration,
|
duration,
|
||||||
userId: userId ?? null,
|
userId: userId ?? null,
|
||||||
ip: event.getClientAddress()
|
ip: getIp(event)
|
||||||
},
|
},
|
||||||
`${event.request.method} ${path} ${status} ${duration}ms`
|
`${event.request.method} ${path} ${status} ${duration}ms`
|
||||||
);
|
);
|
||||||
@@ -87,9 +104,22 @@ const handleRequestLogging: Handle = async ({ event, resolve }) => {
|
|||||||
|
|
||||||
// ── Auth + rate limiting ──
|
// ── Auth + rate limiting ──
|
||||||
const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||||
// Rate limiting for API routes: 600 req/min per IP
|
// Rate limiting for API routes
|
||||||
if (event.url.pathname.startsWith('/api/')) {
|
if (event.url.pathname.startsWith('/api/')) {
|
||||||
const ip = event.getClientAddress();
|
const ip = getIp(event) ?? 'unknown';
|
||||||
|
|
||||||
|
// Stricter limit for auth endpoints: 10 req/min per IP
|
||||||
|
if (event.url.pathname.startsWith('/api/auth/')) {
|
||||||
|
const { ok, retryAfter } = checkRateLimit(`auth:${ip}`, 10, 60_000);
|
||||||
|
if (!ok) {
|
||||||
|
return new Response('Too Many Requests', {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': String(retryAfter ?? 60), 'Content-Type': 'text/plain' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// General API limit: 600 req/min per IP
|
||||||
const { ok, retryAfter } = checkRateLimit(`api:${ip}`, 600, 60_000);
|
const { ok, retryAfter } = checkRateLimit(`api:${ip}`, 600, 60_000);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return new Response('Too Many Requests', {
|
return new Response('Too Many Requests', {
|
||||||
|
|||||||
222
src/lib/components/DataTable.svelte
Normal file
222
src/lib/components/DataTable.svelte
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
interface Column {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
meta,
|
||||||
|
columns,
|
||||||
|
row,
|
||||||
|
searchPlaceholder = 'Search...'
|
||||||
|
}: {
|
||||||
|
data: T[];
|
||||||
|
meta: TableMeta;
|
||||||
|
columns: Column[];
|
||||||
|
row: Snippet<[T, number]>;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
} = $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);
|
||||||
|
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(), { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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}
|
||||||
114
src/lib/components/ProfileComments.svelte
Normal file
114
src/lib/components/ProfileComments.svelte
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
type Comment = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
author: { id: string; name: string; image: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { comments, actionUrl }: { comments: Comment[]; actionUrl: string } = $props();
|
||||||
|
|
||||||
|
const user = $derived(page.data?.user);
|
||||||
|
let content = $state('');
|
||||||
|
let posting = $state(false);
|
||||||
|
|
||||||
|
function timeAgo(date: string | Date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="font-600 text-sm text-text-secondary">Comments</h2>
|
||||||
|
|
||||||
|
{#if user}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={actionUrl}
|
||||||
|
use:enhance={() => {
|
||||||
|
posting = true;
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
posting = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
content = '';
|
||||||
|
await update();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
{#if user.image}
|
||||||
|
<img src={user.image} alt="" class="h-8 w-8 shrink-0 rounded-full" />
|
||||||
|
{:else}
|
||||||
|
<div class="h-8 w-8 shrink-0 rounded-full bg-surface-600"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1">
|
||||||
|
<textarea
|
||||||
|
name="content"
|
||||||
|
bind:value={content}
|
||||||
|
placeholder="Leave a comment..."
|
||||||
|
rows="2"
|
||||||
|
class="w-full resize-none rounded-lg border border-border bg-surface-800 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||||
|
></textarea>
|
||||||
|
<div class="mt-1.5 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={posting || !content.trim()}
|
||||||
|
class="font-600 rounded-md bg-accent px-3 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{posting ? 'Posting...' : 'Post'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-3 text-sm text-text-secondary">Sign in to leave a comment.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if comments.length > 0}
|
||||||
|
<div class="mt-4 flex flex-col gap-3">
|
||||||
|
{#each comments as comment}
|
||||||
|
<div class="flex gap-3">
|
||||||
|
{#if comment.author.image}
|
||||||
|
<a href="/users/{comment.author.id}" class="shrink-0">
|
||||||
|
<img src={comment.author.image} alt="" class="h-8 w-8 rounded-full" />
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a href="/users/{comment.author.id}" class="shrink-0">
|
||||||
|
<div
|
||||||
|
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||||
|
>
|
||||||
|
{comment.author.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/users/{comment.author.id}" class="font-600 text-sm hover:text-accent"
|
||||||
|
>{comment.author.name}</a
|
||||||
|
>
|
||||||
|
<span class="text-[10px] text-text-secondary/50">{timeAgo(comment.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-sm break-words whitespace-pre-wrap text-text-secondary">
|
||||||
|
{comment.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if !user}
|
||||||
|
<p class="mt-4 text-sm text-text-secondary/50">No comments yet.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
253
src/lib/components/SearchModal.svelte
Normal file
253
src/lib/components/SearchModal.svelte
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
type: string;
|
||||||
|
href: string;
|
||||||
|
image?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open = $bindable(false) }: { open: boolean } = $props();
|
||||||
|
let query = $state('');
|
||||||
|
let results = $state<Record<string, SearchResult[]>>({});
|
||||||
|
let loading = $state(false);
|
||||||
|
let selectedIndex = $state(0);
|
||||||
|
let inputRef = $state<HTMLInputElement | null>(null);
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const flatResults = $derived(Object.values(results).flat());
|
||||||
|
|
||||||
|
const typeIcons: Record<string, string> = {
|
||||||
|
users: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||||
|
teams:
|
||||||
|
'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||||
|
mappools:
|
||||||
|
'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3',
|
||||||
|
tournaments:
|
||||||
|
'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
|
||||||
|
matches:
|
||||||
|
'M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
users: 'Users',
|
||||||
|
teams: 'Teams',
|
||||||
|
mappools: 'Mappools',
|
||||||
|
tournaments: 'Tournaments',
|
||||||
|
matches: 'Matches'
|
||||||
|
};
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
open = true;
|
||||||
|
query = '';
|
||||||
|
results = {};
|
||||||
|
selectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus input whenever modal opens (whether via cmd+k or click)
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
// wait for DOM to render the modal
|
||||||
|
setTimeout(() => inputRef?.focus(), 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
open = false;
|
||||||
|
query = '';
|
||||||
|
results = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (open) {
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
openModal();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape' && open) {
|
||||||
|
e.preventDefault();
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeModal();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, flatResults.length - 1);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||||
|
} else if (e.key === 'Enter' && flatResults[selectedIndex]) {
|
||||||
|
e.preventDefault();
|
||||||
|
navigateTo(flatResults[selectedIndex].href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(href: string) {
|
||||||
|
closeModal();
|
||||||
|
goto(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (query.length < 2) {
|
||||||
|
results = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
results = data.results;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
selectedIndex = 0;
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
// compute a flat index for each item to support keyboard selection
|
||||||
|
function getFlatIndex(type: string, itemIndex: number): number {
|
||||||
|
const types = Object.keys(results);
|
||||||
|
let idx = 0;
|
||||||
|
for (const t of types) {
|
||||||
|
if (t === type) return idx + itemIndex;
|
||||||
|
idx += results[t].length;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- backdrop -->
|
||||||
|
<button
|
||||||
|
class="fixed inset-0 z-[60] bg-black/60 backdrop-blur-sm"
|
||||||
|
onclick={closeModal}
|
||||||
|
aria-label="Close search"
|
||||||
|
tabindex="-1"
|
||||||
|
></button>
|
||||||
|
|
||||||
|
<!-- modal -->
|
||||||
|
<div
|
||||||
|
class="fixed top-[15%] left-1/2 z-[60] w-full max-w-lg -translate-x-1/2 rounded-xl border border-border bg-surface-800 shadow-2xl"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Search"
|
||||||
|
onkeydown={handleModalKeydown}
|
||||||
|
>
|
||||||
|
<!-- search input -->
|
||||||
|
<div class="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 shrink-0 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:this={inputRef}
|
||||||
|
bind:value={query}
|
||||||
|
placeholder="Search users, teams, tournaments..."
|
||||||
|
class="flex-1 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-secondary"
|
||||||
|
/>
|
||||||
|
{#if loading}
|
||||||
|
<div
|
||||||
|
class="h-4 w-4 animate-spin rounded-full border-2 border-text-secondary border-t-transparent"
|
||||||
|
></div>
|
||||||
|
{:else}
|
||||||
|
<kbd
|
||||||
|
class="rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-medium text-text-secondary"
|
||||||
|
>ESC</kbd
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- results -->
|
||||||
|
<div class="max-h-80 overflow-y-auto">
|
||||||
|
{#if query.length < 2}
|
||||||
|
<div class="px-4 py-8 text-center text-sm text-text-secondary">
|
||||||
|
Type at least 2 characters to search
|
||||||
|
</div>
|
||||||
|
{:else if !loading && flatResults.length === 0 && query.length >= 2}
|
||||||
|
<div class="px-4 py-8 text-center text-sm text-text-secondary">
|
||||||
|
No results for "{query}"
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each Object.entries(results) as [type, items]}
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="px-4 pt-3 pb-1">
|
||||||
|
<span class="text-[10px] font-semibold tracking-wider text-text-secondary uppercase"
|
||||||
|
>{typeLabels[type] ?? type}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#each items as item, i}
|
||||||
|
{@const flatIdx = getFlatIndex(type, i)}
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm transition-colors {flatIdx ===
|
||||||
|
selectedIndex
|
||||||
|
? 'bg-surface-700 text-text-primary'
|
||||||
|
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
|
||||||
|
onclick={() => navigateTo(item.href)}
|
||||||
|
onmouseenter={() => (selectedIndex = flatIdx)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d={typeIcons[type] ?? ''} />
|
||||||
|
</svg>
|
||||||
|
{#if item.image || item.avatarUrl}
|
||||||
|
<img src={item.image ?? item.avatarUrl} alt="" class="h-5 w-5 rounded-full" />
|
||||||
|
{/if}
|
||||||
|
<span class="flex-1 truncate">{item.name ?? 'Unnamed'}</span>
|
||||||
|
{#if item.state}
|
||||||
|
<span
|
||||||
|
class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-medium text-text-secondary"
|
||||||
|
>{item.state}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- footer -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 border-t border-border px-4 py-2 text-[10px] text-text-secondary"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<kbd class="rounded bg-surface-700 px-1 py-0.5 font-mono">↑↓</kbd> navigate
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<kbd class="rounded bg-surface-700 px-1 py-0.5 font-mono">↵</kbd> select
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<kbd class="rounded bg-surface-700 px-1 py-0.5 font-mono">esc</kbd> close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
const activeMatch = $derived(page.data?.activeMatch);
|
const activeMatch = $derived(page.data?.activeMatch);
|
||||||
const unreadCount = $derived((page.data as any)?.unreadNotificationCount ?? 0);
|
const unreadCount = $derived((page.data as any)?.unreadNotificationCount ?? 0);
|
||||||
|
|
||||||
let { open = $bindable(false) } = $props();
|
let { open = $bindable(false), guest = false } = $props();
|
||||||
let notifOpen = $state(false);
|
let notifOpen = $state(false);
|
||||||
let notifications = $state<any[]>([]);
|
let notifications = $state<any[]>([]);
|
||||||
let loadingNotifs = $state(false);
|
let loadingNotifs = $state(false);
|
||||||
@@ -106,6 +106,22 @@
|
|||||||
height="7"
|
height="7"
|
||||||
/><rect x="3" y="14" width="7" height="7" /></svg
|
/><rect x="3" y="14" width="7" height="7" /></svg
|
||||||
>
|
>
|
||||||
|
{:else if name === 'tournaments'}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" /><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" /><path
|
||||||
|
d="M4 22h16"
|
||||||
|
/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" /><path
|
||||||
|
d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"
|
||||||
|
/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" /></svg
|
||||||
|
>
|
||||||
{:else if name === 'matches'}
|
{:else if name === 'matches'}
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -216,7 +232,7 @@
|
|||||||
<span class="font-700 text-base font-bold tracking-tight text-text-primary">Vash Esports</span
|
<span class="font-700 text-base font-bold tracking-tight text-text-primary">Vash Esports</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
{#if user}
|
{#if user && !guest}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
onclick={toggleNotifications}
|
onclick={toggleNotifications}
|
||||||
@@ -314,6 +330,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Queue Button -->
|
<!-- Queue Button -->
|
||||||
|
{#if !guest}
|
||||||
<div class="px-3 pb-4">
|
<div class="px-3 pb-4">
|
||||||
{#if activeMatch && page.url.pathname !== `/matches/${activeMatch.id}`}
|
{#if activeMatch && page.url.pathname !== `/matches/${activeMatch.id}`}
|
||||||
<a
|
<a
|
||||||
@@ -365,10 +382,11 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Nav -->
|
<!-- Nav -->
|
||||||
<nav class="mt-0 flex flex-1 flex-col gap-0.5 px-3">
|
<nav class="mt-0 flex flex-1 flex-col gap-0.5 px-3">
|
||||||
{#each [{ href: '/', label: 'Dashboard', icon: 'dashboard' }, { href: '/matches', label: 'Matches', icon: 'matches' }, { href: '/mappools', label: 'Mappools', icon: 'mappools' }, { href: '/teams', label: 'Teams', icon: 'teams' }, { href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' }] as item (item.href)}
|
{#each [{ href: '/', label: 'Dashboard', icon: 'dashboard' }, { href: '/tournaments', label: 'Tournaments', icon: 'tournaments' }, { href: '/matches', label: 'Matches', icon: 'matches' }, { href: '/mappools', label: 'Mappools', icon: 'mappools' }, { href: '/teams', label: 'Teams', icon: 'teams' }, { href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' }] as item (item.href)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
||||||
@@ -471,6 +489,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !guest}
|
||||||
<a
|
<a
|
||||||
href="/settings"
|
href="/settings"
|
||||||
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
||||||
@@ -482,6 +501,7 @@
|
|||||||
{@render navIcon('settings')}
|
{@render navIcon('settings')}
|
||||||
Settings
|
Settings
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
<div class="mt-2 flex gap-3 px-3 pb-1">
|
<div class="mt-2 flex gap-3 px-3 pb-1">
|
||||||
<a
|
<a
|
||||||
href="/terms"
|
href="/terms"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
export * from './auth.schema';
|
export * from './auth.schema';
|
||||||
|
import { user } from './auth.schema';
|
||||||
|
|
||||||
// ── Teams ───────────────────────────────────────────────────────────────
|
// ── Teams ───────────────────────────────────────────────────────────────
|
||||||
export const team = pgTable('team', {
|
export const team = pgTable('team', {
|
||||||
@@ -254,6 +255,183 @@ export const matchInvite = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Tournaments ────────────────────────────────────────────────────────
|
||||||
|
export const tournament = pgTable(
|
||||||
|
'tournament',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
format: text('format').notNull(), // single_elim | double_elim | groups_bracket
|
||||||
|
state: text('state').default('DRAFT').notNull(),
|
||||||
|
config: jsonb('config').notNull(),
|
||||||
|
maxSlots: integer('max_slots').notNull(),
|
||||||
|
bannerUrl: text('banner_url'),
|
||||||
|
createdBy: text('created_by').notNull(),
|
||||||
|
registrationOpenAt: timestamp('registration_open_at', { withTimezone: true }),
|
||||||
|
registrationCloseAt: timestamp('registration_close_at', { withTimezone: true }),
|
||||||
|
startAt: timestamp('start_at', { withTimezone: true }),
|
||||||
|
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||||
|
winnerId: uuid('winner_id').references(() => team.id),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('idx_tournament_state').on(t.state),
|
||||||
|
index('idx_tournament_created_at').on(t.createdAt)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Tournament Staff ───────────────────────────────────────────────────
|
||||||
|
export const tournamentStaff = pgTable(
|
||||||
|
'tournament_staff',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
tournamentId: uuid('tournament_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tournament.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
role: text('role').notNull() // organizer | admin | referee | pooler | streamer
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('idx_tstaff_tournament_user').on(t.tournamentId, t.userId),
|
||||||
|
index('idx_tstaff_tournament').on(t.tournamentId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Tournament Rounds ──────────────────────────────────────────────────
|
||||||
|
export const tournamentRound = pgTable(
|
||||||
|
'tournament_round',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
tournamentId: uuid('tournament_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tournament.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
abbreviation: text('abbreviation'),
|
||||||
|
roundOrder: integer('round_order').notNull(),
|
||||||
|
bestOf: integer('best_of').notNull(),
|
||||||
|
mappoolId: uuid('mappool_id').references(() => mappool.id),
|
||||||
|
bracketType: text('bracket_type'), // winners | losers | grand_final | group | null
|
||||||
|
scheduledAt: timestamp('scheduled_at', { withTimezone: true })
|
||||||
|
},
|
||||||
|
(t) => [index('idx_tround_tournament').on(t.tournamentId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Tournament Registrations ───────────────────────────────────────────
|
||||||
|
export const tournamentRegistration = pgTable(
|
||||||
|
'tournament_registration',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
tournamentId: uuid('tournament_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tournament.id, { onDelete: 'cascade' }),
|
||||||
|
teamId: uuid('team_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => team.id),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
seed: integer('seed'),
|
||||||
|
qualifierScore: real('qualifier_score'),
|
||||||
|
status: text('status').default('registered').notNull(),
|
||||||
|
registeredAt: timestamp('registered_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('idx_treg_tournament_team').on(t.tournamentId, t.teamId),
|
||||||
|
index('idx_treg_tournament').on(t.tournamentId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Tournament Matches (bracket structure) ─────────────────────────────
|
||||||
|
export const tournamentMatch = pgTable(
|
||||||
|
'tournament_match',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
tournamentId: uuid('tournament_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tournament.id, { onDelete: 'cascade' }),
|
||||||
|
roundId: uuid('round_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tournamentRound.id, { onDelete: 'cascade' }),
|
||||||
|
matchId: uuid('match_id').references(() => match.id),
|
||||||
|
bracketPosition: integer('bracket_position').notNull(),
|
||||||
|
winnerGoesToId: uuid('winner_goes_to_id'),
|
||||||
|
loserGoesToId: uuid('loser_goes_to_id'),
|
||||||
|
winnerSlot: integer('winner_slot'),
|
||||||
|
loserSlot: integer('loser_slot'),
|
||||||
|
team1Id: uuid('team1_id').references(() => team.id),
|
||||||
|
team2Id: uuid('team2_id').references(() => team.id),
|
||||||
|
winnerId: uuid('winner_id').references(() => team.id),
|
||||||
|
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
|
||||||
|
state: text('state').default('PENDING').notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('idx_tmatch_tournament').on(t.tournamentId),
|
||||||
|
index('idx_tmatch_round').on(t.roundId),
|
||||||
|
index('idx_tmatch_match').on(t.matchId),
|
||||||
|
index('idx_tmatch_state').on(t.state)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Tournament Groups ──────────────────────────────────────────────────
|
||||||
|
export const tournamentGroup = pgTable(
|
||||||
|
'tournament_group',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
tournamentId: uuid('tournament_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tournament.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
groupOrder: integer('group_order').notNull()
|
||||||
|
},
|
||||||
|
(t) => [index('idx_tgroup_tournament').on(t.tournamentId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Tournament Group Entries ───────────────────────────────────────────
|
||||||
|
export const tournamentGroupEntry = pgTable(
|
||||||
|
'tournament_group_entry',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
groupId: uuid('group_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tournamentGroup.id, { onDelete: 'cascade' }),
|
||||||
|
teamId: uuid('team_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => team.id),
|
||||||
|
wins: integer('wins').default(0).notNull(),
|
||||||
|
losses: integer('losses').default(0).notNull(),
|
||||||
|
mapWins: integer('map_wins').default(0).notNull(),
|
||||||
|
mapLosses: integer('map_losses').default(0).notNull(),
|
||||||
|
seed: integer('seed')
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('idx_tge_group_team').on(t.groupId, t.teamId),
|
||||||
|
index('idx_tge_group').on(t.groupId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Tournament Qualifier Scores ────────────────────────────────────────
|
||||||
|
export const tournamentQualifierScore = pgTable(
|
||||||
|
'tournament_qualifier_score',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
tournamentId: uuid('tournament_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tournament.id, { onDelete: 'cascade' }),
|
||||||
|
teamId: uuid('team_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => team.id),
|
||||||
|
mappoolSlotId: uuid('mappool_slot_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => mappoolSlot.id),
|
||||||
|
totalScore: integer('total_score').notNull(),
|
||||||
|
accuracy: real('accuracy'),
|
||||||
|
playedAt: timestamp('played_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('idx_tqs_tournament_team').on(t.tournamentId, t.teamId),
|
||||||
|
index('idx_tqs_tournament').on(t.tournamentId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// ── Relations ───────────────────────────────────────────────────────────
|
// ── Relations ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const teamRelations = relations(team, ({ many }) => ({
|
export const teamRelations = relations(team, ({ many }) => ({
|
||||||
@@ -325,3 +503,117 @@ export const matchInviteRelations = relations(matchInvite, ({ one }) => ({
|
|||||||
mappool: one(mappool, { fields: [matchInvite.mappoolId], references: [mappool.id] }),
|
mappool: one(mappool, { fields: [matchInvite.mappoolId], references: [mappool.id] }),
|
||||||
match: one(match, { fields: [matchInvite.matchId], references: [match.id] })
|
match: one(match, { fields: [matchInvite.matchId], references: [match.id] })
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ── Tournament Relations ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const tournamentRelations = relations(tournament, ({ many }) => ({
|
||||||
|
rounds: many(tournamentRound),
|
||||||
|
registrations: many(tournamentRegistration),
|
||||||
|
matches: many(tournamentMatch),
|
||||||
|
groups: many(tournamentGroup),
|
||||||
|
staff: many(tournamentStaff),
|
||||||
|
qualifierScores: many(tournamentQualifierScore)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const tournamentStaffRelations = relations(tournamentStaff, ({ one }) => ({
|
||||||
|
tournament: one(tournament, {
|
||||||
|
fields: [tournamentStaff.tournamentId],
|
||||||
|
references: [tournament.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const tournamentRoundRelations = relations(tournamentRound, ({ one, many }) => ({
|
||||||
|
tournament: one(tournament, {
|
||||||
|
fields: [tournamentRound.tournamentId],
|
||||||
|
references: [tournament.id]
|
||||||
|
}),
|
||||||
|
mappool: one(mappool, { fields: [tournamentRound.mappoolId], references: [mappool.id] }),
|
||||||
|
matches: many(tournamentMatch)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const tournamentRegistrationRelations = relations(tournamentRegistration, ({ one }) => ({
|
||||||
|
tournament: one(tournament, {
|
||||||
|
fields: [tournamentRegistration.tournamentId],
|
||||||
|
references: [tournament.id]
|
||||||
|
}),
|
||||||
|
team: one(team, { fields: [tournamentRegistration.teamId], references: [team.id] })
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const tournamentMatchRelations = relations(tournamentMatch, ({ one }) => ({
|
||||||
|
tournament: one(tournament, {
|
||||||
|
fields: [tournamentMatch.tournamentId],
|
||||||
|
references: [tournament.id]
|
||||||
|
}),
|
||||||
|
round: one(tournamentRound, {
|
||||||
|
fields: [tournamentMatch.roundId],
|
||||||
|
references: [tournamentRound.id]
|
||||||
|
}),
|
||||||
|
match: one(match, { fields: [tournamentMatch.matchId], references: [match.id] }),
|
||||||
|
team1: one(team, {
|
||||||
|
fields: [tournamentMatch.team1Id],
|
||||||
|
references: [team.id],
|
||||||
|
relationName: 'tournamentMatchTeam1'
|
||||||
|
}),
|
||||||
|
team2: one(team, {
|
||||||
|
fields: [tournamentMatch.team2Id],
|
||||||
|
references: [team.id],
|
||||||
|
relationName: 'tournamentMatchTeam2'
|
||||||
|
}),
|
||||||
|
winner: one(team, {
|
||||||
|
fields: [tournamentMatch.winnerId],
|
||||||
|
references: [team.id],
|
||||||
|
relationName: 'tournamentMatchWinner'
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const tournamentGroupRelations = relations(tournamentGroup, ({ one, many }) => ({
|
||||||
|
tournament: one(tournament, {
|
||||||
|
fields: [tournamentGroup.tournamentId],
|
||||||
|
references: [tournament.id]
|
||||||
|
}),
|
||||||
|
entries: many(tournamentGroupEntry)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const tournamentGroupEntryRelations = relations(tournamentGroupEntry, ({ one }) => ({
|
||||||
|
group: one(tournamentGroup, {
|
||||||
|
fields: [tournamentGroupEntry.groupId],
|
||||||
|
references: [tournamentGroup.id]
|
||||||
|
}),
|
||||||
|
team: one(team, { fields: [tournamentGroupEntry.teamId], references: [team.id] })
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Profile Comments ────────────────────────────────────────────────────
|
||||||
|
export const profileComment = pgTable(
|
||||||
|
'profile_comment',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
// 'user' or 'team'
|
||||||
|
targetType: text('target_type').notNull(),
|
||||||
|
targetId: text('target_id').notNull(),
|
||||||
|
authorId: text('author_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('idx_profile_comment_target').on(t.targetType, t.targetId),
|
||||||
|
index('idx_profile_comment_author').on(t.authorId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const profileCommentRelations = relations(profileComment, ({ one }) => ({
|
||||||
|
author: one(user, { fields: [profileComment.authorId], references: [user.id] })
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const tournamentQualifierScoreRelations = relations(tournamentQualifierScore, ({ one }) => ({
|
||||||
|
tournament: one(tournament, {
|
||||||
|
fields: [tournamentQualifierScore.tournamentId],
|
||||||
|
references: [tournament.id]
|
||||||
|
}),
|
||||||
|
team: one(team, { fields: [tournamentQualifierScore.teamId], references: [team.id] }),
|
||||||
|
slot: one(mappoolSlot, {
|
||||||
|
fields: [tournamentQualifierScore.mappoolSlotId],
|
||||||
|
references: [mappoolSlot.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ async function getChannel(channelId: string): Promise<TextChannel> {
|
|||||||
return channel as TextChannel;
|
return channel as TextChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function initDiscordClient(): Promise<void> {
|
||||||
|
if (!env.DISCORD_BOT_TOKEN) {
|
||||||
|
log.discord.warn('DISCORD_BOT_TOKEN not set, skipping Discord client init');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await getDiscordClient();
|
||||||
|
log.discord.info('Discord client connected');
|
||||||
|
}
|
||||||
|
|
||||||
export async function notifyMatchCreated(match: {
|
export async function notifyMatchCreated(match: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
|||||||
@@ -29,5 +29,6 @@ export const log = {
|
|||||||
rating: logger.child({ subsystem: 'rating' }),
|
rating: logger.child({ subsystem: 'rating' }),
|
||||||
admin: logger.child({ subsystem: 'admin' }),
|
admin: logger.child({ subsystem: 'admin' }),
|
||||||
timeout: logger.child({ subsystem: 'timeout' }),
|
timeout: logger.child({ subsystem: 'timeout' }),
|
||||||
http: logger.child({ subsystem: 'http' })
|
http: logger.child({ subsystem: 'http' }),
|
||||||
|
tournament: logger.child({ subsystem: 'tournament' })
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
winsNeeded as calcWinsNeeded
|
winsNeeded as calcWinsNeeded
|
||||||
} from './engine-logic';
|
} from './engine-logic';
|
||||||
import { getScoreMultiplier } from '$lib/mods';
|
import { getScoreMultiplier } from '$lib/mods';
|
||||||
|
import { tryAdvanceTournament } from '$lib/server/tournament/progression';
|
||||||
|
|
||||||
export { getMatchFull, getMatchOrThrow };
|
export { getMatchFull, getMatchOrThrow };
|
||||||
|
|
||||||
@@ -315,6 +316,9 @@ export async function submitGameScores(
|
|||||||
|
|
||||||
const finishedMatch = await getMatchFull(game.matchId);
|
const finishedMatch = await getMatchFull(game.matchId);
|
||||||
notifyMatchFinished(finishedMatch).catch(() => {});
|
notifyMatchFinished(finishedMatch).catch(() => {});
|
||||||
|
|
||||||
|
// advance tournament bracket if this is a tournament match
|
||||||
|
tryAdvanceTournament(game.matchId).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
await db.update(match).set({ state: MATCH_STATES.PICKING }).where(eq(match.id, game.matchId));
|
await db.update(match).set({ state: MATCH_STATES.PICKING }).where(eq(match.id, game.matchId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ export async function createInvite(opts: {
|
|||||||
sendDM(
|
sendDM(
|
||||||
u.name,
|
u.name,
|
||||||
`Match invite: ${title}. Accept/decline at https://esports.vash.software/matches or reply !accept ${invite.id.slice(0, 8)}`
|
`Match invite: ${title}. Accept/decline at https://esports.vash.software/matches or reply !accept ${invite.id.slice(0, 8)}`
|
||||||
).catch(() => {}); // Silent best-effort
|
).catch((err) => {
|
||||||
|
log.invites.warn({ err, username: u.name }, 'Failed to send Bancho DM for invite');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
// Mock the env module before importing permissions
|
// Mock the env module before importing permissions
|
||||||
vi.mock('$env/dynamic/private', () => ({
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
@@ -10,7 +10,8 @@ vi.mock('$lib/server/db', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/server/db/auth.schema', () => ({ user: {} }));
|
vi.mock('$lib/server/db/auth.schema', () => ({ user: {} }));
|
||||||
vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => ({})) }));
|
vi.mock('$lib/server/db/schema', () => ({ tournamentStaff: {} }));
|
||||||
|
vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => ({})), and: vi.fn(() => ({})) }));
|
||||||
|
|
||||||
// SvelteKit error function mock — throws an object with status/body like the real one
|
// SvelteKit error function mock — throws an object with status/body like the real one
|
||||||
vi.mock('@sveltejs/kit', () => ({
|
vi.mock('@sveltejs/kit', () => ({
|
||||||
|
|||||||
74
src/lib/server/permissions/constants.ts
Normal file
74
src/lib/server/permissions/constants.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// ── Global Permissions ──────────────────────────────────────────────────
|
||||||
|
export const GlobalPermission = {
|
||||||
|
// Match
|
||||||
|
MATCH_CREATE: 'match:create',
|
||||||
|
MATCH_REFEREE: 'match:referee',
|
||||||
|
MATCH_CANCEL_ANY: 'match:cancel_any',
|
||||||
|
|
||||||
|
// Mappool
|
||||||
|
MAPPOOL_CREATE: 'mappool:create',
|
||||||
|
MAPPOOL_DELETE_ANY: 'mappool:delete_any',
|
||||||
|
|
||||||
|
// Tournament
|
||||||
|
TOURNAMENT_CREATE: 'tournament:create',
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
ADMIN_VIEW: 'admin:view',
|
||||||
|
ADMIN_MANAGE_USERS: 'admin:manage_users',
|
||||||
|
ADMIN_MANAGE_ROLES: 'admin:manage_roles',
|
||||||
|
ADMIN_CANCEL_MATCHES: 'admin:cancel_matches',
|
||||||
|
ADMIN_CLEAR_QUEUE: 'admin:clear_queue',
|
||||||
|
ADMIN_RESET_RATINGS: 'admin:reset_ratings',
|
||||||
|
ADMIN_CLEAR_DATA: 'admin:clear_data'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type GlobalPermission = (typeof GlobalPermission)[keyof typeof GlobalPermission];
|
||||||
|
|
||||||
|
const PLAYER_PERMISSIONS: Set<GlobalPermission> = new Set([GlobalPermission.MAPPOOL_CREATE]);
|
||||||
|
|
||||||
|
const REFEREE_PERMISSIONS: Set<GlobalPermission> = new Set([
|
||||||
|
...PLAYER_PERMISSIONS,
|
||||||
|
GlobalPermission.MATCH_CREATE,
|
||||||
|
GlobalPermission.MATCH_REFEREE,
|
||||||
|
GlobalPermission.TOURNAMENT_CREATE
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ADMIN_PERMISSIONS: Set<GlobalPermission> = new Set(
|
||||||
|
Object.values(GlobalPermission) as GlobalPermission[]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ROLE_PERMISSIONS: Record<string, Set<GlobalPermission>> = {
|
||||||
|
player: PLAYER_PERMISSIONS,
|
||||||
|
referee: REFEREE_PERMISSIONS,
|
||||||
|
admin: ADMIN_PERMISSIONS
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tournament-scoped Permissions ──────────────────────────────────────
|
||||||
|
export const TournamentPermission = {
|
||||||
|
MANAGE_SETTINGS: 'tournament:manage_settings',
|
||||||
|
MANAGE_STAFF: 'tournament:manage_staff',
|
||||||
|
MANAGE_ROUNDS: 'tournament:manage_rounds',
|
||||||
|
MANAGE_MAPPOOL: 'tournament:manage_mappool',
|
||||||
|
MANAGE_REGISTRATIONS: 'tournament:manage_registrations',
|
||||||
|
MANAGE_BRACKET: 'tournament:manage_bracket',
|
||||||
|
REFEREE_MATCHES: 'tournament:referee_matches',
|
||||||
|
STREAM: 'tournament:stream'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TournamentPermission = (typeof TournamentPermission)[keyof typeof TournamentPermission];
|
||||||
|
|
||||||
|
export const TOURNAMENT_ROLE_PERMISSIONS: Record<string, Set<TournamentPermission>> = {
|
||||||
|
organizer: new Set(Object.values(TournamentPermission) as TournamentPermission[]),
|
||||||
|
admin: new Set([
|
||||||
|
TournamentPermission.MANAGE_SETTINGS,
|
||||||
|
TournamentPermission.MANAGE_STAFF,
|
||||||
|
TournamentPermission.MANAGE_ROUNDS,
|
||||||
|
TournamentPermission.MANAGE_MAPPOOL,
|
||||||
|
TournamentPermission.MANAGE_REGISTRATIONS,
|
||||||
|
TournamentPermission.MANAGE_BRACKET,
|
||||||
|
TournamentPermission.REFEREE_MATCHES
|
||||||
|
]),
|
||||||
|
referee: new Set([TournamentPermission.REFEREE_MATCHES]),
|
||||||
|
pooler: new Set([TournamentPermission.MANAGE_MAPPOOL]),
|
||||||
|
streamer: new Set([TournamentPermission.STREAM])
|
||||||
|
};
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { user } from '$lib/server/db/auth.schema';
|
import { user } from '$lib/server/db/auth.schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { tournamentStaff } from '$lib/server/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
|
import {
|
||||||
|
ROLE_PERMISSIONS,
|
||||||
|
TOURNAMENT_ROLE_PERMISSIONS,
|
||||||
|
type GlobalPermission,
|
||||||
|
type TournamentPermission
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
export * from './constants';
|
||||||
|
|
||||||
|
// ── Legacy role types (kept for backward compat) ───────────────────────
|
||||||
export type UserRole = 'player' | 'referee' | 'admin';
|
export type UserRole = 'player' | 'referee' | 'admin';
|
||||||
|
|
||||||
const ROLE_HIERARCHY: Record<UserRole, number> = {
|
const ROLE_HIERARCHY: Record<UserRole, number> = {
|
||||||
@@ -12,27 +22,69 @@ const ROLE_HIERARCHY: Record<UserRole, number> = {
|
|||||||
admin: 2
|
admin: 2
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// ── Permission-based checks (preferred) ────────────────────────────────
|
||||||
* Check if a role meets the minimum required level.
|
|
||||||
*/
|
export function hasPermission(userRole: string | undefined, permission: GlobalPermission): boolean {
|
||||||
|
const perms = ROLE_PERMISSIONS[userRole ?? 'player'];
|
||||||
|
return perms?.has(permission) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requirePermission(
|
||||||
|
locals: App.Locals,
|
||||||
|
permission: GlobalPermission,
|
||||||
|
message = 'Insufficient permissions'
|
||||||
|
) {
|
||||||
|
if (!locals.user) {
|
||||||
|
error(401, 'Not logged in');
|
||||||
|
}
|
||||||
|
if (!hasPermission(locals.user.role, permission)) {
|
||||||
|
error(403, message);
|
||||||
|
}
|
||||||
|
return locals.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireTournamentPermission(
|
||||||
|
tournamentId: string,
|
||||||
|
userId: string,
|
||||||
|
permission: TournamentPermission,
|
||||||
|
message = 'Not authorized for this tournament action'
|
||||||
|
) {
|
||||||
|
const staff = await db.query.tournamentStaff.findFirst({
|
||||||
|
where: and(eq(tournamentStaff.tournamentId, tournamentId), eq(tournamentStaff.userId, userId))
|
||||||
|
});
|
||||||
|
if (!staff) {
|
||||||
|
error(403, 'Not a staff member of this tournament');
|
||||||
|
}
|
||||||
|
|
||||||
|
const perms = TOURNAMENT_ROLE_PERMISSIONS[staff.role];
|
||||||
|
if (!perms?.has(permission)) {
|
||||||
|
error(403, message);
|
||||||
|
}
|
||||||
|
return staff;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireTournamentStaffOrAdmin(
|
||||||
|
locals: App.Locals,
|
||||||
|
tournamentId: string,
|
||||||
|
permission: TournamentPermission,
|
||||||
|
message = 'Not authorized for this tournament action'
|
||||||
|
) {
|
||||||
|
const u = requireAuth(locals);
|
||||||
|
if (hasRole(u.role, 'admin')) return u;
|
||||||
|
await requireTournamentPermission(tournamentId, u.id, permission, message);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legacy role-based checks (still functional, prefer permission-based) ─
|
||||||
|
|
||||||
|
/** @deprecated Use hasPermission() for granular checks */
|
||||||
export function hasRole(userRole: string | undefined, minRole: UserRole): boolean {
|
export function hasRole(userRole: string | undefined, minRole: UserRole): boolean {
|
||||||
const level = ROLE_HIERARCHY[userRole as UserRole] ?? 0;
|
const level = ROLE_HIERARCHY[userRole as UserRole] ?? 0;
|
||||||
const required = ROLE_HIERARCHY[minRole];
|
const required = ROLE_HIERARCHY[minRole];
|
||||||
return level >= required;
|
return level >= required;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @deprecated Use requirePermission() for granular checks */
|
||||||
* Check if a user is the root admin (set via ROOT_ADMIN_EMAIL env var).
|
|
||||||
* The root admin can never be demoted and is the only one who can promote others to admin.
|
|
||||||
*/
|
|
||||||
export function isRootAdmin(email: string | undefined | null): boolean {
|
|
||||||
if (!email || !env.ROOT_ADMIN_EMAIL) return false;
|
|
||||||
return email.toLowerCase() === env.ROOT_ADMIN_EMAIL.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Throw 401/403 if user doesn't have the required role.
|
|
||||||
*/
|
|
||||||
export function requireRole(
|
export function requireRole(
|
||||||
locals: App.Locals,
|
locals: App.Locals,
|
||||||
minRole: UserRole,
|
minRole: UserRole,
|
||||||
@@ -46,9 +98,6 @@ export function requireRole(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Throw 401 if not logged in. Returns the user for convenience.
|
|
||||||
*/
|
|
||||||
export function requireAuth(locals: App.Locals) {
|
export function requireAuth(locals: App.Locals) {
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
error(401, 'Not logged in');
|
error(401, 'Not logged in');
|
||||||
@@ -56,9 +105,6 @@ export function requireAuth(locals: App.Locals) {
|
|||||||
return locals.user;
|
return locals.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check that the current user owns the resource OR is an admin.
|
|
||||||
*/
|
|
||||||
export function requireOwnerOrAdmin(
|
export function requireOwnerOrAdmin(
|
||||||
locals: App.Locals,
|
locals: App.Locals,
|
||||||
ownerId: string | null | undefined,
|
ownerId: string | null | undefined,
|
||||||
@@ -71,12 +117,13 @@ export function requireOwnerOrAdmin(
|
|||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Root admin ─────────────────────────────────────────────────────────
|
||||||
* Promote/demote a user's global role with full safety checks.
|
|
||||||
* - Only admins can change roles
|
export function isRootAdmin(email: string | undefined | null): boolean {
|
||||||
* - Only root admin can promote TO admin or demote FROM admin
|
if (!email || !env.ROOT_ADMIN_EMAIL) return false;
|
||||||
* - Root admin cannot be demoted by anyone
|
return email.toLowerCase() === env.ROOT_ADMIN_EMAIL.toLowerCase();
|
||||||
*/
|
}
|
||||||
|
|
||||||
export async function setUserRole(
|
export async function setUserRole(
|
||||||
actorLocals: App.Locals,
|
actorLocals: App.Locals,
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
@@ -93,17 +140,14 @@ export async function setUserRole(
|
|||||||
});
|
});
|
||||||
if (!target) error(404, 'User not found');
|
if (!target) error(404, 'User not found');
|
||||||
|
|
||||||
// Protect root admin from demotion
|
|
||||||
if (isRootAdmin(target.email)) {
|
if (isRootAdmin(target.email)) {
|
||||||
error(403, "Cannot change the root admin's role");
|
error(403, "Cannot change the root admin's role");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only root admin can promote TO admin
|
|
||||||
if (newRole === 'admin' && !isRootAdmin(actor.email)) {
|
if (newRole === 'admin' && !isRootAdmin(actor.email)) {
|
||||||
error(403, 'Only the root admin can promote users to admin');
|
error(403, 'Only the root admin can promote users to admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only root admin can demote FROM admin
|
|
||||||
if (target.role === 'admin' && newRole !== 'admin' && !isRootAdmin(actor.email)) {
|
if (target.role === 'admin' && newRole !== 'admin' && !isRootAdmin(actor.email)) {
|
||||||
error(403, 'Only the root admin can demote admins');
|
error(403, 'Only the root admin can demote admins');
|
||||||
}
|
}
|
||||||
@@ -111,9 +155,6 @@ export async function setUserRole(
|
|||||||
await db.update(user).set({ role: newRole }).where(eq(user.id, targetUserId));
|
await db.update(user).set({ role: newRole }).where(eq(user.id, targetUserId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a user's role from the database.
|
|
||||||
*/
|
|
||||||
export async function getUserRole(userId: string): Promise<UserRole> {
|
export async function getUserRole(userId: string): Promise<UserRole> {
|
||||||
const u = await db.query.user.findFirst({
|
const u = await db.query.user.findFirst({
|
||||||
where: eq(user.id, userId)
|
where: eq(user.id, userId)
|
||||||
@@ -121,9 +162,6 @@ export async function getUserRole(userId: string): Promise<UserRole> {
|
|||||||
return (u?.role as UserRole) ?? 'player';
|
return (u?.role as UserRole) ?? 'player';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-promote root admin on login if they're still 'player'.
|
|
||||||
*/
|
|
||||||
export async function ensureRootAdminRole(userRecord: { id: string; email: string; role: string }) {
|
export async function ensureRootAdminRole(userRecord: { id: string; email: string; role: string }) {
|
||||||
if (isRootAdmin(userRecord.email) && userRecord.role !== 'admin') {
|
if (isRootAdmin(userRecord.email) && userRecord.role !== 'admin') {
|
||||||
await db.update(user).set({ role: 'admin' }).where(eq(user.id, userRecord.id));
|
await db.update(user).set({ role: 'admin' }).where(eq(user.id, userRecord.id));
|
||||||
@@ -77,3 +77,28 @@ export async function proxyImage(osuUrl: string): Promise<string | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload raw image data to R2 under the given key.
|
||||||
|
* Returns the CDN URL or null if R2 is not configured.
|
||||||
|
*/
|
||||||
|
export async function uploadImage(
|
||||||
|
key: string,
|
||||||
|
data: ArrayBuffer | Uint8Array,
|
||||||
|
contentType: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!isConfigured()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r2 = await getClient();
|
||||||
|
const file = r2.file(key);
|
||||||
|
await file.write(data, { type: contentType });
|
||||||
|
knownKeys.add(key);
|
||||||
|
const cdnUrl = `${env.R2_PUBLIC_URL}/${key}`;
|
||||||
|
log.r2.info({ key }, 'uploaded');
|
||||||
|
return cdnUrl;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
log.r2.error({ err, key }, 'uploadImage failed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
61
src/lib/server/table.ts
Normal file
61
src/lib/server/table.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ilike, or, asc, desc, type SQL, type Column } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export interface TableParams {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
search: string;
|
||||||
|
sortBy: string;
|
||||||
|
sortDir: 'asc' | 'desc';
|
||||||
|
filter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableMeta {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
search: string;
|
||||||
|
sortBy: string;
|
||||||
|
sortDir: 'asc' | 'desc';
|
||||||
|
filter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTableParams(url: URL, defaults?: Partial<TableParams>): TableParams {
|
||||||
|
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1'));
|
||||||
|
const limit = Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(1, parseInt(url.searchParams.get('limit') ?? String(defaults?.limit ?? 25)))
|
||||||
|
);
|
||||||
|
const search = url.searchParams.get('search')?.trim() ?? '';
|
||||||
|
const sortBy = url.searchParams.get('sortBy') ?? defaults?.sortBy ?? '';
|
||||||
|
const sortDir = url.searchParams.get('sortDir') === 'asc' ? 'asc' : 'desc';
|
||||||
|
const filter = url.searchParams.get('filter') ?? defaults?.filter ?? '';
|
||||||
|
return { page, limit, search, sortBy, sortDir, filter };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSearchFilter(search: string, columns: Column[]): SQL | undefined {
|
||||||
|
if (!search) return undefined;
|
||||||
|
const pattern = `%${search}%`;
|
||||||
|
const conditions = columns.map((col) => ilike(col, pattern));
|
||||||
|
if (conditions.length === 0) return undefined;
|
||||||
|
if (conditions.length === 1) return conditions[0];
|
||||||
|
return or(...conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOrderBy(
|
||||||
|
sortBy: string,
|
||||||
|
sortDir: 'asc' | 'desc',
|
||||||
|
columnMap: Record<string, Column | SQL>,
|
||||||
|
fallback: Column | SQL
|
||||||
|
): SQL {
|
||||||
|
const col = columnMap[sortBy] ?? fallback;
|
||||||
|
return sortDir === 'asc' ? asc(col) : desc(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTableMeta(params: TableParams, total: number): TableMeta {
|
||||||
|
return {
|
||||||
|
...params,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / params.limit))
|
||||||
|
};
|
||||||
|
}
|
||||||
242
src/lib/server/tournament/bracket.test.ts
Normal file
242
src/lib/server/tournament/bracket.test.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
standardBracketSeeding,
|
||||||
|
nextPowerOf2,
|
||||||
|
generateSingleElimBracket,
|
||||||
|
generateDoubleElimBracket,
|
||||||
|
generateGroupAssignments,
|
||||||
|
generateRoundRobinPairings,
|
||||||
|
generateGroupStageMatches
|
||||||
|
} from './bracket';
|
||||||
|
|
||||||
|
describe('nextPowerOf2', () => {
|
||||||
|
it('returns correct values', () => {
|
||||||
|
expect(nextPowerOf2(1)).toBe(1);
|
||||||
|
expect(nextPowerOf2(2)).toBe(2);
|
||||||
|
expect(nextPowerOf2(3)).toBe(4);
|
||||||
|
expect(nextPowerOf2(5)).toBe(8);
|
||||||
|
expect(nextPowerOf2(8)).toBe(8);
|
||||||
|
expect(nextPowerOf2(9)).toBe(16);
|
||||||
|
expect(nextPowerOf2(17)).toBe(32);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('standardBracketSeeding', () => {
|
||||||
|
it('returns [1, 2] for size 2', () => {
|
||||||
|
expect(standardBracketSeeding(2)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct seeding for size 4', () => {
|
||||||
|
const seeds = standardBracketSeeding(4);
|
||||||
|
expect(seeds).toEqual([1, 4, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct seeding for size 8', () => {
|
||||||
|
const seeds = standardBracketSeeding(8);
|
||||||
|
// 1v8, 4v5, 2v7, 3v6
|
||||||
|
expect(seeds).toEqual([1, 8, 4, 5, 2, 7, 3, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures seed 1 and 2 are on opposite halves for size 16', () => {
|
||||||
|
const seeds = standardBracketSeeding(16);
|
||||||
|
expect(seeds).toHaveLength(16);
|
||||||
|
// seed 1 should be in top half, seed 2 in bottom half
|
||||||
|
const topHalf = seeds.slice(0, 8);
|
||||||
|
const bottomHalf = seeds.slice(8);
|
||||||
|
expect(topHalf).toContain(1);
|
||||||
|
expect(bottomHalf).toContain(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateSingleElimBracket', () => {
|
||||||
|
it('generates correct bracket for 4 teams', () => {
|
||||||
|
const slots = generateSingleElimBracket(4);
|
||||||
|
// 4 teams, 2 rounds, 3 total matches
|
||||||
|
expect(slots).toHaveLength(3);
|
||||||
|
|
||||||
|
const round0 = slots.filter((s) => s.roundIndex === 0);
|
||||||
|
const round1 = slots.filter((s) => s.roundIndex === 1);
|
||||||
|
expect(round0).toHaveLength(2);
|
||||||
|
expect(round1).toHaveLength(1);
|
||||||
|
|
||||||
|
// finals has no winnerGoesTo
|
||||||
|
expect(round1[0].winnerGoesTo).toBeNull();
|
||||||
|
|
||||||
|
// no byes
|
||||||
|
expect(slots.every((s) => !s.isBye)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates correct bracket for 8 teams', () => {
|
||||||
|
const slots = generateSingleElimBracket(8);
|
||||||
|
// 8 teams, 3 rounds, 7 matches
|
||||||
|
expect(slots).toHaveLength(7);
|
||||||
|
|
||||||
|
const round0 = slots.filter((s) => s.roundIndex === 0);
|
||||||
|
expect(round0).toHaveLength(4);
|
||||||
|
// all first round matches have seeds assigned
|
||||||
|
for (const slot of round0) {
|
||||||
|
expect(slot.team1Seed).not.toBeNull();
|
||||||
|
expect(slot.team2Seed).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles 6 teams with BYEs', () => {
|
||||||
|
const slots = generateSingleElimBracket(6);
|
||||||
|
// bracket size = 8, so 7 matches
|
||||||
|
expect(slots).toHaveLength(7);
|
||||||
|
|
||||||
|
const byes = slots.filter((s) => s.isBye);
|
||||||
|
// 2 byes (seeds 7 and 8 don't exist)
|
||||||
|
expect(byes).toHaveLength(2);
|
||||||
|
|
||||||
|
// verify top 2 seeds get byes
|
||||||
|
const byeSeeds = byes.flatMap((s) => [s.team1Seed, s.team2Seed]);
|
||||||
|
expect(byeSeeds).toContain(1);
|
||||||
|
expect(byeSeeds).toContain(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates correct progression links', () => {
|
||||||
|
const slots = generateSingleElimBracket(4);
|
||||||
|
const round0 = slots.filter((s) => s.roundIndex === 0);
|
||||||
|
|
||||||
|
// match 0 winner goes to round 1, position 0, slot 1
|
||||||
|
expect(round0[0].winnerGoesTo).toEqual({
|
||||||
|
roundIndex: 1,
|
||||||
|
bracketPosition: 0,
|
||||||
|
slot: 1
|
||||||
|
});
|
||||||
|
// match 1 winner goes to round 1, position 0, slot 2
|
||||||
|
expect(round0[1].winnerGoesTo).toEqual({
|
||||||
|
roundIndex: 1,
|
||||||
|
bracketPosition: 0,
|
||||||
|
slot: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all bracket types are winners', () => {
|
||||||
|
const slots = generateSingleElimBracket(8);
|
||||||
|
expect(slots.every((s) => s.bracketType === 'winners')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateDoubleElimBracket', () => {
|
||||||
|
it('generates correct bracket for 4 teams', () => {
|
||||||
|
const slots = generateDoubleElimBracket(4);
|
||||||
|
const wb = slots.filter((s) => s.bracketType === 'winners');
|
||||||
|
const lb = slots.filter((s) => s.bracketType === 'losers');
|
||||||
|
const gf = slots.filter((s) => s.bracketType === 'grand_final');
|
||||||
|
|
||||||
|
// WB: 2 rounds (2 + 1 = 3 matches)
|
||||||
|
expect(wb).toHaveLength(3);
|
||||||
|
// LB: 2*(2-1) = 2 rounds
|
||||||
|
expect(lb.length).toBeGreaterThan(0);
|
||||||
|
// GF: 2 matches (GF1 + GF2)
|
||||||
|
expect(gf).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates correct bracket for 8 teams', () => {
|
||||||
|
const slots = generateDoubleElimBracket(8);
|
||||||
|
const wb = slots.filter((s) => s.bracketType === 'winners');
|
||||||
|
const lb = slots.filter((s) => s.bracketType === 'losers');
|
||||||
|
const gf = slots.filter((s) => s.bracketType === 'grand_final');
|
||||||
|
|
||||||
|
// WB: 3 rounds (4 + 2 + 1 = 7 matches)
|
||||||
|
expect(wb).toHaveLength(7);
|
||||||
|
// GF: always 2
|
||||||
|
expect(gf).toHaveLength(2);
|
||||||
|
// LB: 2*(3-1) = 4 rounds
|
||||||
|
const lbRounds = new Set(lb.map((s) => s.roundIndex));
|
||||||
|
expect(lbRounds.size).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WB losers have loserGoesTo set', () => {
|
||||||
|
const slots = generateDoubleElimBracket(8);
|
||||||
|
const wb = slots.filter((s) => s.bracketType === 'winners');
|
||||||
|
// all WB matches should have loserGoesTo pointing to LB
|
||||||
|
for (const slot of wb) {
|
||||||
|
expect(slot.loserGoesTo).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GF1 links to GF2', () => {
|
||||||
|
const slots = generateDoubleElimBracket(8);
|
||||||
|
const gf1 = slots.find((s) => s.bracketType === 'grand_final' && s.roundIndex === 0);
|
||||||
|
expect(gf1).toBeDefined();
|
||||||
|
expect(gf1!.winnerGoesTo).toEqual({
|
||||||
|
roundIndex: 1,
|
||||||
|
bracketPosition: 0,
|
||||||
|
slot: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateGroupAssignments', () => {
|
||||||
|
it('snake seeds correctly for 8 teams, 2 groups', () => {
|
||||||
|
const groups = generateGroupAssignments(8, 2);
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
// Group A: 1, 4, 5, 8
|
||||||
|
expect(groups[0].teamSeeds).toEqual([1, 4, 5, 8]);
|
||||||
|
// Group B: 2, 3, 6, 7
|
||||||
|
expect(groups[1].teamSeeds).toEqual([2, 3, 6, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snake seeds correctly for 16 teams, 4 groups', () => {
|
||||||
|
const groups = generateGroupAssignments(16, 4);
|
||||||
|
expect(groups).toHaveLength(4);
|
||||||
|
// Group A: 1, 8, 9, 16
|
||||||
|
expect(groups[0].teamSeeds).toEqual([1, 8, 9, 16]);
|
||||||
|
// Group B: 2, 7, 10, 15
|
||||||
|
expect(groups[1].teamSeeds).toEqual([2, 7, 10, 15]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles uneven groups', () => {
|
||||||
|
const groups = generateGroupAssignments(7, 2);
|
||||||
|
// one group gets 4, other gets 3
|
||||||
|
const total = groups.reduce((sum, g) => sum + g.teamSeeds.length, 0);
|
||||||
|
expect(total).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateRoundRobinPairings', () => {
|
||||||
|
it('generates correct number of pairings for 4 teams', () => {
|
||||||
|
const pairings = generateRoundRobinPairings([1, 2, 3, 4]);
|
||||||
|
// C(4,2) = 6 matches
|
||||||
|
expect(pairings).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates correct number of pairings for 3 teams', () => {
|
||||||
|
const pairings = generateRoundRobinPairings([1, 2, 3]);
|
||||||
|
// C(3,2) = 3 matches
|
||||||
|
expect(pairings).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every team plays every other team exactly once', () => {
|
||||||
|
const teams = [1, 2, 3, 4];
|
||||||
|
const pairings = generateRoundRobinPairings(teams);
|
||||||
|
|
||||||
|
for (const a of teams) {
|
||||||
|
for (const b of teams) {
|
||||||
|
if (a >= b) continue;
|
||||||
|
const found = pairings.filter(
|
||||||
|
(p) =>
|
||||||
|
(p.team1Seed === a && p.team2Seed === b) || (p.team1Seed === b && p.team2Seed === a)
|
||||||
|
);
|
||||||
|
expect(found).toHaveLength(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateGroupStageMatches', () => {
|
||||||
|
it('generates groups and all round-robin matches', () => {
|
||||||
|
const { groups, matches } = generateGroupStageMatches(8, 2);
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
// 4 teams per group, C(4,2) = 6 matches per group, 12 total
|
||||||
|
expect(matches).toHaveLength(12);
|
||||||
|
// each match has a groupIndex
|
||||||
|
const group0Matches = matches.filter((m) => m.groupIndex === 0);
|
||||||
|
const group1Matches = matches.filter((m) => m.groupIndex === 1);
|
||||||
|
expect(group0Matches).toHaveLength(6);
|
||||||
|
expect(group1Matches).toHaveLength(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
397
src/lib/server/tournament/bracket.ts
Normal file
397
src/lib/server/tournament/bracket.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* Pure bracket generation logic - no DB, no side effects.
|
||||||
|
* Generates the structure of tournament brackets for single elim, double elim, and group stages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BracketSlot {
|
||||||
|
roundIndex: number;
|
||||||
|
bracketPosition: number;
|
||||||
|
team1Seed: number | null; // null = TBD (filled by previous match winner)
|
||||||
|
team2Seed: number | null;
|
||||||
|
winnerGoesTo: { roundIndex: number; bracketPosition: number; slot: 1 | 2 } | null;
|
||||||
|
loserGoesTo: { roundIndex: number; bracketPosition: number; slot: 1 | 2 } | null;
|
||||||
|
bracketType: 'winners' | 'losers' | 'grand_final';
|
||||||
|
isBye: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupAssignment {
|
||||||
|
groupIndex: number;
|
||||||
|
teamSeeds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMatch {
|
||||||
|
groupIndex: number;
|
||||||
|
team1Seed: number;
|
||||||
|
team2Seed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard tournament seeding placement for a bracket of given size.
|
||||||
|
* Ensures seed 1 and 2 are on opposite sides, high seeds are spread out.
|
||||||
|
* Returns array of seed numbers in bracket order (position 0 = top of bracket).
|
||||||
|
*/
|
||||||
|
export function standardBracketSeeding(size: number): number[] {
|
||||||
|
if (size < 2) return [1];
|
||||||
|
if (size === 2) return [1, 2];
|
||||||
|
|
||||||
|
const result: number[] = new Array(size);
|
||||||
|
result[0] = 1;
|
||||||
|
result[1] = 2;
|
||||||
|
|
||||||
|
// iteratively place seeds by splitting the bracket
|
||||||
|
for (let round = 1; round < Math.log2(size); round++) {
|
||||||
|
const count = 1 << round; // seeds already placed: 2, 4, 8...
|
||||||
|
const nextCount = count * 2;
|
||||||
|
const temp: number[] = new Array(nextCount);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
temp[i * 2] = result[i];
|
||||||
|
temp[i * 2 + 1] = nextCount + 1 - result[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < nextCount; i++) {
|
||||||
|
result[i] = temp[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the smallest power of 2 >= n
|
||||||
|
*/
|
||||||
|
export function nextPowerOf2(n: number): number {
|
||||||
|
let p = 1;
|
||||||
|
while (p < n) p *= 2;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single elimination bracket.
|
||||||
|
*/
|
||||||
|
export function generateSingleElimBracket(teamCount: number): BracketSlot[] {
|
||||||
|
const bracketSize = nextPowerOf2(teamCount);
|
||||||
|
const totalRounds = Math.log2(bracketSize);
|
||||||
|
const seeding = standardBracketSeeding(bracketSize);
|
||||||
|
const slots: BracketSlot[] = [];
|
||||||
|
|
||||||
|
// generate all rounds
|
||||||
|
for (let round = 0; round < totalRounds; round++) {
|
||||||
|
const matchesInRound = bracketSize / (2 << round);
|
||||||
|
|
||||||
|
for (let pos = 0; pos < matchesInRound; pos++) {
|
||||||
|
const isFirstRound = round === 0;
|
||||||
|
const isFinalRound = round === totalRounds - 1;
|
||||||
|
|
||||||
|
let team1Seed: number | null = null;
|
||||||
|
let team2Seed: number | null = null;
|
||||||
|
let isBye = false;
|
||||||
|
|
||||||
|
if (isFirstRound) {
|
||||||
|
const idx1 = pos * 2;
|
||||||
|
const idx2 = pos * 2 + 1;
|
||||||
|
team1Seed = seeding[idx1];
|
||||||
|
team2Seed = seeding[idx2];
|
||||||
|
// if either seed > teamCount, it's a BYE
|
||||||
|
isBye = team1Seed > teamCount || team2Seed > teamCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const winnerGoesTo = isFinalRound
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
roundIndex: round + 1,
|
||||||
|
bracketPosition: Math.floor(pos / 2),
|
||||||
|
slot: ((pos % 2) + 1) as 1 | 2
|
||||||
|
};
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
roundIndex: round,
|
||||||
|
bracketPosition: pos,
|
||||||
|
team1Seed,
|
||||||
|
team2Seed,
|
||||||
|
winnerGoesTo,
|
||||||
|
loserGoesTo: null,
|
||||||
|
bracketType: 'winners',
|
||||||
|
isBye
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a double elimination bracket.
|
||||||
|
* Winners bracket + losers bracket + grand finals (with potential bracket reset).
|
||||||
|
*/
|
||||||
|
export function generateDoubleElimBracket(teamCount: number): BracketSlot[] {
|
||||||
|
const bracketSize = nextPowerOf2(teamCount);
|
||||||
|
const wbRounds = Math.log2(bracketSize);
|
||||||
|
const lbRounds = 2 * (wbRounds - 1);
|
||||||
|
const seeding = standardBracketSeeding(bracketSize);
|
||||||
|
const slots: BracketSlot[] = [];
|
||||||
|
|
||||||
|
// helper to find a slot by type + round + position
|
||||||
|
const findSlot = (type: string, round: number, pos: number) =>
|
||||||
|
slots.find(
|
||||||
|
(s) => s.bracketType === type && s.roundIndex === round && s.bracketPosition === pos
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Generate Losers Bracket first (so we can reference it from WB) ──
|
||||||
|
// LB has alternating "feed-in" and "reduction" rounds
|
||||||
|
// LB round 0: WB R0 losers play each other (bracketSize/4 matches)
|
||||||
|
// LB round 1: LB R0 winners vs WB R1 losers (feed-in)
|
||||||
|
// LB round 2: LB R1 winners play each other (reduction)
|
||||||
|
// LB round 3: LB R2 winners vs WB R2 losers (feed-in)
|
||||||
|
// ... and so on
|
||||||
|
|
||||||
|
for (let lbRound = 0; lbRound < lbRounds; lbRound++) {
|
||||||
|
const isFeedIn = lbRound % 2 === 1;
|
||||||
|
// number of matches halves every 2 rounds
|
||||||
|
const matchesInRound = bracketSize / (2 << Math.floor((lbRound + 1) / 2));
|
||||||
|
|
||||||
|
for (let pos = 0; pos < matchesInRound; pos++) {
|
||||||
|
const isLastLbRound = lbRound === lbRounds - 1;
|
||||||
|
|
||||||
|
const winnerGoesTo = isLastLbRound
|
||||||
|
? null // will be linked to GF later
|
||||||
|
: {
|
||||||
|
roundIndex: lbRound + 1,
|
||||||
|
bracketPosition: isFeedIn ? pos : Math.floor(pos / 2),
|
||||||
|
slot: (isFeedIn ? 1 : (pos % 2) + 1) as 1 | 2
|
||||||
|
};
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
roundIndex: lbRound,
|
||||||
|
bracketPosition: pos,
|
||||||
|
team1Seed: null,
|
||||||
|
team2Seed: null,
|
||||||
|
winnerGoesTo,
|
||||||
|
loserGoesTo: null, // losers bracket losers are eliminated
|
||||||
|
bracketType: 'losers',
|
||||||
|
isBye: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generate Winners Bracket ──
|
||||||
|
for (let round = 0; round < wbRounds; round++) {
|
||||||
|
const matchesInRound = bracketSize / (2 << round);
|
||||||
|
|
||||||
|
for (let pos = 0; pos < matchesInRound; pos++) {
|
||||||
|
const isFirstRound = round === 0;
|
||||||
|
const isLastWbRound = round === wbRounds - 1;
|
||||||
|
|
||||||
|
let team1Seed: number | null = null;
|
||||||
|
let team2Seed: number | null = null;
|
||||||
|
let isBye = false;
|
||||||
|
|
||||||
|
if (isFirstRound) {
|
||||||
|
const idx1 = pos * 2;
|
||||||
|
const idx2 = pos * 2 + 1;
|
||||||
|
team1Seed = seeding[idx1];
|
||||||
|
team2Seed = seeding[idx2];
|
||||||
|
isBye = team1Seed > teamCount || team2Seed > teamCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const winnerGoesTo = isLastWbRound
|
||||||
|
? null // will be linked to GF later
|
||||||
|
: {
|
||||||
|
roundIndex: round + 1,
|
||||||
|
bracketPosition: Math.floor(pos / 2),
|
||||||
|
slot: ((pos % 2) + 1) as 1 | 2
|
||||||
|
};
|
||||||
|
|
||||||
|
// losers go to losers bracket
|
||||||
|
// WB R0 losers → LB R0, WB R1 losers → LB R2, WB R2 losers → LB R4, etc.
|
||||||
|
const lbTargetRound = round === 0 ? 0 : round * 2 - 1;
|
||||||
|
// determine position in LB - for feed-in rounds, position maps directly
|
||||||
|
// for the first WB round, losers pair up: WB match 0,1 → LB match 0; WB match 2,3 → LB match 1
|
||||||
|
let lbTargetPos: number;
|
||||||
|
let lbTargetSlot: 1 | 2;
|
||||||
|
|
||||||
|
if (round === 0) {
|
||||||
|
// WB R0 losers pair up in LB R0
|
||||||
|
lbTargetPos = Math.floor(pos / 2);
|
||||||
|
lbTargetSlot = ((pos % 2) + 1) as 1 | 2;
|
||||||
|
} else {
|
||||||
|
// WB R1+ losers enter feed-in rounds as slot 2
|
||||||
|
lbTargetPos = pos;
|
||||||
|
lbTargetSlot = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loserGoesTo = {
|
||||||
|
roundIndex: lbTargetRound,
|
||||||
|
bracketPosition: lbTargetPos,
|
||||||
|
slot: lbTargetSlot
|
||||||
|
};
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
roundIndex: round,
|
||||||
|
bracketPosition: pos,
|
||||||
|
team1Seed,
|
||||||
|
team2Seed,
|
||||||
|
winnerGoesTo,
|
||||||
|
loserGoesTo,
|
||||||
|
bracketType: 'winners',
|
||||||
|
isBye
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grand Finals ──
|
||||||
|
// GF1: WB winner vs LB winner
|
||||||
|
slots.push({
|
||||||
|
roundIndex: 0,
|
||||||
|
bracketPosition: 0,
|
||||||
|
team1Seed: null, // WB winner
|
||||||
|
team2Seed: null, // LB winner
|
||||||
|
winnerGoesTo: null, // conditionally goes to GF2
|
||||||
|
loserGoesTo: null,
|
||||||
|
bracketType: 'grand_final',
|
||||||
|
isBye: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// GF2 (bracket reset): only played if LB winner wins GF1
|
||||||
|
slots.push({
|
||||||
|
roundIndex: 1,
|
||||||
|
bracketPosition: 0,
|
||||||
|
team1Seed: null,
|
||||||
|
team2Seed: null,
|
||||||
|
winnerGoesTo: null,
|
||||||
|
loserGoesTo: null,
|
||||||
|
bracketType: 'grand_final',
|
||||||
|
isBye: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Wire up WB finals → GF1, LB finals → GF1 ──
|
||||||
|
const wbFinal = slots.find((s) => s.bracketType === 'winners' && s.roundIndex === wbRounds - 1);
|
||||||
|
if (wbFinal) {
|
||||||
|
wbFinal.winnerGoesTo = { roundIndex: 0, bracketPosition: 0, slot: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lbFinal = slots.find((s) => s.bracketType === 'losers' && s.roundIndex === lbRounds - 1);
|
||||||
|
if (lbFinal) {
|
||||||
|
lbFinal.winnerGoesTo = { roundIndex: 0, bracketPosition: 0, slot: 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GF1 winner → GF2 (conditionally)
|
||||||
|
const gf1 = slots.find((s) => s.bracketType === 'grand_final' && s.roundIndex === 0);
|
||||||
|
if (gf1) {
|
||||||
|
gf1.winnerGoesTo = { roundIndex: 1, bracketPosition: 0, slot: 1 };
|
||||||
|
gf1.loserGoesTo = { roundIndex: 1, bracketPosition: 0, slot: 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate group assignments using snake seeding.
|
||||||
|
* e.g. 4 groups, 16 teams:
|
||||||
|
* Group A: 1, 8, 9, 16
|
||||||
|
* Group B: 2, 7, 10, 15
|
||||||
|
* Group C: 3, 6, 11, 14
|
||||||
|
* Group D: 4, 5, 12, 13
|
||||||
|
*/
|
||||||
|
export function generateGroupAssignments(teamCount: number, groupCount: number): GroupAssignment[] {
|
||||||
|
const groups: GroupAssignment[] = Array.from({ length: groupCount }, (_, i) => ({
|
||||||
|
groupIndex: i,
|
||||||
|
teamSeeds: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (let seed = 1; seed <= teamCount; seed++) {
|
||||||
|
const row = Math.floor((seed - 1) / groupCount);
|
||||||
|
const col = (seed - 1) % groupCount;
|
||||||
|
// snake: even rows go left→right, odd rows go right→left
|
||||||
|
const groupIdx = row % 2 === 0 ? col : groupCount - 1 - col;
|
||||||
|
groups[groupIdx].teamSeeds.push(seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate round-robin pairings for a group of teams.
|
||||||
|
* Uses the circle method to ensure all teams play each other exactly once.
|
||||||
|
*/
|
||||||
|
export function generateRoundRobinPairings(teamSeeds: number[]): GroupMatch[] {
|
||||||
|
const matches: GroupMatch[] = [];
|
||||||
|
const n = teamSeeds.length;
|
||||||
|
|
||||||
|
// for round-robin with n teams, we need n-1 rounds (or n if n is odd, with byes)
|
||||||
|
// using the circle method
|
||||||
|
const teams = [...teamSeeds];
|
||||||
|
if (teams.length % 2 !== 0) {
|
||||||
|
teams.push(-1); // -1 = BYE
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = teams.length;
|
||||||
|
const rounds = count - 1;
|
||||||
|
const half = count / 2;
|
||||||
|
|
||||||
|
// fix team[0] and rotate the rest
|
||||||
|
for (let round = 0; round < rounds; round++) {
|
||||||
|
for (let i = 0; i < half; i++) {
|
||||||
|
const home = i === 0 ? teams[0] : teams[count - i];
|
||||||
|
const away = teams[i === 0 ? (count - 1 - round < 1 ? count - 1 : 0) : i];
|
||||||
|
|
||||||
|
// actually let's use a simpler approach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// simpler: just enumerate all unique pairs
|
||||||
|
for (let i = 0; i < teamSeeds.length; i++) {
|
||||||
|
for (let j = i + 1; j < teamSeeds.length; j++) {
|
||||||
|
matches.push({
|
||||||
|
groupIndex: 0, // caller sets this
|
||||||
|
team1Seed: teamSeeds[i],
|
||||||
|
team2Seed: teamSeeds[j]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all group stage matches for a tournament.
|
||||||
|
*/
|
||||||
|
export function generateGroupStageMatches(
|
||||||
|
teamCount: number,
|
||||||
|
groupCount: number
|
||||||
|
): { groups: GroupAssignment[]; matches: GroupMatch[] } {
|
||||||
|
const groups = generateGroupAssignments(teamCount, groupCount);
|
||||||
|
const allMatches: GroupMatch[] = [];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const pairings = generateRoundRobinPairings(group.teamSeeds);
|
||||||
|
for (const match of pairings) {
|
||||||
|
allMatches.push({ ...match, groupIndex: group.groupIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { groups, matches: allMatches };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the round name for a single/double elim bracket based on remaining teams.
|
||||||
|
*/
|
||||||
|
export function getRoundName(
|
||||||
|
matchesInRound: number,
|
||||||
|
bracketType: 'winners' | 'losers' | 'grand_final'
|
||||||
|
): string {
|
||||||
|
if (bracketType === 'grand_final') return 'Grand Finals';
|
||||||
|
|
||||||
|
const prefix = bracketType === 'losers' ? 'LB ' : '';
|
||||||
|
|
||||||
|
switch (matchesInRound) {
|
||||||
|
case 1:
|
||||||
|
return `${prefix}Finals`;
|
||||||
|
case 2:
|
||||||
|
return `${prefix}Semifinals`;
|
||||||
|
case 4:
|
||||||
|
return `${prefix}Quarterfinals`;
|
||||||
|
default:
|
||||||
|
return `${prefix}Round of ${matchesInRound * 2}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/lib/server/tournament/index.ts
Normal file
6
src/lib/server/tournament/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './bracket';
|
||||||
|
export * from './lifecycle';
|
||||||
|
export * from './progression';
|
||||||
|
export * from './scheduler';
|
||||||
|
export * from './qualifiers';
|
||||||
714
src/lib/server/tournament/lifecycle.ts
Normal file
714
src/lib/server/tournament/lifecycle.ts
Normal file
@@ -0,0 +1,714 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import {
|
||||||
|
tournament,
|
||||||
|
tournamentStaff,
|
||||||
|
tournamentRound,
|
||||||
|
tournamentRegistration,
|
||||||
|
tournamentMatch,
|
||||||
|
tournamentGroup,
|
||||||
|
tournamentGroupEntry,
|
||||||
|
match
|
||||||
|
} from '$lib/server/db/schema';
|
||||||
|
import { eq, and, inArray, asc } from 'drizzle-orm';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
import {
|
||||||
|
TOURNAMENT_STATES,
|
||||||
|
TOURNAMENT_MATCH_STATES,
|
||||||
|
TOURNAMENT_STAFF_ROLES,
|
||||||
|
type TournamentConfig,
|
||||||
|
type TournamentFormat
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
generateSingleElimBracket,
|
||||||
|
generateDoubleElimBracket,
|
||||||
|
generateGroupAssignments,
|
||||||
|
generateGroupStageMatches,
|
||||||
|
nextPowerOf2,
|
||||||
|
getRoundName,
|
||||||
|
type BracketSlot
|
||||||
|
} from './bracket';
|
||||||
|
import { processByes } from './progression';
|
||||||
|
|
||||||
|
// ── Create Tournament ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createTournament(opts: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
format: TournamentFormat;
|
||||||
|
config: TournamentConfig;
|
||||||
|
maxSlots: number;
|
||||||
|
bannerUrl?: string;
|
||||||
|
createdBy: string;
|
||||||
|
registrationOpenAt?: Date;
|
||||||
|
registrationCloseAt?: Date;
|
||||||
|
startAt?: Date;
|
||||||
|
}) {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(tournament)
|
||||||
|
.values({
|
||||||
|
name: opts.name,
|
||||||
|
description: opts.description,
|
||||||
|
format: opts.format,
|
||||||
|
config: opts.config,
|
||||||
|
maxSlots: opts.maxSlots,
|
||||||
|
bannerUrl: opts.bannerUrl,
|
||||||
|
createdBy: opts.createdBy,
|
||||||
|
registrationOpenAt: opts.registrationOpenAt,
|
||||||
|
registrationCloseAt: opts.registrationCloseAt,
|
||||||
|
startAt: opts.startAt,
|
||||||
|
state: TOURNAMENT_STATES.DRAFT
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// add creator as organizer
|
||||||
|
await db.insert(tournamentStaff).values({
|
||||||
|
tournamentId: created.id,
|
||||||
|
userId: opts.createdBy,
|
||||||
|
role: TOURNAMENT_STAFF_ROLES.ORGANIZER
|
||||||
|
});
|
||||||
|
|
||||||
|
log.tournament.info({ tournamentId: created.id, name: opts.name }, 'tournament created');
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open Registration ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function openRegistration(tournamentId: string) {
|
||||||
|
const t = await getTournamentOrThrow(tournamentId);
|
||||||
|
assertTournamentState(t.state, TOURNAMENT_STATES.DRAFT);
|
||||||
|
|
||||||
|
// validate at least one round exists
|
||||||
|
const rounds = await db.query.tournamentRound.findMany({
|
||||||
|
where: eq(tournamentRound.tournamentId, tournamentId)
|
||||||
|
});
|
||||||
|
if (rounds.length === 0) {
|
||||||
|
throw new Error('Cannot open registration: no rounds configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tournament)
|
||||||
|
.set({
|
||||||
|
state: TOURNAMENT_STATES.REGISTRATION,
|
||||||
|
registrationOpenAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(tournament.id, tournamentId));
|
||||||
|
|
||||||
|
log.tournament.info({ tournamentId }, 'registration opened');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Close Registration ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function closeRegistration(tournamentId: string) {
|
||||||
|
const t = await getTournamentOrThrow(tournamentId);
|
||||||
|
assertTournamentState(t.state, TOURNAMENT_STATES.REGISTRATION);
|
||||||
|
|
||||||
|
const config = t.config as TournamentConfig;
|
||||||
|
const nextState = config.qualifiers?.enabled
|
||||||
|
? TOURNAMENT_STATES.QUALIFIERS
|
||||||
|
: TOURNAMENT_STATES.SEEDING;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tournament)
|
||||||
|
.set({
|
||||||
|
state: nextState,
|
||||||
|
registrationCloseAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(tournament.id, tournamentId));
|
||||||
|
|
||||||
|
log.tournament.info({ tournamentId, nextState }, 'registration closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Register for Tournament ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function registerForTournament(tournamentId: string, teamId: string, userId: string) {
|
||||||
|
const t = await getTournamentOrThrow(tournamentId);
|
||||||
|
assertTournamentState(t.state, TOURNAMENT_STATES.REGISTRATION);
|
||||||
|
|
||||||
|
// check max slots
|
||||||
|
const registrations = await db.query.tournamentRegistration.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||||
|
inArray(tournamentRegistration.status, ['registered', 'confirmed'])
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = t.config as TournamentConfig;
|
||||||
|
const hasQualifiers = config.qualifiers?.enabled;
|
||||||
|
|
||||||
|
// if no qualifiers, cap at maxSlots. with qualifiers, allow more signups
|
||||||
|
if (!hasQualifiers && registrations.length >= t.maxSlots) {
|
||||||
|
throw new Error('Tournament is full');
|
||||||
|
}
|
||||||
|
|
||||||
|
// check not already registered
|
||||||
|
const existing = await db.query.tournamentRegistration.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||||
|
eq(tournamentRegistration.teamId, teamId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('Already registered for this tournament');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [reg] = await db
|
||||||
|
.insert(tournamentRegistration)
|
||||||
|
.values({
|
||||||
|
tournamentId,
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
status: 'registered'
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
log.tournament.info({ tournamentId, teamId }, 'team registered');
|
||||||
|
return reg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Withdraw from Tournament ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function withdrawFromTournament(tournamentId: string, teamId: string) {
|
||||||
|
const reg = await db.query.tournamentRegistration.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||||
|
eq(tournamentRegistration.teamId, teamId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if (!reg) throw new Error('Not registered for this tournament');
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tournamentRegistration)
|
||||||
|
.set({ status: 'withdrawn' })
|
||||||
|
.where(eq(tournamentRegistration.id, reg.id));
|
||||||
|
|
||||||
|
log.tournament.info({ tournamentId, teamId }, 'team withdrew');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Finalize Seeding ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function finalizeSeeding(tournamentId: string) {
|
||||||
|
const t = await getTournamentOrThrow(tournamentId);
|
||||||
|
if (t.state !== TOURNAMENT_STATES.SEEDING && t.state !== TOURNAMENT_STATES.QUALIFIERS) {
|
||||||
|
throw new Error(`Cannot finalize seeding in state ${t.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrations = await db.query.tournamentRegistration.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||||
|
inArray(tournamentRegistration.status, ['registered', 'confirmed'])
|
||||||
|
),
|
||||||
|
orderBy: [asc(tournamentRegistration.seed), asc(tournamentRegistration.registeredAt)]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (registrations.length < 2) {
|
||||||
|
throw new Error('Need at least 2 teams to generate bracket');
|
||||||
|
}
|
||||||
|
|
||||||
|
// cap to maxSlots if needed (qualifiers should have already reduced)
|
||||||
|
const activeTeams = registrations.slice(0, t.maxSlots);
|
||||||
|
|
||||||
|
// assign seeds if not already set (from qualifiers)
|
||||||
|
for (let i = 0; i < activeTeams.length; i++) {
|
||||||
|
if (!activeTeams[i].seed) {
|
||||||
|
await db
|
||||||
|
.update(tournamentRegistration)
|
||||||
|
.set({ seed: i + 1 })
|
||||||
|
.where(eq(tournamentRegistration.id, activeTeams[i].id));
|
||||||
|
activeTeams[i] = { ...activeTeams[i], seed: i + 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eliminate teams that didn't make the cut
|
||||||
|
const eliminated = registrations.slice(t.maxSlots);
|
||||||
|
for (const reg of eliminated) {
|
||||||
|
await db
|
||||||
|
.update(tournamentRegistration)
|
||||||
|
.set({ status: 'eliminated' })
|
||||||
|
.where(eq(tournamentRegistration.id, reg.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = t.format as TournamentFormat;
|
||||||
|
const config = t.config as TournamentConfig;
|
||||||
|
|
||||||
|
// build seed → teamId map
|
||||||
|
const seedToTeam = new Map<number, string>();
|
||||||
|
for (const reg of activeTeams) {
|
||||||
|
seedToTeam.set(reg.seed!, reg.teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'groups_bracket' && config.groupStage) {
|
||||||
|
await generateGroupsAndBracket(tournamentId, activeTeams.length, config, seedToTeam);
|
||||||
|
} else if (format === 'double_elim') {
|
||||||
|
await generateDoubleElimFromSeeds(tournamentId, activeTeams.length, seedToTeam);
|
||||||
|
} else {
|
||||||
|
await generateSingleElimFromSeeds(tournamentId, activeTeams.length, seedToTeam);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tournament)
|
||||||
|
.set({ state: TOURNAMENT_STATES.BRACKET })
|
||||||
|
.where(eq(tournament.id, tournamentId));
|
||||||
|
|
||||||
|
// auto-advance BYE matches
|
||||||
|
await processByes(tournamentId);
|
||||||
|
|
||||||
|
log.tournament.info({ tournamentId, teamCount: activeTeams.length, format }, 'bracket generated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cancel Tournament ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function cancelTournament(tournamentId: string) {
|
||||||
|
const t = await getTournamentOrThrow(tournamentId);
|
||||||
|
if (t.state === TOURNAMENT_STATES.FINISHED || t.state === TOURNAMENT_STATES.CANCELLED) {
|
||||||
|
throw new Error(`Tournament is already ${t.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancel any active matches
|
||||||
|
const activeMatches = await db.query.tournamentMatch.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentMatch.tournamentId, tournamentId),
|
||||||
|
inArray(tournamentMatch.state, [
|
||||||
|
TOURNAMENT_MATCH_STATES.SCHEDULED,
|
||||||
|
TOURNAMENT_MATCH_STATES.LIVE
|
||||||
|
])
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tm of activeMatches) {
|
||||||
|
if (tm.matchId) {
|
||||||
|
await db
|
||||||
|
.update(match)
|
||||||
|
.set({ state: 'CANCELLED', finishedAt: new Date() })
|
||||||
|
.where(eq(match.id, tm.matchId));
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(tournamentMatch)
|
||||||
|
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED })
|
||||||
|
.where(eq(tournamentMatch.id, tm.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tournament)
|
||||||
|
.set({ state: TOURNAMENT_STATES.CANCELLED, finishedAt: new Date() })
|
||||||
|
.where(eq(tournament.id, tournamentId));
|
||||||
|
|
||||||
|
log.tournament.info({ tournamentId }, 'tournament cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Check Tournament Completion ────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function checkTournamentCompletion(tournamentId: string) {
|
||||||
|
const allMatches = await db.query.tournamentMatch.findMany({
|
||||||
|
where: eq(tournamentMatch.tournamentId, tournamentId)
|
||||||
|
});
|
||||||
|
|
||||||
|
const incomplete = allMatches.filter(
|
||||||
|
(m) => m.state !== TOURNAMENT_MATCH_STATES.FINISHED && m.state !== TOURNAMENT_MATCH_STATES.BYE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (incomplete.length > 0) return false;
|
||||||
|
|
||||||
|
// find the final match winner
|
||||||
|
// for single/double elim, it's the last match with no winnerGoesToId
|
||||||
|
const finalMatch = allMatches.find((m) => !m.winnerGoesToId && m.winnerId);
|
||||||
|
const winnerId = finalMatch?.winnerId;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tournament)
|
||||||
|
.set({
|
||||||
|
state: TOURNAMENT_STATES.FINISHED,
|
||||||
|
finishedAt: new Date(),
|
||||||
|
winnerId
|
||||||
|
})
|
||||||
|
.where(eq(tournament.id, tournamentId));
|
||||||
|
|
||||||
|
log.tournament.info({ tournamentId, winnerId }, 'tournament finished');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal: Generate Single Elim from Seeds ──────────────────────────
|
||||||
|
|
||||||
|
async function generateSingleElimFromSeeds(
|
||||||
|
tournamentId: string,
|
||||||
|
teamCount: number,
|
||||||
|
seedToTeam: Map<number, string>
|
||||||
|
) {
|
||||||
|
const bracketSlots = generateSingleElimBracket(teamCount);
|
||||||
|
const bracketSize = nextPowerOf2(teamCount);
|
||||||
|
const totalRounds = Math.log2(bracketSize);
|
||||||
|
|
||||||
|
// get or create rounds
|
||||||
|
const rounds = await getOrCreateRounds(tournamentId, totalRounds, 'winners');
|
||||||
|
|
||||||
|
// create tournament matches
|
||||||
|
await insertBracketMatches(tournamentId, bracketSlots, rounds, seedToTeam, 'winners');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal: Generate Double Elim from Seeds ──────────────────────────
|
||||||
|
|
||||||
|
async function generateDoubleElimFromSeeds(
|
||||||
|
tournamentId: string,
|
||||||
|
teamCount: number,
|
||||||
|
seedToTeam: Map<number, string>
|
||||||
|
) {
|
||||||
|
const bracketSlots = generateDoubleElimBracket(teamCount);
|
||||||
|
const bracketSize = nextPowerOf2(teamCount);
|
||||||
|
const wbRounds = Math.log2(bracketSize);
|
||||||
|
const lbRounds = 2 * (wbRounds - 1);
|
||||||
|
|
||||||
|
// create rounds for WB, LB, and GF
|
||||||
|
const wbRoundRecords = await getOrCreateRounds(tournamentId, wbRounds, 'winners');
|
||||||
|
const lbRoundRecords = await getOrCreateRounds(tournamentId, lbRounds, 'losers');
|
||||||
|
const gfRoundRecords = await getOrCreateRounds(tournamentId, 2, 'grand_final');
|
||||||
|
|
||||||
|
// build round map: bracketType + roundIndex → round record
|
||||||
|
const roundMap = new Map<string, { id: string }>();
|
||||||
|
for (let i = 0; i < wbRoundRecords.length; i++) {
|
||||||
|
roundMap.set(`winners-${i}`, wbRoundRecords[i]);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < lbRoundRecords.length; i++) {
|
||||||
|
roundMap.set(`losers-${i}`, lbRoundRecords[i]);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < gfRoundRecords.length; i++) {
|
||||||
|
roundMap.set(`grand_final-${i}`, gfRoundRecords[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert all bracket matches (in two passes: first create, then link)
|
||||||
|
// first pass: create all matches without progression links
|
||||||
|
const slotToMatchId = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const slot of bracketSlots) {
|
||||||
|
const roundKey = `${slot.bracketType}-${slot.roundIndex}`;
|
||||||
|
const round = roundMap.get(roundKey);
|
||||||
|
if (!round) continue;
|
||||||
|
|
||||||
|
const team1Id = slot.team1Seed ? (seedToTeam.get(slot.team1Seed) ?? null) : null;
|
||||||
|
const team2Id = slot.team2Seed ? (seedToTeam.get(slot.team2Seed) ?? null) : null;
|
||||||
|
const isBye = slot.isBye;
|
||||||
|
|
||||||
|
let state: string = TOURNAMENT_MATCH_STATES.PENDING;
|
||||||
|
if (isBye) state = TOURNAMENT_MATCH_STATES.BYE;
|
||||||
|
else if (team1Id && team2Id) state = TOURNAMENT_MATCH_STATES.READY;
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(tournamentMatch)
|
||||||
|
.values({
|
||||||
|
tournamentId,
|
||||||
|
roundId: round.id,
|
||||||
|
bracketPosition: slot.bracketPosition,
|
||||||
|
team1Id: team1Id,
|
||||||
|
team2Id: team2Id,
|
||||||
|
state
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const key = `${slot.bracketType}-${slot.roundIndex}-${slot.bracketPosition}`;
|
||||||
|
slotToMatchId.set(key, created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// second pass: link progression
|
||||||
|
for (const slot of bracketSlots) {
|
||||||
|
const key = `${slot.bracketType}-${slot.roundIndex}-${slot.bracketPosition}`;
|
||||||
|
const matchId = slotToMatchId.get(key);
|
||||||
|
if (!matchId) continue;
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (slot.winnerGoesTo) {
|
||||||
|
const targetKey = `${slot.winnerGoesTo.roundIndex === 0 && slot.bracketType !== 'grand_final' ? slot.bracketType : slot.winnerGoesTo.roundIndex >= 0 ? '' : ''}`;
|
||||||
|
// determine target bracket type from the slot's winnerGoesTo
|
||||||
|
let targetBracketType = slot.bracketType;
|
||||||
|
if (
|
||||||
|
slot.bracketType === 'winners' &&
|
||||||
|
slot.roundIndex ===
|
||||||
|
Math.log2(
|
||||||
|
nextPowerOf2(bracketSlots.filter((s) => s.bracketType === 'winners').length + 1)
|
||||||
|
) -
|
||||||
|
1
|
||||||
|
) {
|
||||||
|
targetBracketType = 'grand_final';
|
||||||
|
}
|
||||||
|
if (slot.bracketType === 'losers') {
|
||||||
|
const maxLbRound = Math.max(
|
||||||
|
...bracketSlots.filter((s) => s.bracketType === 'losers').map((s) => s.roundIndex)
|
||||||
|
);
|
||||||
|
if (slot.roundIndex === maxLbRound) {
|
||||||
|
targetBracketType = 'grand_final';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// actually, the winnerGoesTo already encodes which round/position, we just need to find the bracket type
|
||||||
|
// the bracket generator already set winnerGoesTo correctly, we need to figure out the target bracket type
|
||||||
|
// for WB final → GF: winnerGoesTo points to GF roundIndex 0
|
||||||
|
// for LB final → GF: winnerGoesTo points to GF roundIndex 0
|
||||||
|
// for GF1 → GF2: winnerGoesTo points to GF roundIndex 1
|
||||||
|
|
||||||
|
// let's use a simpler approach: try all bracket types
|
||||||
|
const wgt = slot.winnerGoesTo;
|
||||||
|
let targetId: string | undefined;
|
||||||
|
for (const bt of ['winners', 'losers', 'grand_final'] as const) {
|
||||||
|
const tKey = `${bt}-${wgt.roundIndex}-${wgt.bracketPosition}`;
|
||||||
|
targetId = slotToMatchId.get(tKey);
|
||||||
|
if (targetId) break;
|
||||||
|
}
|
||||||
|
if (targetId) {
|
||||||
|
updates.winnerGoesToId = targetId;
|
||||||
|
updates.winnerSlot = wgt.slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slot.loserGoesTo) {
|
||||||
|
const lgt = slot.loserGoesTo;
|
||||||
|
let targetId: string | undefined;
|
||||||
|
for (const bt of ['winners', 'losers', 'grand_final'] as const) {
|
||||||
|
const tKey = `${bt}-${lgt.roundIndex}-${lgt.bracketPosition}`;
|
||||||
|
targetId = slotToMatchId.get(tKey);
|
||||||
|
if (targetId) break;
|
||||||
|
}
|
||||||
|
if (targetId) {
|
||||||
|
updates.loserGoesToId = targetId;
|
||||||
|
updates.loserSlot = lgt.slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await db.update(tournamentMatch).set(updates).where(eq(tournamentMatch.id, matchId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal: Generate Groups + Bracket ────────────────────────────────
|
||||||
|
|
||||||
|
async function generateGroupsAndBracket(
|
||||||
|
tournamentId: string,
|
||||||
|
teamCount: number,
|
||||||
|
config: TournamentConfig,
|
||||||
|
seedToTeam: Map<number, string>
|
||||||
|
) {
|
||||||
|
const { groupCount, advanceCount } = config.groupStage!;
|
||||||
|
const { groups, matches } = generateGroupStageMatches(teamCount, groupCount);
|
||||||
|
|
||||||
|
// get or create group stage round
|
||||||
|
const existingRounds = await db.query.tournamentRound.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentRound.tournamentId, tournamentId),
|
||||||
|
eq(tournamentRound.bracketType, 'group')
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let groupRound: { id: string };
|
||||||
|
if (existingRounds.length > 0) {
|
||||||
|
groupRound = existingRounds[0];
|
||||||
|
} else {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(tournamentRound)
|
||||||
|
.values({
|
||||||
|
tournamentId,
|
||||||
|
name: 'Group Stage',
|
||||||
|
abbreviation: 'GS',
|
||||||
|
roundOrder: 0,
|
||||||
|
bestOf: 5, // default, can be configured per round
|
||||||
|
bracketType: 'group'
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
groupRound = created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create groups and entries
|
||||||
|
for (const group of groups) {
|
||||||
|
const [createdGroup] = await db
|
||||||
|
.insert(tournamentGroup)
|
||||||
|
.values({
|
||||||
|
tournamentId,
|
||||||
|
name: `Group ${String.fromCharCode(65 + group.groupIndex)}`,
|
||||||
|
groupOrder: group.groupIndex
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
for (let i = 0; i < group.teamSeeds.length; i++) {
|
||||||
|
const teamId = seedToTeam.get(group.teamSeeds[i]);
|
||||||
|
if (!teamId) continue;
|
||||||
|
|
||||||
|
await db.insert(tournamentGroupEntry).values({
|
||||||
|
groupId: createdGroup.id,
|
||||||
|
teamId,
|
||||||
|
seed: i + 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create group stage matches
|
||||||
|
let position = 0;
|
||||||
|
for (const gm of matches) {
|
||||||
|
const team1Id = seedToTeam.get(gm.team1Seed) ?? null;
|
||||||
|
const team2Id = seedToTeam.get(gm.team2Seed) ?? null;
|
||||||
|
|
||||||
|
await db.insert(tournamentMatch).values({
|
||||||
|
tournamentId,
|
||||||
|
roundId: groupRound.id,
|
||||||
|
bracketPosition: position++,
|
||||||
|
team1Id,
|
||||||
|
team2Id,
|
||||||
|
state: team1Id && team2Id ? TOURNAMENT_MATCH_STATES.READY : TOURNAMENT_MATCH_STATES.PENDING
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// bracket stage will be generated after groups finish
|
||||||
|
// (via progression.ts when all group matches are done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal: Get or Create Rounds ─────────────────────────────────────
|
||||||
|
|
||||||
|
async function getOrCreateRounds(
|
||||||
|
tournamentId: string,
|
||||||
|
count: number,
|
||||||
|
bracketType: string
|
||||||
|
): Promise<{ id: string }[]> {
|
||||||
|
const existing = await db.query.tournamentRound.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentRound.tournamentId, tournamentId),
|
||||||
|
eq(tournamentRound.bracketType, bracketType)
|
||||||
|
),
|
||||||
|
orderBy: [asc(tournamentRound.roundOrder)]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.length >= count) {
|
||||||
|
return existing.slice(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rounds: { id: string }[] = [...existing];
|
||||||
|
|
||||||
|
for (let i = existing.length; i < count; i++) {
|
||||||
|
// calculate matches in this round for naming
|
||||||
|
const bracketSize = 1 << count; // 2^count for winners
|
||||||
|
const matchesInRound = bracketType === 'grand_final' ? 1 : bracketSize / (2 << i);
|
||||||
|
|
||||||
|
const name =
|
||||||
|
bracketType === 'grand_final'
|
||||||
|
? i === 0
|
||||||
|
? 'Grand Finals'
|
||||||
|
: 'Grand Finals (Reset)'
|
||||||
|
: getRoundName(
|
||||||
|
Math.max(matchesInRound, 1),
|
||||||
|
bracketType as 'winners' | 'losers' | 'grand_final'
|
||||||
|
);
|
||||||
|
|
||||||
|
const prefix = bracketType === 'winners' ? 'WB' : bracketType === 'losers' ? 'LB' : 'GF';
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(tournamentRound)
|
||||||
|
.values({
|
||||||
|
tournamentId,
|
||||||
|
name,
|
||||||
|
abbreviation: `${prefix}${i + 1}`,
|
||||||
|
roundOrder: rounds.length,
|
||||||
|
bestOf: 5, // default, organizer can change later
|
||||||
|
bracketType
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
rounds.push(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal: Insert Bracket Matches (for single elim) ─────────────────
|
||||||
|
|
||||||
|
async function insertBracketMatches(
|
||||||
|
tournamentId: string,
|
||||||
|
bracketSlots: BracketSlot[],
|
||||||
|
rounds: { id: string }[],
|
||||||
|
seedToTeam: Map<number, string>,
|
||||||
|
bracketType: string
|
||||||
|
) {
|
||||||
|
// first pass: create all matches
|
||||||
|
const slotToMatchId = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const slot of bracketSlots) {
|
||||||
|
if (slot.bracketType !== bracketType) continue;
|
||||||
|
|
||||||
|
const round = rounds[slot.roundIndex];
|
||||||
|
if (!round) continue;
|
||||||
|
|
||||||
|
const team1Id = slot.team1Seed ? (seedToTeam.get(slot.team1Seed) ?? null) : null;
|
||||||
|
const team2Id = slot.team2Seed ? (seedToTeam.get(slot.team2Seed) ?? null) : null;
|
||||||
|
const isBye = slot.isBye;
|
||||||
|
|
||||||
|
let state: string = TOURNAMENT_MATCH_STATES.PENDING;
|
||||||
|
if (isBye) state = TOURNAMENT_MATCH_STATES.BYE;
|
||||||
|
else if (team1Id && team2Id) state = TOURNAMENT_MATCH_STATES.READY;
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(tournamentMatch)
|
||||||
|
.values({
|
||||||
|
tournamentId,
|
||||||
|
roundId: round.id,
|
||||||
|
bracketPosition: slot.bracketPosition,
|
||||||
|
team1Id,
|
||||||
|
team2Id,
|
||||||
|
state
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const key = `${slot.roundIndex}-${slot.bracketPosition}`;
|
||||||
|
slotToMatchId.set(key, created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// second pass: link progression
|
||||||
|
for (const slot of bracketSlots) {
|
||||||
|
if (slot.bracketType !== bracketType) continue;
|
||||||
|
|
||||||
|
const key = `${slot.roundIndex}-${slot.bracketPosition}`;
|
||||||
|
const matchId = slotToMatchId.get(key);
|
||||||
|
if (!matchId) continue;
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (slot.winnerGoesTo) {
|
||||||
|
const targetKey = `${slot.winnerGoesTo.roundIndex}-${slot.winnerGoesTo.bracketPosition}`;
|
||||||
|
const targetId = slotToMatchId.get(targetKey);
|
||||||
|
if (targetId) {
|
||||||
|
updates.winnerGoesToId = targetId;
|
||||||
|
updates.winnerSlot = slot.winnerGoesTo.slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await db.update(tournamentMatch).set(updates).where(eq(tournamentMatch.id, matchId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getTournamentOrThrow(tournamentId: string) {
|
||||||
|
const t = await db.query.tournament.findFirst({
|
||||||
|
where: eq(tournament.id, tournamentId)
|
||||||
|
});
|
||||||
|
if (!t) throw new Error('Tournament not found');
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertTournamentState(current: string, expected: string) {
|
||||||
|
if (current !== expected) {
|
||||||
|
throw new Error(`Tournament is in state ${current}, expected ${expected}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTournamentFull(tournamentId: string) {
|
||||||
|
return db.query.tournament.findFirst({
|
||||||
|
where: eq(tournament.id, tournamentId),
|
||||||
|
with: {
|
||||||
|
rounds: {
|
||||||
|
orderBy: [asc(tournamentRound.roundOrder)]
|
||||||
|
},
|
||||||
|
registrations: true,
|
||||||
|
matches: true,
|
||||||
|
groups: {
|
||||||
|
with: { entries: true }
|
||||||
|
},
|
||||||
|
staff: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
408
src/lib/server/tournament/progression.ts
Normal file
408
src/lib/server/tournament/progression.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import {
|
||||||
|
tournament,
|
||||||
|
tournamentMatch,
|
||||||
|
tournamentRound,
|
||||||
|
tournamentGroup,
|
||||||
|
tournamentGroupEntry,
|
||||||
|
tournamentRegistration,
|
||||||
|
match,
|
||||||
|
matchParticipant
|
||||||
|
} from '$lib/server/db/schema';
|
||||||
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
import { TOURNAMENT_MATCH_STATES, type TournamentConfig } from './types';
|
||||||
|
import { generateSingleElimBracket, nextPowerOf2, getRoundName } from './bracket';
|
||||||
|
import { checkTournamentCompletion } from './lifecycle';
|
||||||
|
|
||||||
|
// ── Try Advance Tournament ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function tryAdvanceTournament(matchId: string) {
|
||||||
|
const tm = await db.query.tournamentMatch.findFirst({
|
||||||
|
where: eq(tournamentMatch.matchId, matchId)
|
||||||
|
});
|
||||||
|
if (!tm) return; // not a tournament match
|
||||||
|
|
||||||
|
const finishedMatch = await db.query.match.findFirst({
|
||||||
|
where: eq(match.id, matchId)
|
||||||
|
});
|
||||||
|
if (!finishedMatch || !finishedMatch.winnerId) return;
|
||||||
|
|
||||||
|
const winnerId = finishedMatch.winnerId;
|
||||||
|
const loserId = tm.team1Id === winnerId ? tm.team2Id : tm.team1Id;
|
||||||
|
|
||||||
|
// update tournament match
|
||||||
|
await db
|
||||||
|
.update(tournamentMatch)
|
||||||
|
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED, winnerId })
|
||||||
|
.where(eq(tournamentMatch.id, tm.id));
|
||||||
|
|
||||||
|
log.tournament.info(
|
||||||
|
{ tournamentId: tm.tournamentId, matchId, winnerId },
|
||||||
|
'tournament match finished'
|
||||||
|
);
|
||||||
|
|
||||||
|
// get the round to check bracket type
|
||||||
|
const round = await db.query.tournamentRound.findFirst({
|
||||||
|
where: eq(tournamentRound.id, tm.roundId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (round?.bracketType === 'group') {
|
||||||
|
await handleGroupMatchResult(tm.tournamentId, winnerId, loserId, finishedMatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (round?.bracketType === 'grand_final') {
|
||||||
|
await handleGrandFinalsResult(tm, winnerId, loserId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// standard bracket advancement
|
||||||
|
if (tm.winnerGoesToId && winnerId) {
|
||||||
|
await advanceTeamToMatch(tm.winnerGoesToId, tm.winnerSlot!, winnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tm.loserGoesToId && loserId) {
|
||||||
|
await advanceTeamToMatch(tm.loserGoesToId, tm.loserSlot!, loserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkTournamentCompletion(tm.tournamentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Process BYE Matches ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function processByes(tournamentId: string) {
|
||||||
|
const byeMatches = await db.query.tournamentMatch.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentMatch.tournamentId, tournamentId),
|
||||||
|
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.BYE)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tm of byeMatches) {
|
||||||
|
const winnerId = tm.team1Id ?? tm.team2Id;
|
||||||
|
if (!winnerId) continue;
|
||||||
|
|
||||||
|
// skip if already has a winner (already processed)
|
||||||
|
if (tm.winnerId) continue;
|
||||||
|
|
||||||
|
await db.update(tournamentMatch).set({ winnerId }).where(eq(tournamentMatch.id, tm.id));
|
||||||
|
|
||||||
|
if (tm.winnerGoesToId) {
|
||||||
|
await advanceTeamToMatch(tm.winnerGoesToId, tm.winnerSlot!, winnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.tournament.info({ tournamentId, matchId: tm.id, winnerId }, 'BYE auto-advanced');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handle Team Withdrawal ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function handleWithdrawal(tournamentId: string, teamId: string) {
|
||||||
|
const teamMatches = await db.query.tournamentMatch.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentMatch.tournamentId, tournamentId),
|
||||||
|
inArray(tournamentMatch.state, [
|
||||||
|
TOURNAMENT_MATCH_STATES.PENDING,
|
||||||
|
TOURNAMENT_MATCH_STATES.READY,
|
||||||
|
TOURNAMENT_MATCH_STATES.SCHEDULED
|
||||||
|
])
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tm of teamMatches) {
|
||||||
|
if (tm.team1Id !== teamId && tm.team2Id !== teamId) continue;
|
||||||
|
|
||||||
|
const opponentId = tm.team1Id === teamId ? tm.team2Id : tm.team1Id;
|
||||||
|
|
||||||
|
if (tm.matchId) {
|
||||||
|
await db
|
||||||
|
.update(match)
|
||||||
|
.set({ state: 'CANCELLED', finishedAt: new Date() })
|
||||||
|
.where(eq(match.id, tm.matchId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opponentId) {
|
||||||
|
await db
|
||||||
|
.update(tournamentMatch)
|
||||||
|
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED, winnerId: opponentId })
|
||||||
|
.where(eq(tournamentMatch.id, tm.id));
|
||||||
|
|
||||||
|
if (tm.winnerGoesToId) {
|
||||||
|
await advanceTeamToMatch(tm.winnerGoesToId, tm.winnerSlot!, opponentId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.update(tournamentMatch)
|
||||||
|
.set({ state: TOURNAMENT_MATCH_STATES.FINISHED })
|
||||||
|
.where(eq(tournamentMatch.id, tm.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tournamentRegistration)
|
||||||
|
.set({ status: 'withdrawn' })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||||
|
eq(tournamentRegistration.teamId, teamId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
log.tournament.info({ tournamentId, teamId }, 'team withdrew mid-tournament');
|
||||||
|
await checkTournamentCompletion(tournamentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal: Advance Team to Match ────────────────────────────────────
|
||||||
|
|
||||||
|
async function advanceTeamToMatch(targetMatchId: string, targetSlot: number, teamId: string) {
|
||||||
|
const updates: Record<string, unknown> =
|
||||||
|
targetSlot === 1 ? { team1Id: teamId } : { team2Id: teamId };
|
||||||
|
|
||||||
|
await db.update(tournamentMatch).set(updates).where(eq(tournamentMatch.id, targetMatchId));
|
||||||
|
|
||||||
|
// check if both teams are now known → mark as READY
|
||||||
|
const updated = await db.query.tournamentMatch.findFirst({
|
||||||
|
where: eq(tournamentMatch.id, targetMatchId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
updated &&
|
||||||
|
updated.team1Id &&
|
||||||
|
updated.team2Id &&
|
||||||
|
updated.state === TOURNAMENT_MATCH_STATES.PENDING
|
||||||
|
) {
|
||||||
|
await db
|
||||||
|
.update(tournamentMatch)
|
||||||
|
.set({ state: TOURNAMENT_MATCH_STATES.READY })
|
||||||
|
.where(eq(tournamentMatch.id, targetMatchId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal: Handle Group Match Result ────────────────────────────────
|
||||||
|
|
||||||
|
async function handleGroupMatchResult(
|
||||||
|
tournamentId: string,
|
||||||
|
winnerId: string,
|
||||||
|
loserId: string | null,
|
||||||
|
finishedMatch: typeof match.$inferSelect
|
||||||
|
) {
|
||||||
|
// update group standings
|
||||||
|
const groups = await db.query.tournamentGroup.findMany({
|
||||||
|
where: eq(tournamentGroup.tournamentId, tournamentId),
|
||||||
|
with: { entries: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// get match participant scores for map difference
|
||||||
|
const participants = await db.query.matchParticipant.findMany({
|
||||||
|
where: eq(matchParticipant.matchId, finishedMatch.id)
|
||||||
|
});
|
||||||
|
const winnerParticipant = participants.find((p) => p.teamId === winnerId);
|
||||||
|
const loserParticipant = loserId ? participants.find((p) => p.teamId === loserId) : null;
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const winnerEntry = group.entries.find((e) => e.teamId === winnerId);
|
||||||
|
const loserEntry = loserId ? group.entries.find((e) => e.teamId === loserId) : null;
|
||||||
|
|
||||||
|
if (winnerEntry) {
|
||||||
|
await db
|
||||||
|
.update(tournamentGroupEntry)
|
||||||
|
.set({
|
||||||
|
wins: winnerEntry.wins + 1,
|
||||||
|
mapWins: winnerEntry.mapWins + (winnerParticipant?.score ?? 0),
|
||||||
|
mapLosses: winnerEntry.mapLosses + (loserParticipant?.score ?? 0)
|
||||||
|
})
|
||||||
|
.where(eq(tournamentGroupEntry.id, winnerEntry.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loserEntry) {
|
||||||
|
await db
|
||||||
|
.update(tournamentGroupEntry)
|
||||||
|
.set({
|
||||||
|
losses: loserEntry.losses + 1,
|
||||||
|
mapWins: loserEntry.mapWins + (loserParticipant?.score ?? 0),
|
||||||
|
mapLosses: loserEntry.mapLosses + (winnerParticipant?.score ?? 0)
|
||||||
|
})
|
||||||
|
.where(eq(tournamentGroupEntry.id, loserEntry.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if all group matches are finished
|
||||||
|
const groupRounds = await db.query.tournamentRound.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentRound.tournamentId, tournamentId),
|
||||||
|
eq(tournamentRound.bracketType, 'group')
|
||||||
|
)
|
||||||
|
});
|
||||||
|
const groupRoundIds = groupRounds.map((r) => r.id);
|
||||||
|
|
||||||
|
if (groupRoundIds.length === 0) return;
|
||||||
|
|
||||||
|
const allGroupMatches = await db.query.tournamentMatch.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentMatch.tournamentId, tournamentId),
|
||||||
|
inArray(tournamentMatch.roundId, groupRoundIds)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const allDone = allGroupMatches.every(
|
||||||
|
(m) => m.state === TOURNAMENT_MATCH_STATES.FINISHED || m.state === TOURNAMENT_MATCH_STATES.BYE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allDone) {
|
||||||
|
await advanceGroupsToPlayoffs(tournamentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal: Advance Groups to Playoffs ───────────────────────────────
|
||||||
|
|
||||||
|
async function advanceGroupsToPlayoffs(tournamentId: string) {
|
||||||
|
const t = await db.query.tournament.findFirst({
|
||||||
|
where: eq(tournament.id, tournamentId)
|
||||||
|
});
|
||||||
|
if (!t) return;
|
||||||
|
|
||||||
|
const config = t.config as TournamentConfig;
|
||||||
|
const advanceCount = config.groupStage?.advanceCount ?? 2;
|
||||||
|
|
||||||
|
const groups = await db.query.tournamentGroup.findMany({
|
||||||
|
where: eq(tournamentGroup.tournamentId, tournamentId),
|
||||||
|
with: { entries: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// rank teams and determine who advances
|
||||||
|
const advancingTeams: { teamId: string; globalSeed: number }[] = [];
|
||||||
|
let seedCounter = 1;
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const sorted = [...group.entries].sort((a, b) => {
|
||||||
|
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||||
|
const aDiff = a.mapWins - a.mapLosses;
|
||||||
|
const bDiff = b.mapWins - b.mapLosses;
|
||||||
|
return bDiff - aDiff;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of sorted.slice(0, advanceCount)) {
|
||||||
|
advancingTeams.push({ teamId: entry.teamId, globalSeed: seedCounter++ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate playoff bracket
|
||||||
|
const seedToTeam = new Map<number, string>();
|
||||||
|
for (const t of advancingTeams) {
|
||||||
|
seedToTeam.set(t.globalSeed, t.teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bracketSlots = generateSingleElimBracket(advancingTeams.length);
|
||||||
|
const bracketSize = nextPowerOf2(advancingTeams.length);
|
||||||
|
const totalRounds = Math.log2(bracketSize);
|
||||||
|
|
||||||
|
// create playoff rounds
|
||||||
|
const rounds: { id: string }[] = [];
|
||||||
|
for (let i = 0; i < totalRounds; i++) {
|
||||||
|
const matchesInRound = bracketSize / (2 << i);
|
||||||
|
const name = getRoundName(matchesInRound, 'winners');
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(tournamentRound)
|
||||||
|
.values({
|
||||||
|
tournamentId,
|
||||||
|
name,
|
||||||
|
abbreviation: `PO${i + 1}`,
|
||||||
|
roundOrder: 100 + i,
|
||||||
|
bestOf: 7,
|
||||||
|
bracketType: 'winners'
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
rounds.push(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert bracket matches
|
||||||
|
const slotToMatchId = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const slot of bracketSlots) {
|
||||||
|
const round = rounds[slot.roundIndex];
|
||||||
|
if (!round) continue;
|
||||||
|
|
||||||
|
const team1Id = slot.team1Seed ? (seedToTeam.get(slot.team1Seed) ?? null) : null;
|
||||||
|
const team2Id = slot.team2Seed ? (seedToTeam.get(slot.team2Seed) ?? null) : null;
|
||||||
|
|
||||||
|
let state: string = TOURNAMENT_MATCH_STATES.PENDING;
|
||||||
|
if (slot.isBye) state = TOURNAMENT_MATCH_STATES.BYE;
|
||||||
|
else if (team1Id && team2Id) state = TOURNAMENT_MATCH_STATES.READY;
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(tournamentMatch)
|
||||||
|
.values({
|
||||||
|
tournamentId,
|
||||||
|
roundId: round.id,
|
||||||
|
bracketPosition: slot.bracketPosition,
|
||||||
|
team1Id,
|
||||||
|
team2Id,
|
||||||
|
state
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
slotToMatchId.set(`${slot.roundIndex}-${slot.bracketPosition}`, created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// link progression
|
||||||
|
for (const slot of bracketSlots) {
|
||||||
|
if (!slot.winnerGoesTo) continue;
|
||||||
|
const key = `${slot.roundIndex}-${slot.bracketPosition}`;
|
||||||
|
const mid = slotToMatchId.get(key);
|
||||||
|
if (!mid) continue;
|
||||||
|
|
||||||
|
const targetKey = `${slot.winnerGoesTo.roundIndex}-${slot.winnerGoesTo.bracketPosition}`;
|
||||||
|
const targetId = slotToMatchId.get(targetKey);
|
||||||
|
if (!targetId) continue;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tournamentMatch)
|
||||||
|
.set({ winnerGoesToId: targetId, winnerSlot: slot.winnerGoesTo.slot })
|
||||||
|
.where(eq(tournamentMatch.id, mid));
|
||||||
|
}
|
||||||
|
|
||||||
|
// process byes
|
||||||
|
await processByes(tournamentId);
|
||||||
|
|
||||||
|
log.tournament.info(
|
||||||
|
{ tournamentId, teamCount: advancingTeams.length },
|
||||||
|
'groups advanced to playoffs'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal: Handle Grand Finals Result ───────────────────────────────
|
||||||
|
|
||||||
|
async function handleGrandFinalsResult(
|
||||||
|
tm: typeof tournamentMatch.$inferSelect,
|
||||||
|
winnerId: string,
|
||||||
|
loserId: string | null
|
||||||
|
) {
|
||||||
|
if (tm.winnerGoesToId) {
|
||||||
|
// this is GF1 (has progression to GF2)
|
||||||
|
// WB winner is always team1 (slot 1) in GF
|
||||||
|
if (winnerId === tm.team1Id) {
|
||||||
|
// WB winner won → skip bracket reset, mark GF2 as BYE
|
||||||
|
await db
|
||||||
|
.update(tournamentMatch)
|
||||||
|
.set({ state: TOURNAMENT_MATCH_STATES.BYE as string, winnerId })
|
||||||
|
.where(eq(tournamentMatch.id, tm.winnerGoesToId));
|
||||||
|
|
||||||
|
log.tournament.info(
|
||||||
|
{ tournamentId: tm.tournamentId, winnerId },
|
||||||
|
'WB winner won GF1, skipping bracket reset'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// LB winner won → bracket reset
|
||||||
|
await advanceTeamToMatch(tm.winnerGoesToId, 1, winnerId);
|
||||||
|
if (loserId) {
|
||||||
|
await advanceTeamToMatch(tm.winnerGoesToId, 2, loserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.tournament.info({ tournamentId: tm.tournamentId }, 'bracket reset activated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkTournamentCompletion(tm.tournamentId);
|
||||||
|
}
|
||||||
149
src/lib/server/tournament/qualifiers.ts
Normal file
149
src/lib/server/tournament/qualifiers.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import {
|
||||||
|
tournament,
|
||||||
|
tournamentRegistration,
|
||||||
|
tournamentQualifierScore,
|
||||||
|
mappool,
|
||||||
|
mappoolSlot
|
||||||
|
} from '$lib/server/db/schema';
|
||||||
|
import { eq, and, inArray, asc, desc } from 'drizzle-orm';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
import { TOURNAMENT_STATES, type TournamentConfig } from './types';
|
||||||
|
|
||||||
|
// ── Submit Qualifier Score ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function submitQualifierScore(opts: {
|
||||||
|
tournamentId: string;
|
||||||
|
teamId: string;
|
||||||
|
mappoolSlotId: string;
|
||||||
|
totalScore: number;
|
||||||
|
accuracy?: number;
|
||||||
|
}) {
|
||||||
|
const t = await db.query.tournament.findFirst({
|
||||||
|
where: eq(tournament.id, opts.tournamentId)
|
||||||
|
});
|
||||||
|
if (!t) throw new Error('Tournament not found');
|
||||||
|
if (t.state !== TOURNAMENT_STATES.QUALIFIERS) {
|
||||||
|
throw new Error('Tournament is not in qualifiers phase');
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify team is registered
|
||||||
|
const reg = await db.query.tournamentRegistration.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(tournamentRegistration.tournamentId, opts.tournamentId),
|
||||||
|
eq(tournamentRegistration.teamId, opts.teamId),
|
||||||
|
inArray(tournamentRegistration.status, ['registered', 'confirmed'])
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if (!reg) throw new Error('Team is not registered for this tournament');
|
||||||
|
|
||||||
|
const [score] = await db
|
||||||
|
.insert(tournamentQualifierScore)
|
||||||
|
.values({
|
||||||
|
tournamentId: opts.tournamentId,
|
||||||
|
teamId: opts.teamId,
|
||||||
|
mappoolSlotId: opts.mappoolSlotId,
|
||||||
|
totalScore: opts.totalScore,
|
||||||
|
accuracy: opts.accuracy
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
log.tournament.info(
|
||||||
|
{ tournamentId: opts.tournamentId, teamId: opts.teamId, score: opts.totalScore },
|
||||||
|
'qualifier score submitted'
|
||||||
|
);
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get Qualifier Rankings ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getQualifierRankings(tournamentId: string) {
|
||||||
|
const scores = await db.query.tournamentQualifierScore.findMany({
|
||||||
|
where: eq(tournamentQualifierScore.tournamentId, tournamentId)
|
||||||
|
});
|
||||||
|
|
||||||
|
// aggregate total score per team across all maps
|
||||||
|
const teamTotals = new Map<string, { totalScore: number; mapCount: number }>();
|
||||||
|
|
||||||
|
for (const score of scores) {
|
||||||
|
const existing = teamTotals.get(score.teamId) ?? { totalScore: 0, mapCount: 0 };
|
||||||
|
existing.totalScore += score.totalScore;
|
||||||
|
existing.mapCount += 1;
|
||||||
|
teamTotals.set(score.teamId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// rank by total score descending
|
||||||
|
const ranked = Array.from(teamTotals.entries())
|
||||||
|
.map(([teamId, data]) => ({
|
||||||
|
teamId,
|
||||||
|
totalScore: data.totalScore,
|
||||||
|
mapCount: data.mapCount,
|
||||||
|
averageScore: data.mapCount > 0 ? data.totalScore / data.mapCount : 0
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.totalScore - a.totalScore);
|
||||||
|
|
||||||
|
return ranked;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Finalize Qualifiers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function finalizeQualifiers(tournamentId: string) {
|
||||||
|
const t = await db.query.tournament.findFirst({
|
||||||
|
where: eq(tournament.id, tournamentId)
|
||||||
|
});
|
||||||
|
if (!t) throw new Error('Tournament not found');
|
||||||
|
if (t.state !== TOURNAMENT_STATES.QUALIFIERS) {
|
||||||
|
throw new Error('Tournament is not in qualifiers phase');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankings = await getQualifierRankings(tournamentId);
|
||||||
|
|
||||||
|
// top maxSlots teams qualify, rest are eliminated
|
||||||
|
for (let i = 0; i < rankings.length; i++) {
|
||||||
|
const isQualified = i < t.maxSlots;
|
||||||
|
|
||||||
|
// update registration with seed and qualifier score
|
||||||
|
await db
|
||||||
|
.update(tournamentRegistration)
|
||||||
|
.set({
|
||||||
|
seed: isQualified ? i + 1 : null,
|
||||||
|
qualifierScore: rankings[i].totalScore,
|
||||||
|
status: isQualified ? 'confirmed' : 'eliminated'
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(tournamentRegistration.tournamentId, tournamentId),
|
||||||
|
eq(tournamentRegistration.teamId, rankings[i].teamId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// move to seeding phase
|
||||||
|
await db
|
||||||
|
.update(tournament)
|
||||||
|
.set({ state: TOURNAMENT_STATES.SEEDING })
|
||||||
|
.where(eq(tournament.id, tournamentId));
|
||||||
|
|
||||||
|
log.tournament.info(
|
||||||
|
{ tournamentId, qualified: Math.min(rankings.length, t.maxSlots), total: rankings.length },
|
||||||
|
'qualifiers finalized'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get Qualifier Mappool ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getQualifierMappool(tournamentId: string) {
|
||||||
|
const t = await db.query.tournament.findFirst({
|
||||||
|
where: eq(tournament.id, tournamentId)
|
||||||
|
});
|
||||||
|
if (!t) throw new Error('Tournament not found');
|
||||||
|
|
||||||
|
const config = t.config as TournamentConfig;
|
||||||
|
if (!config.qualifiers?.mappoolId) return null;
|
||||||
|
|
||||||
|
return db.query.mappool.findFirst({
|
||||||
|
where: eq(mappool.id, config.qualifiers.mappoolId),
|
||||||
|
with: { slots: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
245
src/lib/server/tournament/scheduler.ts
Normal file
245
src/lib/server/tournament/scheduler.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { tournament, tournamentMatch, tournamentRound } from '$lib/server/db/schema';
|
||||||
|
import { eq, and, lte, asc, inArray } from 'drizzle-orm';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
import { TOURNAMENT_MATCH_STATES, TOURNAMENT_STATES, type TournamentConfig } from './types';
|
||||||
|
import { createMatch } from '$lib/server/match/engine';
|
||||||
|
import { initMatchLobby } from '$lib/server/match/orchestrator';
|
||||||
|
import { getActiveLobbyCount } from '$lib/server/bancho/client';
|
||||||
|
import type { MatchConfig } from '$lib/server/match/types';
|
||||||
|
|
||||||
|
const MAX_TOTAL_LOBBIES = 4;
|
||||||
|
const SCHEDULER_INTERVAL_MS = 30_000; // 30 seconds
|
||||||
|
|
||||||
|
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// ── Start/Stop Scheduler ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function startTournamentScheduler() {
|
||||||
|
if (schedulerTimer) return;
|
||||||
|
|
||||||
|
schedulerTimer = setInterval(() => {
|
||||||
|
processTournamentMatchQueue().catch((err) =>
|
||||||
|
log.tournament.error({ err }, 'scheduler tick failed')
|
||||||
|
);
|
||||||
|
}, SCHEDULER_INTERVAL_MS);
|
||||||
|
|
||||||
|
log.tournament.info('tournament scheduler started');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopTournamentScheduler() {
|
||||||
|
if (schedulerTimer) {
|
||||||
|
clearInterval(schedulerTimer);
|
||||||
|
schedulerTimer = null;
|
||||||
|
log.tournament.info('tournament scheduler stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Process Tournament Match Queue ─────────────────────────────────────
|
||||||
|
|
||||||
|
export async function processTournamentMatchQueue() {
|
||||||
|
// check how many lobby slots are available
|
||||||
|
const activeLobbies = getActiveLobbyCount();
|
||||||
|
const available = MAX_TOTAL_LOBBIES - activeLobbies;
|
||||||
|
|
||||||
|
if (available <= 0) {
|
||||||
|
log.tournament.debug({ activeLobbies }, 'no lobby slots available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// find tournaments in BRACKET state
|
||||||
|
const activeTournaments = await db.query.tournament.findMany({
|
||||||
|
where: eq(tournament.state, TOURNAMENT_STATES.BRACKET)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeTournaments.length === 0) return;
|
||||||
|
|
||||||
|
// find READY tournament matches across all active tournaments
|
||||||
|
// that are either past their scheduled time or have no scheduled time
|
||||||
|
const readyMatches = await db.query.tournamentMatch.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(
|
||||||
|
tournamentMatch.tournamentId,
|
||||||
|
activeTournaments.map((t) => t.id)
|
||||||
|
),
|
||||||
|
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
|
||||||
|
),
|
||||||
|
orderBy: [asc(tournamentMatch.scheduledAt)]
|
||||||
|
});
|
||||||
|
|
||||||
|
// filter to matches that are ready to start (scheduled time passed or no schedule)
|
||||||
|
const now = new Date();
|
||||||
|
const eligible = readyMatches.filter((m) => !m.scheduledAt || m.scheduledAt <= now);
|
||||||
|
|
||||||
|
if (eligible.length === 0) return;
|
||||||
|
|
||||||
|
// respect per-tournament concurrency limits
|
||||||
|
const tournamentConfigs = new Map<string, TournamentConfig>();
|
||||||
|
for (const t of activeTournaments) {
|
||||||
|
tournamentConfigs.set(t.id, t.config as TournamentConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// count currently live matches per tournament
|
||||||
|
const liveMatches = await db.query.tournamentMatch.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(
|
||||||
|
tournamentMatch.tournamentId,
|
||||||
|
activeTournaments.map((t) => t.id)
|
||||||
|
),
|
||||||
|
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.LIVE)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const livePerTournament = new Map<string, number>();
|
||||||
|
for (const m of liveMatches) {
|
||||||
|
livePerTournament.set(m.tournamentId, (livePerTournament.get(m.tournamentId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let slotsUsed = 0;
|
||||||
|
|
||||||
|
for (const tm of eligible) {
|
||||||
|
if (slotsUsed >= available) break;
|
||||||
|
|
||||||
|
const config = tournamentConfigs.get(tm.tournamentId);
|
||||||
|
const maxConcurrent = config?.scheduling?.maxConcurrentLobbies ?? 3;
|
||||||
|
const currentLive = livePerTournament.get(tm.tournamentId) ?? 0;
|
||||||
|
|
||||||
|
if (currentLive >= maxConcurrent) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startTournamentMatch(tm);
|
||||||
|
slotsUsed++;
|
||||||
|
livePerTournament.set(tm.tournamentId, currentLive + 1);
|
||||||
|
} catch (err) {
|
||||||
|
log.tournament.error(
|
||||||
|
{ err, tournamentMatchId: tm.id, tournamentId: tm.tournamentId },
|
||||||
|
'failed to start tournament match'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slotsUsed > 0) {
|
||||||
|
log.tournament.info({ started: slotsUsed }, 'started tournament matches');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start a Single Tournament Match ────────────────────────────────────
|
||||||
|
|
||||||
|
async function startTournamentMatch(tm: typeof tournamentMatch.$inferSelect) {
|
||||||
|
if (!tm.team1Id || !tm.team2Id) {
|
||||||
|
throw new Error('Cannot start match without both teams');
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the round for config (bestOf, mappool)
|
||||||
|
const round = await db.query.tournamentRound.findFirst({
|
||||||
|
where: eq(tournamentRound.id, tm.roundId)
|
||||||
|
});
|
||||||
|
if (!round) throw new Error('Round not found');
|
||||||
|
|
||||||
|
// get tournament for config
|
||||||
|
const t = await db.query.tournament.findFirst({
|
||||||
|
where: eq(tournament.id, tm.tournamentId)
|
||||||
|
});
|
||||||
|
if (!t) throw new Error('Tournament not found');
|
||||||
|
|
||||||
|
const tournamentConfig = t.config as TournamentConfig;
|
||||||
|
|
||||||
|
if (!round.mappoolId) {
|
||||||
|
throw new Error(`Round ${round.name} has no mappool assigned`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// build match config from tournament + round settings
|
||||||
|
const matchConfig: MatchConfig = {
|
||||||
|
bestOf: round.bestOf,
|
||||||
|
teamSize: tournamentConfig.teamSize,
|
||||||
|
scoringType: tournamentConfig.scoringType,
|
||||||
|
freemod: tournamentConfig.freemod,
|
||||||
|
forceNoFail: tournamentConfig.forceNoFail,
|
||||||
|
allowEloChange: false // tournament matches don't affect elo
|
||||||
|
};
|
||||||
|
|
||||||
|
// create the actual match using the existing engine
|
||||||
|
const matchName = `${t.name} - ${round.name} - Match ${tm.bracketPosition + 1}`;
|
||||||
|
|
||||||
|
const createdMatch = await createMatch({
|
||||||
|
name: matchName,
|
||||||
|
config: matchConfig,
|
||||||
|
mappoolId: round.mappoolId,
|
||||||
|
teams: [tm.team1Id, tm.team2Id],
|
||||||
|
createdBy: t.createdBy
|
||||||
|
});
|
||||||
|
|
||||||
|
// link tournament match to the real match
|
||||||
|
await db
|
||||||
|
.update(tournamentMatch)
|
||||||
|
.set({
|
||||||
|
matchId: createdMatch.id,
|
||||||
|
state: TOURNAMENT_MATCH_STATES.LIVE as string
|
||||||
|
})
|
||||||
|
.where(eq(tournamentMatch.id, tm.id));
|
||||||
|
|
||||||
|
// start the IRC lobby
|
||||||
|
await initMatchLobby(createdMatch.id);
|
||||||
|
|
||||||
|
log.tournament.info(
|
||||||
|
{
|
||||||
|
tournamentId: tm.tournamentId,
|
||||||
|
tournamentMatchId: tm.id,
|
||||||
|
matchId: createdMatch.id,
|
||||||
|
round: round.name
|
||||||
|
},
|
||||||
|
'tournament match started'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schedule Matches for a Round ───────────────────────────────────────
|
||||||
|
|
||||||
|
export async function scheduleTournamentMatches(tournamentId: string, roundId?: string) {
|
||||||
|
const t = await db.query.tournament.findFirst({
|
||||||
|
where: eq(tournament.id, tournamentId)
|
||||||
|
});
|
||||||
|
if (!t) throw new Error('Tournament not found');
|
||||||
|
|
||||||
|
const config = t.config as TournamentConfig;
|
||||||
|
const intervalMs = (config.scheduling?.matchIntervalMinutes ?? 30) * 60 * 1000;
|
||||||
|
|
||||||
|
// get matches to schedule
|
||||||
|
const where = roundId
|
||||||
|
? and(
|
||||||
|
eq(tournamentMatch.tournamentId, tournamentId),
|
||||||
|
eq(tournamentMatch.roundId, roundId),
|
||||||
|
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
|
||||||
|
)
|
||||||
|
: and(
|
||||||
|
eq(tournamentMatch.tournamentId, tournamentId),
|
||||||
|
eq(tournamentMatch.state, TOURNAMENT_MATCH_STATES.READY)
|
||||||
|
);
|
||||||
|
|
||||||
|
const matches = await db.query.tournamentMatch.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [asc(tournamentMatch.bracketPosition)]
|
||||||
|
});
|
||||||
|
|
||||||
|
// get the round's scheduled start time
|
||||||
|
const round = roundId
|
||||||
|
? await db.query.tournamentRound.findFirst({
|
||||||
|
where: eq(tournamentRound.id, roundId)
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const baseTime = round?.scheduledAt ?? t.startAt ?? new Date();
|
||||||
|
|
||||||
|
// assign scheduled times with intervals
|
||||||
|
for (let i = 0; i < matches.length; i++) {
|
||||||
|
const scheduledAt = new Date(baseTime.getTime() + i * intervalMs);
|
||||||
|
await db
|
||||||
|
.update(tournamentMatch)
|
||||||
|
.set({ scheduledAt })
|
||||||
|
.where(eq(tournamentMatch.id, matches[i].id));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.tournament.info(
|
||||||
|
{ tournamentId, roundId, count: matches.length },
|
||||||
|
'scheduled tournament matches'
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/lib/server/tournament/types.ts
Normal file
71
src/lib/server/tournament/types.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export interface TournamentConfig {
|
||||||
|
teamSize: number;
|
||||||
|
scoringType: 'score' | 'score_v2' | 'accuracy' | 'combo';
|
||||||
|
freemod?: boolean;
|
||||||
|
forceNoFail?: boolean;
|
||||||
|
qualifiers?: {
|
||||||
|
enabled: boolean;
|
||||||
|
mappoolId?: string;
|
||||||
|
lobbySizeMax?: number; // players per qualifier lobby (default 8)
|
||||||
|
};
|
||||||
|
groupStage?: {
|
||||||
|
groupCount: number;
|
||||||
|
advanceCount: number; // top N per group advance to bracket
|
||||||
|
};
|
||||||
|
scheduling?: {
|
||||||
|
matchIntervalMinutes: number; // gap between matches (default 30)
|
||||||
|
maxConcurrentLobbies: number; // respect IRC limits (default 3)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOURNAMENT_STATES = {
|
||||||
|
DRAFT: 'DRAFT',
|
||||||
|
REGISTRATION: 'REGISTRATION',
|
||||||
|
QUALIFIERS: 'QUALIFIERS',
|
||||||
|
SEEDING: 'SEEDING',
|
||||||
|
BRACKET: 'BRACKET',
|
||||||
|
FINISHED: 'FINISHED',
|
||||||
|
CANCELLED: 'CANCELLED'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TournamentState = (typeof TOURNAMENT_STATES)[keyof typeof TOURNAMENT_STATES];
|
||||||
|
|
||||||
|
export const TOURNAMENT_MATCH_STATES = {
|
||||||
|
PENDING: 'PENDING',
|
||||||
|
READY: 'READY',
|
||||||
|
SCHEDULED: 'SCHEDULED',
|
||||||
|
LIVE: 'LIVE',
|
||||||
|
FINISHED: 'FINISHED',
|
||||||
|
BYE: 'BYE'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TournamentMatchState =
|
||||||
|
(typeof TOURNAMENT_MATCH_STATES)[keyof typeof TOURNAMENT_MATCH_STATES];
|
||||||
|
|
||||||
|
export const TOURNAMENT_FORMATS = {
|
||||||
|
SINGLE_ELIM: 'single_elim',
|
||||||
|
DOUBLE_ELIM: 'double_elim',
|
||||||
|
GROUPS_BRACKET: 'groups_bracket'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TournamentFormat = (typeof TOURNAMENT_FORMATS)[keyof typeof TOURNAMENT_FORMATS];
|
||||||
|
|
||||||
|
export const TOURNAMENT_STAFF_ROLES = {
|
||||||
|
ORGANIZER: 'organizer',
|
||||||
|
ADMIN: 'admin',
|
||||||
|
REFEREE: 'referee',
|
||||||
|
POOLER: 'pooler',
|
||||||
|
STREAMER: 'streamer'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TournamentStaffRole =
|
||||||
|
(typeof TOURNAMENT_STAFF_ROLES)[keyof typeof TOURNAMENT_STAFF_ROLES];
|
||||||
|
|
||||||
|
export const REGISTRATION_STATUSES = {
|
||||||
|
REGISTERED: 'registered',
|
||||||
|
CONFIRMED: 'confirmed',
|
||||||
|
ELIMINATED: 'eliminated',
|
||||||
|
WITHDRAWN: 'withdrawn'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RegistrationStatus = (typeof REGISTRATION_STATUSES)[keyof typeof REGISTRATION_STATUSES];
|
||||||
352
src/lib/server/validation.test.ts
Normal file
352
src/lib/server/validation.test.ts
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock SvelteKit error to throw like the real one
|
||||||
|
vi.mock('@sveltejs/kit', () => ({
|
||||||
|
error: (status: number, body: string) => {
|
||||||
|
throw { status, body };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
matchConfigSchema,
|
||||||
|
createMatchSchema,
|
||||||
|
createInviteSchema,
|
||||||
|
submitScoreSchema,
|
||||||
|
createMappoolSchema,
|
||||||
|
createMappoolSlotSchema,
|
||||||
|
submitRollSchema,
|
||||||
|
pickMapSchema,
|
||||||
|
parseBody
|
||||||
|
} = await import('./validation');
|
||||||
|
|
||||||
|
// ── matchConfigSchema ──
|
||||||
|
|
||||||
|
describe('matchConfigSchema', () => {
|
||||||
|
const validConfig = {
|
||||||
|
bestOf: 3,
|
||||||
|
teamSize: 1,
|
||||||
|
scoringType: 'score_v2' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
test('accepts valid minimal config', () => {
|
||||||
|
expect(matchConfigSchema.parse(validConfig)).toEqual(validConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts valid full config', () => {
|
||||||
|
const full = {
|
||||||
|
...validConfig,
|
||||||
|
teamSizes: [1, 2],
|
||||||
|
warmups: 2,
|
||||||
|
bans: 1,
|
||||||
|
freemod: true,
|
||||||
|
forceNoFail: true,
|
||||||
|
allowEloChange: false,
|
||||||
|
description: 'test match'
|
||||||
|
};
|
||||||
|
expect(matchConfigSchema.parse(full)).toEqual(full);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects bestOf = 0', () => {
|
||||||
|
expect(() => matchConfigSchema.parse({ ...validConfig, bestOf: 0 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects bestOf > 15', () => {
|
||||||
|
expect(() => matchConfigSchema.parse({ ...validConfig, bestOf: 16 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-integer bestOf', () => {
|
||||||
|
expect(() => matchConfigSchema.parse({ ...validConfig, bestOf: 3.5 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid scoringType', () => {
|
||||||
|
expect(() => matchConfigSchema.parse({ ...validConfig, scoringType: 'invalid' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects extra unknown fields (strict mode)', () => {
|
||||||
|
expect(() => matchConfigSchema.parse({ ...validConfig, injectedField: 'malicious' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects teamSize = 0', () => {
|
||||||
|
expect(() => matchConfigSchema.parse({ ...validConfig, teamSize: 0 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects teamSize > 8', () => {
|
||||||
|
expect(() => matchConfigSchema.parse({ ...validConfig, teamSize: 9 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects description over 500 chars', () => {
|
||||||
|
expect(() =>
|
||||||
|
matchConfigSchema.parse({ ...validConfig, description: 'a'.repeat(501) })
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing required fields', () => {
|
||||||
|
expect(() => matchConfigSchema.parse({})).toThrow();
|
||||||
|
expect(() => matchConfigSchema.parse({ bestOf: 3 })).toThrow();
|
||||||
|
expect(() => matchConfigSchema.parse({ bestOf: 3, teamSize: 1 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── createMatchSchema ──
|
||||||
|
|
||||||
|
describe('createMatchSchema', () => {
|
||||||
|
const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
const uuid2 = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
const uuid3 = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
const validMatch = {
|
||||||
|
name: 'Test Match',
|
||||||
|
config: { bestOf: 3, teamSize: 1, scoringType: 'score_v2' },
|
||||||
|
mappoolId: uuid1,
|
||||||
|
teams: [uuid2, uuid3]
|
||||||
|
};
|
||||||
|
|
||||||
|
test('accepts valid match creation', () => {
|
||||||
|
expect(createMatchSchema.parse(validMatch)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty name', () => {
|
||||||
|
expect(() => createMatchSchema.parse({ ...validMatch, name: '' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects name over 200 chars', () => {
|
||||||
|
expect(() => createMatchSchema.parse({ ...validMatch, name: 'a'.repeat(201) })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-uuid mappoolId', () => {
|
||||||
|
expect(() => createMatchSchema.parse({ ...validMatch, mappoolId: 'not-a-uuid' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects teams with less than 2 entries', () => {
|
||||||
|
expect(() => createMatchSchema.parse({ ...validMatch, teams: [uuid2] })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects teams with more than 2 entries', () => {
|
||||||
|
expect(() =>
|
||||||
|
createMatchSchema.parse({ ...validMatch, teams: [uuid1, uuid2, uuid3] })
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-uuid team ids', () => {
|
||||||
|
expect(() => createMatchSchema.parse({ ...validMatch, teams: ['bad', 'ids'] })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── createInviteSchema ──
|
||||||
|
|
||||||
|
describe('createInviteSchema', () => {
|
||||||
|
const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
const uuid2 = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
const uuid3 = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
const validInvite = {
|
||||||
|
creatorTeamId: uuid1,
|
||||||
|
invitedTeamId: uuid2,
|
||||||
|
config: { bestOf: 5, teamSize: 2, scoringType: 'score' },
|
||||||
|
mappoolId: uuid3
|
||||||
|
};
|
||||||
|
|
||||||
|
test('accepts valid minimal invite', () => {
|
||||||
|
expect(createInviteSchema.parse(validInvite)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts invite with optional fields', () => {
|
||||||
|
const full = {
|
||||||
|
...validInvite,
|
||||||
|
name: 'Cool Match',
|
||||||
|
message: 'lets go',
|
||||||
|
scheduledAt: '2026-04-01T12:00:00Z'
|
||||||
|
};
|
||||||
|
expect(createInviteSchema.parse(full)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects message over 500 chars', () => {
|
||||||
|
expect(() => createInviteSchema.parse({ ...validInvite, message: 'x'.repeat(501) })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid scheduledAt format', () => {
|
||||||
|
expect(() =>
|
||||||
|
createInviteSchema.parse({ ...validInvite, scheduledAt: 'next tuesday' })
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts null scheduledAt', () => {
|
||||||
|
expect(createInviteSchema.parse({ ...validInvite, scheduledAt: null })).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── submitScoreSchema ──
|
||||||
|
|
||||||
|
describe('submitScoreSchema', () => {
|
||||||
|
const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
const uuid2 = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
|
test('accepts valid score submission', () => {
|
||||||
|
const valid = {
|
||||||
|
matchGameId: uuid1,
|
||||||
|
scores: [{ playerId: uuid2, score: 1000000 }]
|
||||||
|
};
|
||||||
|
expect(submitScoreSchema.parse(valid)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects negative score', () => {
|
||||||
|
expect(() =>
|
||||||
|
submitScoreSchema.parse({
|
||||||
|
matchGameId: uuid1,
|
||||||
|
scores: [{ playerId: uuid2, score: -1 }]
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty scores array', () => {
|
||||||
|
expect(() => submitScoreSchema.parse({ matchGameId: uuid1, scores: [] })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects scores array over 16 entries', () => {
|
||||||
|
const scores = Array.from({ length: 17 }, (_, i) => ({
|
||||||
|
playerId: `550e8400-e29b-41d4-a716-4466554400${String(i).padStart(2, '0')}`,
|
||||||
|
score: 100
|
||||||
|
}));
|
||||||
|
expect(() => submitScoreSchema.parse({ matchGameId: uuid1, scores })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-uuid matchGameId', () => {
|
||||||
|
expect(() =>
|
||||||
|
submitScoreSchema.parse({
|
||||||
|
matchGameId: 'bad',
|
||||||
|
scores: [{ playerId: uuid2, score: 100 }]
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── createMappoolSchema ──
|
||||||
|
|
||||||
|
describe('createMappoolSchema', () => {
|
||||||
|
test('accepts valid name', () => {
|
||||||
|
expect(createMappoolSchema.parse({ name: 'Pool A' })).toEqual({ name: 'Pool A' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trims whitespace', () => {
|
||||||
|
expect(createMappoolSchema.parse({ name: ' Pool A ' })).toEqual({ name: 'Pool A' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty name', () => {
|
||||||
|
expect(() => createMappoolSchema.parse({ name: '' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects whitespace-only name', () => {
|
||||||
|
expect(() => createMappoolSchema.parse({ name: ' ' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects name over 200 chars', () => {
|
||||||
|
expect(() => createMappoolSchema.parse({ name: 'a'.repeat(201) })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── createMappoolSlotSchema ──
|
||||||
|
|
||||||
|
describe('createMappoolSlotSchema', () => {
|
||||||
|
test('accepts valid slot with string beatmapId', () => {
|
||||||
|
const result = createMappoolSlotSchema.parse({ beatmapId: '12345', category: 'nm' });
|
||||||
|
expect(result).toEqual({ beatmapId: '12345', category: 'NM' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts valid slot with numeric beatmapId', () => {
|
||||||
|
const result = createMappoolSlotSchema.parse({ beatmapId: 12345, category: 'hd' });
|
||||||
|
expect(result).toEqual({ beatmapId: '12345', category: 'HD' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uppercases category', () => {
|
||||||
|
const result = createMappoolSlotSchema.parse({ beatmapId: '1', category: 'dt' });
|
||||||
|
expect(result.category).toBe('DT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts mods array', () => {
|
||||||
|
const result = createMappoolSlotSchema.parse({
|
||||||
|
beatmapId: '1',
|
||||||
|
category: 'fm',
|
||||||
|
mods: ['HD', 'HR']
|
||||||
|
});
|
||||||
|
expect(result.mods).toEqual(['HD', 'HR']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty category', () => {
|
||||||
|
expect(() => createMappoolSlotSchema.parse({ beatmapId: '1', category: '' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── submitRollSchema ──
|
||||||
|
|
||||||
|
describe('submitRollSchema', () => {
|
||||||
|
test('accepts valid roll', () => {
|
||||||
|
expect(submitRollSchema.parse({ value: 50 })).toEqual({ value: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects value = 0', () => {
|
||||||
|
expect(() => submitRollSchema.parse({ value: 0 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects value > 100', () => {
|
||||||
|
expect(() => submitRollSchema.parse({ value: 101 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-integer', () => {
|
||||||
|
expect(() => submitRollSchema.parse({ value: 3.14 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing value', () => {
|
||||||
|
expect(() => submitRollSchema.parse({})).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pickMapSchema ──
|
||||||
|
|
||||||
|
describe('pickMapSchema', () => {
|
||||||
|
test('accepts valid uuid', () => {
|
||||||
|
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
expect(pickMapSchema.parse({ mappoolSlotId: uuid })).toEqual({ mappoolSlotId: uuid });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-uuid', () => {
|
||||||
|
expect(() => pickMapSchema.parse({ mappoolSlotId: 'not-a-uuid' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing field', () => {
|
||||||
|
expect(() => pickMapSchema.parse({})).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── parseBody ──
|
||||||
|
|
||||||
|
describe('parseBody', () => {
|
||||||
|
test('returns parsed data on success', () => {
|
||||||
|
const result = parseBody(submitRollSchema, { value: 42 });
|
||||||
|
expect(result).toEqual({ value: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws sveltekit error with 400 on validation failure', () => {
|
||||||
|
try {
|
||||||
|
parseBody(submitRollSchema, { value: 'not a number' });
|
||||||
|
expect.unreachable('should have thrown');
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.status).toBe(400);
|
||||||
|
expect(e.body).toContain('Validation failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes field path in error message', () => {
|
||||||
|
try {
|
||||||
|
parseBody(createMatchSchema, {
|
||||||
|
name: 'test',
|
||||||
|
config: { bestOf: 'bad', teamSize: 1, scoringType: 'score' },
|
||||||
|
mappoolId: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
teams: ['550e8400-e29b-41d4-a716-446655440001', '550e8400-e29b-41d4-a716-446655440002']
|
||||||
|
});
|
||||||
|
expect.unreachable('should have thrown');
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.status).toBe(400);
|
||||||
|
expect(e.body).toContain('config.bestOf');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
101
src/lib/server/validation.ts
Normal file
101
src/lib/server/validation.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ── Match config schema ──
|
||||||
|
export const matchConfigSchema = z
|
||||||
|
.object({
|
||||||
|
bestOf: z.number().int().min(1).max(15),
|
||||||
|
teamSize: z.number().int().min(1).max(8),
|
||||||
|
teamSizes: z.array(z.number().int().min(1).max(8)).optional(),
|
||||||
|
scoringType: z.enum(['score', 'score_v2', 'accuracy', 'combo']),
|
||||||
|
warmups: z.number().int().min(0).max(5).optional(),
|
||||||
|
bans: z.number().int().min(0).max(10).optional(),
|
||||||
|
freemod: z.boolean().optional(),
|
||||||
|
forceNoFail: z.boolean().optional(),
|
||||||
|
allowEloChange: z.boolean().optional(),
|
||||||
|
description: z.string().max(500).optional()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ValidatedMatchConfig = z.infer<typeof matchConfigSchema>;
|
||||||
|
|
||||||
|
// ── Match creation ──
|
||||||
|
export const createMatchSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
config: matchConfigSchema,
|
||||||
|
mappoolId: z.string().uuid(),
|
||||||
|
teams: z.array(z.string().uuid()).min(2).max(2)
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Invite creation ──
|
||||||
|
export const createInviteSchema = z.object({
|
||||||
|
creatorTeamId: z.string().uuid(),
|
||||||
|
invitedTeamId: z.string().uuid(),
|
||||||
|
config: matchConfigSchema,
|
||||||
|
mappoolId: z.string().uuid(),
|
||||||
|
name: z.string().max(200).optional(),
|
||||||
|
message: z.string().max(500).optional(),
|
||||||
|
scheduledAt: z.string().datetime().nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Score submission ──
|
||||||
|
const scoreEntrySchema = z.object({
|
||||||
|
playerId: z.string().uuid(),
|
||||||
|
score: z.number().int().min(0),
|
||||||
|
accuracy: z.number().min(0).max(100).optional(),
|
||||||
|
maxCombo: z.number().int().min(0).optional(),
|
||||||
|
count300: z.number().int().min(0).optional(),
|
||||||
|
count100: z.number().int().min(0).optional(),
|
||||||
|
count50: z.number().int().min(0).optional(),
|
||||||
|
countMiss: z.number().int().min(0).optional(),
|
||||||
|
mods: z.array(z.string()).optional(),
|
||||||
|
passed: z.boolean().optional(),
|
||||||
|
pp: z.number().min(0).nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const submitScoreSchema = z.object({
|
||||||
|
matchGameId: z.string().uuid(),
|
||||||
|
scores: z.array(scoreEntrySchema).min(1).max(16)
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mappool creation ──
|
||||||
|
export const createMappoolSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.max(200)
|
||||||
|
.transform((s) => s.trim())
|
||||||
|
.pipe(z.string().min(1, 'Name cannot be empty'))
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mappool slot creation ──
|
||||||
|
export const createMappoolSlotSchema = z.object({
|
||||||
|
beatmapId: z.union([z.string(), z.number()]).transform(String),
|
||||||
|
category: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(10)
|
||||||
|
.transform((s) => s.toUpperCase()),
|
||||||
|
mods: z.array(z.string()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Roll submission ──
|
||||||
|
export const submitRollSchema = z.object({
|
||||||
|
value: z.number().int().min(1).max(100)
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Pick submission ──
|
||||||
|
export const pickMapSchema = z.object({
|
||||||
|
mappoolSlotId: z.string().uuid()
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse request body with a zod schema. Throws SvelteKit error(400) on failure.
|
||||||
|
*/
|
||||||
|
export function parseBody<T>(schema: z.ZodType<T>, data: unknown): T {
|
||||||
|
const result = schema.safeParse(data);
|
||||||
|
if (!result.success) {
|
||||||
|
const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
|
||||||
|
error(400, `Validation failed: ${issues}`);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import './layout.css';
|
import './layout.css';
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
|
import SearchModal from '$lib/components/SearchModal.svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { startPolling, stopPolling } from '$lib/stores/queue.svelte';
|
import { startPolling, stopPolling } from '$lib/stores/queue.svelte';
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
const activeMatch = $derived(page.data?.activeMatch);
|
const activeMatch = $derived(page.data?.activeMatch);
|
||||||
|
|
||||||
let sidebarOpen = $state(false);
|
let sidebarOpen = $state(false);
|
||||||
|
let searchOpen = $state(false);
|
||||||
|
|
||||||
const stateLabel: Record<string, string> = {
|
const stateLabel: Record<string, string> = {
|
||||||
CREATED: 'Starting',
|
CREATED: 'Starting',
|
||||||
@@ -52,6 +54,26 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (searchOpen = true)}
|
||||||
|
class="hidden items-center gap-2 rounded-md border border-border bg-surface-800 px-3 py-1.5 text-sm text-text-secondary transition-colors hover:border-text-secondary hover:text-text-primary lg:flex"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
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>
|
||||||
|
Search
|
||||||
|
<kbd class="ml-1 rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-medium">⌘K</kbd>
|
||||||
|
</button>
|
||||||
<div class="lg:flex-1"></div>
|
<div class="lg:flex-1"></div>
|
||||||
<Header />
|
<Header />
|
||||||
</header>
|
</header>
|
||||||
@@ -86,16 +108,51 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<SearchModal bind:open={searchOpen} />
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Unauthenticated: clean layout, no sidebar -->
|
<!-- Unauthenticated: clean layout with nav links -->
|
||||||
<div class="min-h-dvh">
|
<div class="flex min-h-dvh">
|
||||||
|
<Sidebar bind:open={sidebarOpen} guest />
|
||||||
|
<div class="flex flex-1 flex-col lg:ml-56">
|
||||||
<header
|
<header
|
||||||
class="flex h-14 items-center justify-between border-b border-border bg-surface-900/80 px-6 backdrop-blur-md"
|
class="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-surface-900/80 px-4 backdrop-blur-md lg:px-6"
|
||||||
>
|
>
|
||||||
<a href="/" class="flex items-center gap-2.5">
|
<button
|
||||||
<img src="/logo.png" alt="Vash Esports" class="h-8 w-8" />
|
class="rounded-md p-2 text-text-secondary hover:bg-surface-700 hover:text-text-primary lg:hidden"
|
||||||
<span class="font-700 text-base tracking-tight text-text-primary">Vash Esports</span>
|
onclick={() => (sidebarOpen = !sidebarOpen)}
|
||||||
</a>
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (searchOpen = true)}
|
||||||
|
class="hidden items-center gap-2 rounded-md border border-border bg-surface-800 px-3 py-1.5 text-sm text-text-secondary transition-colors hover:border-text-secondary hover:text-text-primary lg:flex"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
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>
|
||||||
|
Search
|
||||||
|
<kbd class="ml-1 rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-medium">⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
<div class="lg:flex-1"></div>
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href="/login"
|
||||||
class="font-600 rounded-md bg-accent px-3.5 py-1.5 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
|
class="font-600 rounded-md bg-accent px-3.5 py-1.5 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
@@ -103,8 +160,10 @@
|
|||||||
Sign in with osu!
|
Sign in with osu!
|
||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
<main class="p-6">
|
<main class="flex-1 p-4 lg:p-6">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<SearchModal bind:open={searchOpen} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// src/routes/(app)/+page.server.ts
|
// src/routes/(app)/+page.server.ts
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { match, team, mappool } from '$lib/server/db/schema';
|
import { match, team, mappool, tournament } from '$lib/server/db/schema';
|
||||||
import { eq, desc, count, inArray } from 'drizzle-orm';
|
import { eq, desc, count, inArray } from 'drizzle-orm';
|
||||||
|
import { user } from '$lib/server/db/auth.schema';
|
||||||
import { getQueueStatus } from '$lib/server/match/queue';
|
import { getQueueStatus } from '$lib/server/match/queue';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
authenticated: false as const,
|
authenticated: false as const,
|
||||||
recentMatches: [],
|
recentMatches: [],
|
||||||
liveMatches: [],
|
liveMatches: [],
|
||||||
stats: { matches: 0, finished: 0, teams: 0, mappools: 0 }
|
stats: { completed: 0, tournaments: 0, usersAndTeams: 0, mappools: 0 }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,10 +21,11 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
const [
|
const [
|
||||||
recentMatches,
|
recentMatches,
|
||||||
liveMatches,
|
liveMatches,
|
||||||
[matchCount],
|
[finishedCount],
|
||||||
|
[tournamentCount],
|
||||||
|
[userCount],
|
||||||
[teamCount],
|
[teamCount],
|
||||||
[poolCount],
|
[poolCount],
|
||||||
[finishedCount],
|
|
||||||
queueStatus
|
queueStatus
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.query.match.findMany({
|
db.query.match.findMany({
|
||||||
@@ -35,10 +37,11 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
|
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
|
||||||
where: inArray(match.state, ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
|
where: inArray(match.state, ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
|
||||||
}),
|
}),
|
||||||
db.select({ count: count() }).from(match),
|
db.select({ count: count() }).from(match).where(eq(match.state, 'FINISHED')),
|
||||||
|
db.select({ count: count() }).from(tournament),
|
||||||
|
db.select({ count: count() }).from(user),
|
||||||
db.select({ count: count() }).from(team),
|
db.select({ count: count() }).from(team),
|
||||||
db.select({ count: count() }).from(mappool),
|
db.select({ count: count() }).from(mappool),
|
||||||
db.select({ count: count() }).from(match).where(eq(match.state, 'FINISHED')),
|
|
||||||
getQueueStatus(locals.user.id)
|
getQueueStatus(locals.user.id)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -49,9 +52,9 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
hasActiveMatch: !!queueStatus.matchedMatchId,
|
hasActiveMatch: !!queueStatus.matchedMatchId,
|
||||||
activeMatchId: queueStatus.matchedMatchId ?? null,
|
activeMatchId: queueStatus.matchedMatchId ?? null,
|
||||||
stats: {
|
stats: {
|
||||||
matches: matchCount.count,
|
completed: finishedCount.count,
|
||||||
finished: finishedCount.count,
|
tournaments: tournamentCount.count,
|
||||||
teams: teamCount.count,
|
usersAndTeams: userCount.count + teamCount.count,
|
||||||
mappools: poolCount.count
|
mappools: poolCount.count
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -102,16 +102,16 @@
|
|||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div class="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||||
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.matches}</p>
|
<p class="font-800 text-2xl text-green-400 tabular-nums">{data.stats.completed}</p>
|
||||||
<p class="mt-1 text-xs text-text-secondary">Total Matches</p>
|
<p class="mt-1 text-xs text-text-secondary">Completed Matches</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||||
<p class="font-800 text-2xl text-green-400 tabular-nums">{data.stats.finished}</p>
|
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.tournaments}</p>
|
||||||
<p class="mt-1 text-xs text-text-secondary">Completed</p>
|
<p class="mt-1 text-xs text-text-secondary">Tournaments</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||||
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.teams}</p>
|
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.usersAndTeams}</p>
|
||||||
<p class="mt-1 text-xs text-text-secondary">Teams</p>
|
<p class="mt-1 text-xs text-text-secondary">Total Users + Teams</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||||
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.mappools}</p>
|
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.mappools}</p>
|
||||||
@@ -207,127 +207,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 grid gap-4 lg:grid-cols-3">
|
<!-- Recent Matches -->
|
||||||
<!-- Quick Actions -->
|
<div class="mt-8">
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<h2 class="font-600 text-sm">Quick Actions</h2>
|
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
|
||||||
<a
|
|
||||||
href="/matches"
|
|
||||||
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-md bg-accent/10 text-accent"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
><path d="M14.5 17.5 3 6V3h3l11.5 11.5" /><path d="m13 19 3.5-3.5" /><path
|
|
||||||
d="m16.5 22 5-5"
|
|
||||||
/><path d="M10 5.5 6 2H3v3l4 4" /></svg
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p class="font-600 text-sm">Matches</p>
|
|
||||||
<p class="text-xs text-text-secondary">View all matches</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/mappools"
|
|
||||||
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-md bg-purple-500/10 text-purple-400"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
><circle cx="8" cy="18" r="3" /><path d="M11 18V8l9-1v10" /><circle
|
|
||||||
cx="20"
|
|
||||||
cy="17"
|
|
||||||
r="3"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p class="font-600 text-sm">Mappools</p>
|
|
||||||
<p class="text-xs text-text-secondary">Build a pool</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/teams"
|
|
||||||
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500/10 text-blue-400"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle
|
|
||||||
cx="9"
|
|
||||||
cy="7"
|
|
||||||
r="4"
|
|
||||||
/><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p class="font-600 text-sm">Teams</p>
|
|
||||||
<p class="text-xs text-text-secondary">Manage rosters</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/leaderboard"
|
|
||||||
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-md bg-yellow-500/10 text-yellow-400"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
><line x1="18" y1="20" x2="18" y2="10" /><line
|
|
||||||
x1="12"
|
|
||||||
y1="20"
|
|
||||||
x2="12"
|
|
||||||
y2="4"
|
|
||||||
/><line x1="6" y1="20" x2="6" y2="14" /></svg
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p class="font-600 text-sm">Leaderboard</p>
|
|
||||||
<p class="text-xs text-text-secondary">Rankings & stats</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent Activity -->
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<h2 class="font-600 text-sm">Recent Matches</h2>
|
<h2 class="font-600 text-sm">Recent Matches</h2>
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
{#each data.recentMatches as m}
|
{#each data.recentMatches as m}
|
||||||
@@ -389,13 +270,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="rounded-lg border border-dashed border-border py-8 text-center">
|
<div class="rounded-lg border border-dashed border-border py-8 text-center">
|
||||||
<p class="text-sm text-text-secondary">
|
<p class="text-sm text-text-secondary">
|
||||||
No matches yet. <a href="/matches" class="text-accent hover:underline">Create one</a
|
No matches yet. <a href="/matches" class="text-accent hover:underline">Create one</a>
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -14,37 +14,68 @@ import {
|
|||||||
matchParticipantPlayer
|
matchParticipantPlayer
|
||||||
} from '$lib/server/db/schema';
|
} from '$lib/server/db/schema';
|
||||||
import { account } from '$lib/server/db/auth.schema';
|
import { account } from '$lib/server/db/auth.schema';
|
||||||
import { eq, and, desc, inArray, sql } from 'drizzle-orm';
|
import { eq, and, inArray, sql } from 'drizzle-orm';
|
||||||
import { requireRole, setUserRole, isRootAdmin } from '$lib/server/permissions';
|
import {
|
||||||
|
requirePermission,
|
||||||
|
setUserRole,
|
||||||
|
isRootAdmin,
|
||||||
|
GlobalPermission
|
||||||
|
} from '$lib/server/permissions';
|
||||||
import { getUser as getOsuUser } from '$lib/server/osu/api';
|
import { getUser as getOsuUser } from '$lib/server/osu/api';
|
||||||
|
import {
|
||||||
|
parseTableParams,
|
||||||
|
buildSearchFilter,
|
||||||
|
buildOrderBy,
|
||||||
|
buildTableMeta
|
||||||
|
} from '$lib/server/table';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
requireRole(locals, 'admin');
|
requirePermission(locals, GlobalPermission.ADMIN_VIEW);
|
||||||
|
|
||||||
const users = await db.query.user.findMany({
|
const params = parseTableParams(url, { sortBy: 'createdAt', limit: 25 });
|
||||||
orderBy: desc(user.createdAt)
|
const searchFilter = buildSearchFilter(params.search, [user.name, user.email]);
|
||||||
});
|
|
||||||
|
|
||||||
const usersWithRatings = await Promise.all(
|
const [rows, countResult] = await Promise.all([
|
||||||
users.map(async (u) => {
|
db
|
||||||
const rating = await db.query.playerRating.findFirst({
|
.select({
|
||||||
where: eq(playerRating.userId, u.id)
|
id: user.id,
|
||||||
});
|
name: user.name,
|
||||||
return {
|
email: user.email,
|
||||||
id: u.id,
|
image: user.image,
|
||||||
name: u.name,
|
role: user.role,
|
||||||
email: u.email,
|
createdAt: user.createdAt,
|
||||||
image: u.image,
|
elo: playerRating.elo,
|
||||||
role: u.role ?? 'player',
|
wins: playerRating.wins,
|
||||||
createdAt: u.createdAt,
|
losses: playerRating.losses
|
||||||
elo: rating?.elo ?? 1000,
|
|
||||||
wins: rating?.wins ?? 0,
|
|
||||||
losses: rating?.losses ?? 0,
|
|
||||||
isRootAdmin: isRootAdmin(u.email)
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
);
|
.from(user)
|
||||||
|
.leftJoin(playerRating, eq(user.id, playerRating.userId))
|
||||||
|
.where(searchFilter)
|
||||||
|
.orderBy(
|
||||||
|
buildOrderBy(
|
||||||
|
params.sortBy,
|
||||||
|
params.sortDir,
|
||||||
|
{ name: user.name, createdAt: user.createdAt, elo: playerRating.elo },
|
||||||
|
user.createdAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(params.limit)
|
||||||
|
.offset((params.page - 1) * params.limit),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(user)
|
||||||
|
.where(searchFilter)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const users = rows.map((u) => ({
|
||||||
|
...u,
|
||||||
|
role: u.role ?? 'player',
|
||||||
|
elo: u.elo ?? 1000,
|
||||||
|
wins: u.wins ?? 0,
|
||||||
|
losses: u.losses ?? 0,
|
||||||
|
isRootAdmin: isRootAdmin(u.email)
|
||||||
|
}));
|
||||||
|
|
||||||
const [activeMatches, queueSize, pendingInvites] = await Promise.all([
|
const [activeMatches, queueSize, pendingInvites] = await Promise.all([
|
||||||
db
|
db
|
||||||
@@ -59,7 +90,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users: usersWithRatings,
|
users,
|
||||||
|
meta: buildTableMeta(params, Number(countResult[0].count)),
|
||||||
actorIsRootAdmin: isRootAdmin(locals.user!.email),
|
actorIsRootAdmin: isRootAdmin(locals.user!.email),
|
||||||
stats: {
|
stats: {
|
||||||
activeMatches: Number(activeMatches[0].count),
|
activeMatches: Number(activeMatches[0].count),
|
||||||
@@ -71,7 +103,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
setRole: async ({ request, locals }) => {
|
setRole: async ({ request, locals }) => {
|
||||||
requireRole(locals, 'admin');
|
requirePermission(locals, GlobalPermission.ADMIN_MANAGE_ROLES);
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const userId = form.get('userId')?.toString();
|
const userId = form.get('userId')?.toString();
|
||||||
@@ -85,7 +117,6 @@ export const actions: Actions = {
|
|||||||
try {
|
try {
|
||||||
await setUserRole(locals, userId, role as any);
|
await setUserRole(locals, userId, role as any);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// setUserRole throws SvelteKit errors; extract the message
|
|
||||||
return { error: e.body?.message ?? e.message ?? 'Failed to update role' };
|
return { error: e.body?.message ?? e.message ?? 'Failed to update role' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +124,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
cancelAllMatches: async ({ locals }) => {
|
cancelAllMatches: async ({ locals }) => {
|
||||||
requireRole(locals, 'admin');
|
requirePermission(locals, GlobalPermission.ADMIN_CANCEL_MATCHES);
|
||||||
|
|
||||||
const cancelled = await db
|
const cancelled = await db
|
||||||
.update(match)
|
.update(match)
|
||||||
@@ -110,7 +141,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearQueue: async ({ locals }) => {
|
clearQueue: async ({ locals }) => {
|
||||||
requireRole(locals, 'admin');
|
requirePermission(locals, GlobalPermission.ADMIN_CLEAR_QUEUE);
|
||||||
const deleted = await db.delete(matchQueue).returning({ id: matchQueue.id });
|
const deleted = await db.delete(matchQueue).returning({ id: matchQueue.id });
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -119,7 +150,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
expireInvites: async ({ locals }) => {
|
expireInvites: async ({ locals }) => {
|
||||||
requireRole(locals, 'admin');
|
requirePermission(locals, GlobalPermission.ADMIN_MANAGE_USERS);
|
||||||
const expired = await db
|
const expired = await db
|
||||||
.update(matchInvite)
|
.update(matchInvite)
|
||||||
.set({ status: 'expired' })
|
.set({ status: 'expired' })
|
||||||
@@ -132,7 +163,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearNotifications: async ({ locals }) => {
|
clearNotifications: async ({ locals }) => {
|
||||||
requireRole(locals, 'admin');
|
requirePermission(locals, GlobalPermission.ADMIN_MANAGE_USERS);
|
||||||
const deleted = await db.delete(notification).returning({ id: notification.id });
|
const deleted = await db.delete(notification).returning({ id: notification.id });
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -141,7 +172,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
resetRatings: async ({ locals }) => {
|
resetRatings: async ({ locals }) => {
|
||||||
requireRole(locals, 'admin');
|
requirePermission(locals, GlobalPermission.ADMIN_RESET_RATINGS);
|
||||||
|
|
||||||
const allUsers = await db.query.user.findMany();
|
const allUsers = await db.query.user.findMany();
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
@@ -161,12 +192,10 @@ export const actions: Actions = {
|
|||||||
// API failure — treat as unranked
|
// API failure — treat as unranked
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unranked → rank 10,000,000 (yields 0 ELO)
|
|
||||||
const effectiveRank = rank && rank > 0 ? rank : 10_000_000;
|
const effectiveRank = rank && rank > 0 ? rank : 10_000_000;
|
||||||
const rawElo = 3500 - Math.log10(effectiveRank) * 500;
|
const rawElo = 3500 - Math.log10(effectiveRank) * 500;
|
||||||
const elo = Math.round(Math.max(0, Math.min(3500, rawElo)));
|
const elo = Math.round(Math.max(0, Math.min(3500, rawElo)));
|
||||||
|
|
||||||
// Upsert: insert if no rating row exists, update if it does
|
|
||||||
const existing = await db.query.playerRating.findFirst({
|
const existing = await db.query.playerRating.findFirst({
|
||||||
where: eq(playerRating.userId, u.id)
|
where: eq(playerRating.userId, u.id)
|
||||||
});
|
});
|
||||||
@@ -205,7 +234,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearMatchHistory: async ({ locals }) => {
|
clearMatchHistory: async ({ locals }) => {
|
||||||
requireRole(locals, 'admin');
|
requirePermission(locals, GlobalPermission.ADMIN_CLEAR_DATA);
|
||||||
|
|
||||||
await db.delete(matchGameScore);
|
await db.delete(matchGameScore);
|
||||||
await db.delete(matchGame);
|
await db.delete(matchGame);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -15,17 +16,23 @@
|
|||||||
admin: { label: 'Admin', color: 'bg-red-500/20 text-red-400 border-red-500/30' }
|
admin: { label: 'Admin', color: 'bg-red-500/20 text-red-400 border-red-500/30' }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Non-root admins can only assign player/referee
|
|
||||||
const availableRoles = $derived(
|
const availableRoles = $derived(
|
||||||
data.actorIsRootAdmin ? ['player', 'referee', 'admin'] : ['player', 'referee']
|
data.actorIsRootAdmin ? ['player', 'referee', 'admin'] : ['player', 'referee']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: 'User', sortable: true },
|
||||||
|
{ key: 'elo', label: 'ELO', sortable: true },
|
||||||
|
{ key: 'wl', label: 'W/L' },
|
||||||
|
{ key: 'role', label: 'Role' }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl">
|
<div class="mx-auto max-w-4xl">
|
||||||
<div>
|
<div class="mb-6">
|
||||||
<h1 class="font-700 text-2xl tracking-tight">Admin Panel</h1>
|
<h1 class="font-700 text-2xl tracking-tight">Admin Panel</h1>
|
||||||
<p class="mt-1 text-sm text-text-secondary">
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
{data.users.length} registered user{data.users.length !== 1 ? 's' : ''}
|
{data.meta.total} registered user{data.meta.total !== 1 ? 's' : ''}
|
||||||
{#if data.actorIsRootAdmin}
|
{#if data.actorIsRootAdmin}
|
||||||
<span class="font-700 ml-2 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400"
|
<span class="font-700 ml-2 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400"
|
||||||
>ROOT ADMIN</span
|
>ROOT ADMIN</span
|
||||||
@@ -35,30 +42,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if actionError}
|
{#if actionError}
|
||||||
<p class="mt-3 text-sm text-red-400">{actionError}</p>
|
<p class="mb-4 text-sm text-red-400">{actionError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if actionSuccess}
|
{#if actionSuccess}
|
||||||
<p class="mt-3 text-sm text-green-400">{actionSuccess}</p>
|
<p class="mb-4 text-sm text-green-400">{actionSuccess}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Users Table -->
|
<DataTable data={data.users} meta={data.meta} {columns} searchPlaceholder="Search users...">
|
||||||
<div class="mt-6 rounded-lg border border-border bg-surface-800">
|
{#snippet row(u, _i)}
|
||||||
<div class="grid grid-cols-[auto_1fr_auto_auto_auto] gap-4 border-b border-border px-4 py-3">
|
|
||||||
<span class="font-600 text-xs text-text-secondary">Avatar</span>
|
|
||||||
<span class="font-600 text-xs text-text-secondary">User</span>
|
|
||||||
<span class="font-600 text-xs text-text-secondary">ELO</span>
|
|
||||||
<span class="font-600 text-xs text-text-secondary">W/L</span>
|
|
||||||
<span class="font-600 text-xs text-text-secondary">Role</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each data.users as u}
|
|
||||||
{@const badge = roleBadge[u.role] ?? roleBadge.player}
|
{@const badge = roleBadge[u.role] ?? roleBadge.player}
|
||||||
{@const isProtected = u.isRootAdmin}
|
{@const isProtected = u.isRootAdmin}
|
||||||
{@const canChangeRole = !isProtected && (data.actorIsRootAdmin || u.role !== 'admin')}
|
{@const canChangeRole = !isProtected && (data.actorIsRootAdmin || u.role !== 'admin')}
|
||||||
<div
|
<tr class="transition-colors hover:bg-surface-800/50">
|
||||||
class="grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 border-b border-border/50 px-4 py-3 last:border-b-0"
|
<td class="px-4 py-3">
|
||||||
>
|
<div class="flex items-center gap-3">
|
||||||
<!-- Avatar -->
|
|
||||||
{#if u.image}
|
{#if u.image}
|
||||||
<img src={u.image} alt="" class="h-8 w-8 rounded-full" />
|
<img src={u.image} alt="" class="h-8 w-8 rounded-full" />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -68,30 +65,32 @@
|
|||||||
{u.name.charAt(0).toUpperCase()}
|
{u.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Name -->
|
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<p class="font-500 truncate text-sm">{u.name}</p>
|
<a href="/users/{u.id}" class="font-500 truncate text-sm hover:text-accent"
|
||||||
|
>{u.name}</a
|
||||||
|
>
|
||||||
{#if u.isRootAdmin}
|
{#if u.isRootAdmin}
|
||||||
<span class="font-700 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400"
|
<span
|
||||||
|
class="font-700 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400"
|
||||||
>ROOT</span
|
>ROOT</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="truncate text-xs text-text-secondary">{u.email}</p>
|
<p class="truncate text-xs text-text-secondary">{u.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- ELO -->
|
</td>
|
||||||
<span class="font-600 text-sm text-text-primary tabular-nums">{u.elo}</span>
|
<td class="px-4 py-3">
|
||||||
|
<span class="font-600 text-sm tabular-nums">{u.elo}</span>
|
||||||
<!-- W/L -->
|
</td>
|
||||||
<span class="text-xs text-text-secondary tabular-nums">
|
<td class="px-4 py-3">
|
||||||
|
<span class="text-xs tabular-nums">
|
||||||
<span class="text-green-400">{u.wins}W</span> /
|
<span class="text-green-400">{u.wins}W</span> /
|
||||||
<span class="text-red-400">{u.losses}L</span>
|
<span class="text-red-400">{u.losses}L</span>
|
||||||
</span>
|
</span>
|
||||||
|
</td>
|
||||||
<!-- Role selector -->
|
<td class="px-4 py-3">
|
||||||
{#if canChangeRole}
|
{#if canChangeRole}
|
||||||
<form
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
@@ -107,7 +106,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="userId" value={u.id} />
|
<input type="hidden" name="userId" value={u.id} />
|
||||||
<select
|
<select
|
||||||
@@ -128,15 +126,14 @@
|
|||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</td>
|
||||||
{/each}
|
</tr>
|
||||||
</div>
|
{/snippet}
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
<!-- Admin Tools -->
|
<!-- Admin Tools -->
|
||||||
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
|
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
|
||||||
<h2 class="font-600 text-sm">Tools</h2>
|
<h2 class="font-600 text-sm">Tools</h2>
|
||||||
|
|
||||||
<!-- Stats -->
|
|
||||||
<div class="mt-3 flex gap-4 text-xs text-text-secondary">
|
<div class="mt-3 flex gap-4 text-xs text-text-secondary">
|
||||||
<span
|
<span
|
||||||
>{data.stats.activeMatches} active match{data.stats.activeMatches !== 1 ? 'es' : ''}</span
|
>{data.stats.activeMatches} active match{data.stats.activeMatches !== 1 ? 'es' : ''}</span
|
||||||
@@ -148,10 +145,8 @@
|
|||||||
: ''}</span
|
: ''}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
{#each [{ action: 'cancelAllMatches', label: 'Cancel All Matches', confirm: 'Cancel all active matches and clear the queue?', color: 'border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10' }, { action: 'clearQueue', label: 'Clear Queue', confirm: 'Remove everyone from the queue?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'expireInvites', label: 'Expire All Invites', confirm: 'Expire all pending invites?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'clearNotifications', label: 'Clear Notifications', confirm: 'Delete all notifications?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'resetRatings', label: 'Re-seed Ratings from osu!', confirm: 'Fetch all osu! ranks and re-seed ELO ratings? W/L records will be reset. Unranked players get 10M rank. This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' }, { action: 'clearMatchHistory', label: 'Clear All Match Data', confirm: 'Delete ALL matches, scores, invites, and notifications? This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' }] as tool}
|
{#each [{ action: 'cancelAllMatches', label: 'Cancel All Matches', confirm: 'Cancel all active matches and clear the queue?', color: 'border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10' }, { action: 'clearQueue', label: 'Clear Queue', confirm: 'Remove everyone from the queue?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'expireInvites', label: 'Expire All Invites', confirm: 'Expire all pending invites?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'clearNotifications', label: 'Clear Notifications', confirm: 'Delete all notifications?', color: 'border-border text-text-secondary hover:bg-surface-700' }, { action: 'resetRatings', label: 'Re-seed Ratings from osu!', confirm: 'Fetch all osu! ranks and re-seed ELO ratings? W/L records will be reset.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' }, { action: 'clearMatchHistory', label: 'Clear All Match Data', confirm: 'Delete ALL matches, scores, invites, and notifications? This cannot be undone.', color: 'border-red-500/30 text-red-400 hover:bg-red-500/10' }] as tool}
|
||||||
<form
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
action="?/{tool.action}"
|
action="?/{tool.action}"
|
||||||
@@ -180,30 +175,4 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Permission Info -->
|
|
||||||
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
|
|
||||||
<h2 class="font-600 text-sm">Permission Levels</h2>
|
|
||||||
<div class="mt-3 flex flex-col gap-2 text-xs text-text-secondary">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-600 rounded bg-surface-600 px-1.5 py-0.5 text-text-secondary">Player</span
|
|
||||||
>
|
|
||||||
Create account, join queue, create/manage own mappools and teams
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-600 rounded bg-yellow-500/20 px-1.5 py-0.5 text-yellow-400">Referee</span>
|
|
||||||
All player permissions + manage any match, force start games
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-600 rounded bg-red-500/20 px-1.5 py-0.5 text-red-400">Admin</span>
|
|
||||||
Full access + manage all mappools/teams + promote players to referee
|
|
||||||
</div>
|
|
||||||
{#if data.actorIsRootAdmin}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-700 rounded bg-red-500/20 px-1.5 py-0.5 text-red-400">Root Admin</span>
|
|
||||||
Can promote/demote admins. Set via ROOT_ADMIN_EMAIL env var. Cannot be demoted.
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,140 +2,195 @@ import { db } from '$lib/server/db';
|
|||||||
import {
|
import {
|
||||||
playerRating,
|
playerRating,
|
||||||
team,
|
team,
|
||||||
teamMember,
|
|
||||||
matchParticipant,
|
matchParticipant,
|
||||||
matchGameScore,
|
matchGameScore,
|
||||||
matchGame,
|
matchGame,
|
||||||
matchParticipantPlayer,
|
matchParticipantPlayer,
|
||||||
|
mappoolSlot,
|
||||||
match
|
match
|
||||||
} from '$lib/server/db/schema';
|
} from '$lib/server/db/schema';
|
||||||
import { user } from '$lib/server/db/auth.schema';
|
import { user } from '$lib/server/db/auth.schema';
|
||||||
import { eq, desc, sql, and } from 'drizzle-orm';
|
import { eq, desc, sql, and } from 'drizzle-orm';
|
||||||
|
import { parseTableParams, buildTableMeta } from '$lib/server/table';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
// ── Top players by ELO ──
|
const tab = url.searchParams.get('tab') ?? 'players';
|
||||||
const topPlayers = await db.query.playerRating.findMany({
|
const params = parseTableParams(url, { sortBy: 'elo', limit: 25 });
|
||||||
orderBy: desc(playerRating.elo),
|
|
||||||
limit: 25
|
|
||||||
});
|
|
||||||
|
|
||||||
const playersWithUsers = await Promise.all(
|
if (tab === 'teams') {
|
||||||
topPlayers.map(async (r, i) => {
|
return loadTeams(params);
|
||||||
const u = await db.query.user.findFirst({
|
} else if (tab === 'scores') {
|
||||||
where: eq(user.id, r.userId)
|
return loadScores(params);
|
||||||
});
|
}
|
||||||
return {
|
return loadPlayers(params);
|
||||||
rank: i + 1,
|
|
||||||
userId: r.userId,
|
|
||||||
name: u?.name ?? 'Unknown',
|
|
||||||
image: u?.image ?? null,
|
|
||||||
elo: r.elo,
|
|
||||||
wins: r.wins,
|
|
||||||
losses: r.losses,
|
|
||||||
winRate: r.wins + r.losses > 0 ? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1) : '—'
|
|
||||||
};
|
};
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Top teams by match wins ──
|
async function loadPlayers(params: ReturnType<typeof parseTableParams>) {
|
||||||
// Count wins for each team from matchParticipant scores
|
const [rows, countResult] = await Promise.all([
|
||||||
const teamWins = await db
|
db
|
||||||
|
.select({
|
||||||
|
userId: playerRating.userId,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image,
|
||||||
|
elo: playerRating.elo,
|
||||||
|
wins: playerRating.wins,
|
||||||
|
losses: playerRating.losses
|
||||||
|
})
|
||||||
|
.from(playerRating)
|
||||||
|
.innerJoin(user, eq(playerRating.userId, user.id))
|
||||||
|
.orderBy(desc(playerRating.elo))
|
||||||
|
.limit(params.limit)
|
||||||
|
.offset((params.page - 1) * params.limit),
|
||||||
|
db.select({ count: sql<number>`count(*)` }).from(playerRating)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const topPlayers = rows.map((r, i) => ({
|
||||||
|
...r,
|
||||||
|
rank: (params.page - 1) * params.limit + i + 1,
|
||||||
|
winRate: r.wins + r.losses > 0 ? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1) : '—'
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
tab: 'players' as const,
|
||||||
|
topPlayers,
|
||||||
|
topTeams: [],
|
||||||
|
highScores: [],
|
||||||
|
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTeams(params: ReturnType<typeof parseTableParams>) {
|
||||||
|
// Get teams ranked by match wins, excluding personal teams
|
||||||
|
const [rows, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
.select({
|
.select({
|
||||||
teamId: match.winnerId,
|
teamId: match.winnerId,
|
||||||
wins: sql<number>`count(*)`.as('wins')
|
name: team.name,
|
||||||
|
avatarUrl: team.avatarUrl,
|
||||||
|
isPersonal: team.isPersonal,
|
||||||
|
wins: sql<number>`count(*)`.as('wins'),
|
||||||
|
memberCount: sql<number>`(SELECT count(*) FROM team_member WHERE team_id = ${team.id})`
|
||||||
})
|
})
|
||||||
.from(match)
|
.from(match)
|
||||||
.where(and(sql`${match.winnerId} IS NOT NULL`, eq(match.state, 'FINISHED')))
|
.innerJoin(team, eq(match.winnerId, team.id))
|
||||||
.groupBy(match.winnerId)
|
.where(
|
||||||
|
and(
|
||||||
|
sql`${match.winnerId} IS NOT NULL`,
|
||||||
|
eq(match.state, 'FINISHED'),
|
||||||
|
eq(team.isPersonal, false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(match.winnerId, team.id, team.name, team.avatarUrl, team.isPersonal)
|
||||||
.orderBy(sql`count(*) DESC`)
|
.orderBy(sql`count(*) DESC`)
|
||||||
.limit(15);
|
.limit(params.limit)
|
||||||
|
.offset((params.page - 1) * params.limit),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(DISTINCT ${match.winnerId})` })
|
||||||
|
.from(match)
|
||||||
|
.innerJoin(team, eq(match.winnerId, team.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
sql`${match.winnerId} IS NOT NULL`,
|
||||||
|
eq(match.state, 'FINISHED'),
|
||||||
|
eq(team.isPersonal, false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
const teamsWithDetails = await Promise.all(
|
// Batch-fetch total match counts for these teams
|
||||||
teamWins.map(async (tw, i) => {
|
const teamIds = rows.map((r) => r.teamId).filter(Boolean) as string[];
|
||||||
if (!tw.teamId) return null;
|
const matchCounts: Record<string, number> = {};
|
||||||
const t = await db.query.team.findFirst({
|
if (teamIds.length > 0) {
|
||||||
where: eq(team.id, tw.teamId),
|
const counts = await db
|
||||||
with: { members: true }
|
.select({
|
||||||
});
|
teamId: matchParticipant.teamId,
|
||||||
if (!t) return null;
|
count: sql<number>`count(DISTINCT ${matchParticipant.matchId})`
|
||||||
if (t.isPersonal) return null; // Skip personal teams
|
})
|
||||||
|
|
||||||
// Count total matches for this team
|
|
||||||
const totalMatches = await db
|
|
||||||
.select({ count: sql<number>`count(DISTINCT ${matchParticipant.matchId})` })
|
|
||||||
.from(matchParticipant)
|
.from(matchParticipant)
|
||||||
.where(eq(matchParticipant.teamId, tw.teamId));
|
.where(
|
||||||
|
sql`${matchParticipant.teamId} IN (${sql.join(
|
||||||
|
teamIds.map((id) => sql`${id}`),
|
||||||
|
sql`, `
|
||||||
|
)})`
|
||||||
|
)
|
||||||
|
.groupBy(matchParticipant.teamId);
|
||||||
|
for (const c of counts) {
|
||||||
|
matchCounts[c.teamId] = Number(c.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topTeams = rows.map((r, i) => ({
|
||||||
|
rank: (params.page - 1) * params.limit + i + 1,
|
||||||
|
teamId: r.teamId,
|
||||||
|
name: r.name,
|
||||||
|
avatarUrl: r.avatarUrl,
|
||||||
|
isPersonal: r.isPersonal,
|
||||||
|
memberCount: Number(r.memberCount),
|
||||||
|
wins: Number(r.wins),
|
||||||
|
totalMatches: matchCounts[r.teamId!] ?? 0
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rank: i + 1,
|
tab: 'teams' as const,
|
||||||
teamId: t.id,
|
topPlayers: [],
|
||||||
name: t.name,
|
topTeams,
|
||||||
avatarUrl: t.avatarUrl,
|
highScores: [],
|
||||||
isPersonal: t.isPersonal,
|
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||||
memberCount: t.members.length,
|
|
||||||
wins: Number(tw.wins),
|
|
||||||
totalMatches: Number(totalMatches[0]?.count ?? 0)
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScores(params: ReturnType<typeof parseTableParams>) {
|
||||||
|
const [rows, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: matchGameScore.id,
|
||||||
|
score: matchGameScore.score,
|
||||||
|
accuracy: matchGameScore.accuracy,
|
||||||
|
maxCombo: matchGameScore.maxCombo,
|
||||||
|
pp: matchGameScore.pp,
|
||||||
|
mods: matchGameScore.mods,
|
||||||
|
passed: matchGameScore.passed,
|
||||||
|
playerUserId: matchParticipantPlayer.userId,
|
||||||
|
playerName: user.name,
|
||||||
|
playerImage: user.image,
|
||||||
|
mapCategory: mappoolSlot.category,
|
||||||
|
mapOrder: mappoolSlot.orderInCategory,
|
||||||
|
matchName: match.name,
|
||||||
|
matchId: matchGame.matchId
|
||||||
})
|
})
|
||||||
);
|
|
||||||
|
|
||||||
const topTeams = teamsWithDetails.filter((x): x is NonNullable<typeof x> => Boolean(x));
|
|
||||||
|
|
||||||
// ── High scores — sorted by pp (nulls last) then score ──
|
|
||||||
const recentHighScores = await db
|
|
||||||
.select()
|
|
||||||
.from(matchGameScore)
|
.from(matchGameScore)
|
||||||
|
.innerJoin(matchParticipantPlayer, eq(matchGameScore.playerId, matchParticipantPlayer.id))
|
||||||
|
.innerJoin(user, eq(matchParticipantPlayer.userId, user.id))
|
||||||
|
.innerJoin(matchGame, eq(matchGameScore.matchGameId, matchGame.id))
|
||||||
|
.innerJoin(mappoolSlot, eq(matchGame.mappoolSlotId, mappoolSlot.id))
|
||||||
|
.innerJoin(match, eq(matchGame.matchId, match.id))
|
||||||
.orderBy(sql`${matchGameScore.pp} DESC NULLS LAST`, desc(matchGameScore.score))
|
.orderBy(sql`${matchGameScore.pp} DESC NULLS LAST`, desc(matchGameScore.score))
|
||||||
.limit(15)
|
.limit(params.limit)
|
||||||
.then((rows) =>
|
.offset((params.page - 1) * params.limit),
|
||||||
Promise.all(
|
db.select({ count: sql<number>`count(*)` }).from(matchGameScore)
|
||||||
rows.map((s) =>
|
]);
|
||||||
db.query.matchGameScore.findFirst({
|
|
||||||
where: eq(matchGameScore.id, s.id),
|
|
||||||
with: {
|
|
||||||
player: true,
|
|
||||||
game: {
|
|
||||||
with: { slot: true, match: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const highScoresWithNames = await Promise.all(
|
const highScores = rows.map((s, i) => ({
|
||||||
recentHighScores.map(async (s, i) => {
|
rank: (params.page - 1) * params.limit + i + 1,
|
||||||
if (!s) return null;
|
playerUserId: s.playerUserId,
|
||||||
const u = await db.query.user.findFirst({
|
playerName: s.playerName ?? 'Unknown',
|
||||||
where: eq(user.id, s.player.userId)
|
playerImage: s.playerImage,
|
||||||
});
|
|
||||||
return {
|
|
||||||
rank: i + 1,
|
|
||||||
playerName: u?.name ?? 'Unknown',
|
|
||||||
playerImage: u?.image ?? null,
|
|
||||||
score: s.score,
|
score: s.score,
|
||||||
accuracy: s.accuracy,
|
accuracy: s.accuracy,
|
||||||
maxCombo: s.maxCombo,
|
|
||||||
count300: s.count300,
|
|
||||||
count100: s.count100,
|
|
||||||
count50: s.count50,
|
|
||||||
countMiss: s.countMiss,
|
|
||||||
pp: s.pp,
|
pp: s.pp,
|
||||||
mods: s.mods,
|
mods: s.mods,
|
||||||
passed: s.passed,
|
mapCategory: s.mapCategory ?? '',
|
||||||
beatmapId: s.game.slot?.beatmapId ?? null,
|
mapOrder: s.mapOrder ?? 0,
|
||||||
mapCategory: s.game.slot?.category ?? '',
|
matchName: s.matchName ?? 'Match',
|
||||||
mapOrder: s.game.slot?.orderInCategory ?? 0,
|
matchId: s.matchId
|
||||||
matchName: s.game.match?.name ?? 'Match',
|
}));
|
||||||
matchId: s.game.matchId
|
|
||||||
};
|
|
||||||
})
|
|
||||||
).then((rows) => rows.filter((x): x is NonNullable<typeof x> => Boolean(x)));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
topPlayers: playersWithUsers,
|
tab: 'scores' as const,
|
||||||
topTeams: topTeams,
|
topPlayers: [],
|
||||||
highScores: highScoresWithNames
|
topTeams: [],
|
||||||
};
|
highScores,
|
||||||
|
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
|
|
||||||
|
function rowClick(href: string) {
|
||||||
|
return () => goto(href);
|
||||||
|
}
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
let activeTab = $state<'players' | 'teams' | 'scores'>('players');
|
const activeTab = $derived(data.tab);
|
||||||
|
|
||||||
|
function setTab(tab: string) {
|
||||||
|
const url = new URL($page.url);
|
||||||
|
url.searchParams.set('tab', tab);
|
||||||
|
url.searchParams.set('page', '1');
|
||||||
|
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'players' as const, label: 'Top Players', icon: '👤' },
|
{ id: 'players', label: 'Top Players', icon: '👤' },
|
||||||
{ id: 'teams' as const, label: 'Top Teams', icon: '⚑' },
|
{ id: 'teams', label: 'Top Teams', icon: '⚑' },
|
||||||
{ id: 'scores' as const, label: 'High Scores', icon: '🎯' }
|
{ id: 'scores', label: 'High Scores', icon: '🎯' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function eloColor(elo: number): string {
|
function eloColor(elo: number): string {
|
||||||
@@ -32,6 +47,33 @@
|
|||||||
FM: 'text-green-400',
|
FM: 'text-green-400',
|
||||||
TB: 'text-pink-400'
|
TB: 'text-pink-400'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const playerColumns = [
|
||||||
|
{ key: 'rank', label: '#', class: 'w-10 pl-4 text-center' },
|
||||||
|
{ key: 'avatar', label: '', class: 'w-10' },
|
||||||
|
{ key: 'name', label: 'Player', class: 'px-4 text-left' },
|
||||||
|
{ key: 'elo', label: 'ELO', class: 'w-16 pr-4 text-right' },
|
||||||
|
{ key: 'wl', label: 'W/L', class: 'w-16 pr-4 text-right' },
|
||||||
|
{ key: 'winRate', label: 'Win %', class: 'w-16 pr-4 text-right' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const teamColumns = [
|
||||||
|
{ key: 'rank', label: '#', class: 'w-10 pl-4 text-center' },
|
||||||
|
{ key: 'avatar', label: '', class: 'w-10' },
|
||||||
|
{ key: 'name', label: 'Team', class: 'px-4 text-left' },
|
||||||
|
{ key: 'wins', label: 'Wins', class: 'w-16 pr-4 text-right' },
|
||||||
|
{ key: 'matches', label: 'Matches', class: 'w-20 pr-4 text-right' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const scoreColumns = [
|
||||||
|
{ key: 'rank', label: '#', class: 'w-10 pl-4 text-center' },
|
||||||
|
{ key: 'avatar', label: '', class: 'w-10' },
|
||||||
|
{ key: 'player', label: 'Player', class: 'px-4 text-left' },
|
||||||
|
{ key: 'map', label: 'Map', class: 'w-12 pr-4 text-right' },
|
||||||
|
{ key: 'pp', label: 'PP', class: 'w-16 pr-4 text-right' },
|
||||||
|
{ key: 'score', label: 'Score', class: 'w-24 pr-4 text-right' },
|
||||||
|
{ key: 'acc', label: 'Acc', class: 'w-16 pr-4 text-right' }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -53,7 +95,7 @@
|
|||||||
<div class="mt-6 flex gap-1 rounded-lg border border-border bg-surface-800 p-1">
|
<div class="mt-6 flex gap-1 rounded-lg border border-border bg-surface-800 p-1">
|
||||||
{#each tabs as tab}
|
{#each tabs as tab}
|
||||||
<button
|
<button
|
||||||
onclick={() => (activeTab = tab.id)}
|
onclick={() => setTab(tab.id)}
|
||||||
class="font-600 flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm transition-colors {activeTab ===
|
class="font-600 flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm transition-colors {activeTab ===
|
||||||
tab.id
|
tab.id
|
||||||
? 'bg-accent text-surface-900'
|
? 'bg-accent text-surface-900'
|
||||||
@@ -67,28 +109,14 @@
|
|||||||
|
|
||||||
<!-- Top Players -->
|
<!-- Top Players -->
|
||||||
{#if activeTab === 'players'}
|
{#if activeTab === 'players'}
|
||||||
<div class="mt-6 overflow-x-auto rounded-lg border border-border bg-surface-800">
|
<div class="mt-6">
|
||||||
{#if data.topPlayers.length === 0}
|
<DataTable data={data.topPlayers} meta={data.meta} columns={playerColumns}>
|
||||||
<div class="py-12 text-center">
|
{#snippet row(p, _i)}
|
||||||
<p class="text-sm text-text-secondary">No ranked players yet. Play some matches!</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="font-600 border-b border-border text-left text-xs text-text-secondary">
|
|
||||||
<th class="w-10 py-3 pl-4 text-center">#</th>
|
|
||||||
<th class="w-10 py-3"></th>
|
|
||||||
<th class="py-3">Player</th>
|
|
||||||
<th class="w-16 py-3 pr-4 text-right">ELO</th>
|
|
||||||
<th class="w-16 py-3 pr-4 text-right">W/L</th>
|
|
||||||
<th class="w-16 py-3 pr-4 text-right">Win %</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each data.topPlayers as p}
|
|
||||||
{@const medal = rankBadge(p.rank)}
|
{@const medal = rankBadge(p.rank)}
|
||||||
<tr
|
<tr
|
||||||
class="border-b border-border/50 last:border-b-0 {p.rank <= 3
|
onclick={rowClick(`/users/${p.userId}`)}
|
||||||
|
class="cursor-pointer border-b border-border/50 transition-colors last:border-b-0 hover:bg-surface-800/50 {p.rank <=
|
||||||
|
3
|
||||||
? 'bg-accent/[0.02]'
|
? 'bg-accent/[0.02]'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
@@ -127,36 +155,21 @@
|
|||||||
{p.winRate}{p.winRate !== '—' ? '%' : ''}
|
{p.winRate}{p.winRate !== '—' ? '%' : ''}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/snippet}
|
||||||
</tbody>
|
</DataTable>
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Top Teams -->
|
<!-- Top Teams -->
|
||||||
{#if activeTab === 'teams'}
|
{#if activeTab === 'teams'}
|
||||||
<div class="mt-6 overflow-x-auto rounded-lg border border-border bg-surface-800">
|
<div class="mt-6">
|
||||||
{#if data.topTeams.length === 0}
|
<DataTable data={data.topTeams} meta={data.meta} columns={teamColumns}>
|
||||||
<div class="py-12 text-center">
|
{#snippet row(t, _i)}
|
||||||
<p class="text-sm text-text-secondary">No team results yet.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="font-600 border-b border-border text-left text-xs text-text-secondary">
|
|
||||||
<th class="w-10 py-3 pl-4 text-center">#</th>
|
|
||||||
<th class="w-10 py-3"></th>
|
|
||||||
<th class="py-3">Team</th>
|
|
||||||
<th class="w-16 py-3 pr-4 text-right">Wins</th>
|
|
||||||
<th class="w-20 py-3 pr-4 text-right">Matches</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each data.topTeams as t}
|
|
||||||
{@const medal = rankBadge(t.rank)}
|
{@const medal = rankBadge(t.rank)}
|
||||||
<tr
|
<tr
|
||||||
class="border-b border-border/50 last:border-b-0 {t.rank <= 3
|
onclick={rowClick(`/teams/${t.teamId}`)}
|
||||||
|
class="cursor-pointer border-b border-border/50 transition-colors last:border-b-0 hover:bg-surface-800/50 {t.rank <=
|
||||||
|
3
|
||||||
? 'bg-accent/[0.02]'
|
? 'bg-accent/[0.02]'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
@@ -179,9 +192,6 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<span class="font-600 text-sm">{t.name}</span>
|
<span class="font-600 text-sm">{t.name}</span>
|
||||||
{#if t.isPersonal}
|
|
||||||
<span class="ml-1 text-[10px] text-text-secondary">(solo)</span>
|
|
||||||
{/if}
|
|
||||||
<p class="text-xs text-text-secondary">
|
<p class="text-xs text-text-secondary">
|
||||||
{t.memberCount} member{t.memberCount !== 1 ? 's' : ''}
|
{t.memberCount} member{t.memberCount !== 1 ? 's' : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -193,43 +203,21 @@
|
|||||||
>{t.totalMatches}</td
|
>{t.totalMatches}</td
|
||||||
>
|
>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/snippet}
|
||||||
</tbody>
|
</DataTable>
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- High Scores -->
|
<!-- High Scores -->
|
||||||
{#if activeTab === 'scores'}
|
{#if activeTab === 'scores'}
|
||||||
<div class="mt-6 overflow-x-auto rounded-lg border border-border bg-surface-800">
|
<div class="mt-6">
|
||||||
{#if data.highScores.length === 0}
|
<DataTable data={data.highScores} meta={data.meta} columns={scoreColumns}>
|
||||||
<div class="py-12 text-center">
|
{#snippet row(s, _i)}
|
||||||
<p class="text-sm text-text-secondary">No scores recorded yet.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="border-b border-border px-4 py-2.5">
|
|
||||||
<p class="text-xs text-text-secondary">
|
|
||||||
Ranked by pp (performance points) when available, then score
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="font-600 border-b border-border text-left text-xs text-text-secondary">
|
|
||||||
<th class="w-10 py-3 pl-4 text-center">#</th>
|
|
||||||
<th class="w-10 py-3"></th>
|
|
||||||
<th class="py-3">Player</th>
|
|
||||||
<th class="w-12 py-3 pr-4 text-right">Map</th>
|
|
||||||
<th class="w-16 py-3 pr-4 text-right text-accent">PP</th>
|
|
||||||
<th class="w-24 py-3 pr-4 text-right">Score</th>
|
|
||||||
<th class="w-16 py-3 pr-4 text-right">Acc</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each data.highScores as s}
|
|
||||||
{@const medal = rankBadge(s.rank)}
|
{@const medal = rankBadge(s.rank)}
|
||||||
<tr
|
<tr
|
||||||
class="border-b border-border/50 last:border-b-0 {s.rank <= 3
|
onclick={rowClick(`/users/${s.playerUserId}`)}
|
||||||
|
class="cursor-pointer border-b border-border/50 transition-colors last:border-b-0 hover:bg-surface-800/50 {s.rank <=
|
||||||
|
3
|
||||||
? 'bg-accent/[0.02]'
|
? 'bg-accent/[0.02]'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
@@ -252,9 +240,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<span class="font-600 text-sm">{s.playerName}</span>
|
<span class="font-600 text-sm">{s.playerName}</span>
|
||||||
<p class="truncate text-xs text-text-secondary">
|
<p class="truncate text-xs text-text-secondary">{s.matchName}</p>
|
||||||
<a href="/matches/{s.matchId}" class="hover:text-accent">{s.matchName}</a>
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="font-600 py-3 pr-4 text-right text-xs {catColors[s.mapCategory] ??
|
class="font-600 py-3 pr-4 text-right text-xs {catColors[s.mapCategory] ??
|
||||||
@@ -276,10 +262,8 @@
|
|||||||
{s.accuracy > 0 ? `${(s.accuracy * 100).toFixed(1)}%` : '—'}
|
{s.accuracy > 0 ? `${(s.accuracy * 100).toFixed(1)}%` : '—'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/snippet}
|
||||||
</tbody>
|
</DataTable>
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,56 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
|
import { mappool, mappoolSlot } from '$lib/server/db/schema';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { requireAuth } from '$lib/server/permissions';
|
||||||
|
import {
|
||||||
|
parseTableParams,
|
||||||
|
buildSearchFilter,
|
||||||
|
buildOrderBy,
|
||||||
|
buildTableMeta
|
||||||
|
} from '$lib/server/table';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { mappool } from '$lib/server/db/schema';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
const mapCount = sql<number>`(SELECT count(*) FROM mappool_slot WHERE mappool_id = ${mappool.id})`;
|
||||||
if (!locals.user) redirect(302, '/');
|
const avgStarRating = sql<number>`(SELECT coalesce(avg(star_rating), 0) FROM mappool_slot WHERE mappool_id = ${mappool.id})`;
|
||||||
|
|
||||||
const mappools = await db.query.mappool.findMany({ with: { slots: true } });
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
|
const params = parseTableParams(url, { sortBy: 'createdAt', limit: 25 });
|
||||||
|
const searchFilter = buildSearchFilter(params.search, [mappool.name]);
|
||||||
|
|
||||||
const avgSR = (m: (typeof mappools)[0]) =>
|
const [rows, countResult] = await Promise.all([
|
||||||
m.slots.length ? m.slots.reduce((s, sl) => s + (sl.starRating ?? 0), 0) / m.slots.length : 0;
|
db.query.mappool.findMany({
|
||||||
|
with: { slots: true },
|
||||||
|
where: searchFilter,
|
||||||
|
orderBy: buildOrderBy(
|
||||||
|
params.sortBy,
|
||||||
|
params.sortDir,
|
||||||
|
{
|
||||||
|
name: mappool.name,
|
||||||
|
createdAt: mappool.createdAt,
|
||||||
|
maps: mapCount,
|
||||||
|
avgSr: avgStarRating
|
||||||
|
},
|
||||||
|
mappool.createdAt
|
||||||
|
),
|
||||||
|
limit: params.limit,
|
||||||
|
offset: (params.page - 1) * params.limit
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(mappool)
|
||||||
|
.where(searchFilter)
|
||||||
|
]);
|
||||||
|
|
||||||
mappools.sort((a, b) => avgSR(b) - avgSR(a));
|
return {
|
||||||
|
mappools: rows,
|
||||||
return { mappools };
|
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
create: async ({ request, locals }) => {
|
create: async ({ request, locals }) => {
|
||||||
if (!locals.user) redirect(302, '/');
|
requireAuth(locals);
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const name = form.get('name')?.toString()?.trim();
|
const name = form.get('name')?.toString()?.trim();
|
||||||
@@ -26,7 +58,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(mappool)
|
.insert(mappool)
|
||||||
.values({ name, createdBy: locals.user.id })
|
.values({ name, createdBy: locals.user!.id })
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
redirect(303, `/mappools/${created.id}`);
|
redirect(303, `/mappools/${created.id}`);
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: 'Pool', sortable: true },
|
||||||
|
{ key: 'maps', label: 'Maps', sortable: true },
|
||||||
|
{ key: 'avgSr', label: 'Avg SR', sortable: true },
|
||||||
|
{ key: 'createdAt', label: 'Created', sortable: true, class: 'hidden sm:table-cell' }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -18,10 +26,12 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl">
|
<div class="mx-auto max-w-4xl">
|
||||||
<div class="flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="font-700 text-2xl tracking-tight">Mappools</h1>
|
<h1 class="font-700 text-2xl tracking-tight">Mappools</h1>
|
||||||
<p class="mt-1 text-sm text-text-secondary">Create and manage your beatmap pools</p>
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
{data.meta.total} pool{data.meta.total !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() => (creating = !creating)}
|
onclick={() => (creating = !creating)}
|
||||||
@@ -36,7 +46,7 @@
|
|||||||
method="post"
|
method="post"
|
||||||
action="?/create"
|
action="?/create"
|
||||||
use:enhance
|
use:enhance
|
||||||
class="mt-4 flex gap-3 rounded-lg border border-border bg-surface-800 p-4"
|
class="mb-6 flex gap-3 rounded-lg border border-border bg-surface-800 p-4"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -54,39 +64,35 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mt-6 flex flex-col gap-3">
|
<DataTable data={data.mappools} meta={data.meta} {columns} searchPlaceholder="Search mappools...">
|
||||||
{#each data.mappools as pool}
|
{#snippet row(pool, _i)}
|
||||||
{@const rated = pool.slots.filter((s: any) => s.starRating != null)}
|
{@const rated = pool.slots.filter((s: any) => s.starRating != null)}
|
||||||
{@const avgSr =
|
{@const avgSr =
|
||||||
rated.length > 0
|
rated.length > 0
|
||||||
? (rated.reduce((sum: number, s: any) => sum + s.starRating, 0) / rated.length).toFixed(2)
|
? (rated.reduce((sum: number, s: any) => sum + s.starRating, 0) / rated.length).toFixed(2)
|
||||||
: null}
|
: null}
|
||||||
<a
|
<tr class="transition-colors hover:bg-surface-800/50">
|
||||||
href="/mappools/{pool.id}"
|
<td class="px-4 py-3">
|
||||||
class="group flex items-center justify-between rounded-lg border border-border bg-surface-800 p-4 transition-colors hover:border-accent/40 hover:bg-surface-700"
|
<a href="/mappools/{pool.id}" class="flex items-center gap-2 hover:text-accent">
|
||||||
>
|
<span class="font-600 text-sm">{pool.name}</span>
|
||||||
<div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<h2 class="font-600 text-sm">{pool.name}</h2>
|
|
||||||
{#if pool.verifiedAt}
|
{#if pool.verifiedAt}
|
||||||
<span
|
<span
|
||||||
class="font-600 rounded border border-green-500/30 bg-green-500/10 px-1.5 py-0.5 text-[10px] text-green-400"
|
class="font-600 rounded border border-green-500/30 bg-green-500/10 px-1.5 py-0.5 text-[10px] text-green-400"
|
||||||
>✓ Verified</span
|
>Verified</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-text-secondary">
|
|
||||||
{pool.slots.length} map{pool.slots.length !== 1 ? 's' : ''}
|
|
||||||
{#if avgSr}· {avgSr}★{/if}
|
|
||||||
· Created {new Date(pool.createdAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-text-secondary transition-colors group-hover:text-accent">→</span>
|
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
</td>
|
||||||
<div class="rounded-lg border border-dashed border-border py-12 text-center">
|
<td class="px-4 py-3 text-sm text-text-secondary">
|
||||||
<p class="text-sm text-text-secondary">No mappools yet. Create one to get started.</p>
|
{pool.slots.length}
|
||||||
</div>
|
</td>
|
||||||
{/each}
|
<td class="px-4 py-3 text-sm text-text-secondary">
|
||||||
</div>
|
{avgSr ? `${avgSr}★` : '—'}
|
||||||
|
</td>
|
||||||
|
<td class="hidden px-4 py-3 text-sm text-text-secondary sm:table-cell">
|
||||||
|
{new Date(pool.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/snippet}
|
||||||
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import { categoryToMods, isValidCategory, MOD_REGEX } from '$lib/mods';
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
requireAuth(locals);
|
|
||||||
|
|
||||||
const pool = await db.query.mappool.findFirst({
|
const pool = await db.query.mappool.findFirst({
|
||||||
where: eq(mappool.id, params.id),
|
where: eq(mappool.id, params.id),
|
||||||
with: {
|
with: {
|
||||||
@@ -85,24 +83,35 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const isAdmin = locals.user!.role === 'admin';
|
const isAdmin = locals.user?.role === 'admin';
|
||||||
|
const isOwner = locals.user ? pool.createdBy === locals.user.id : false;
|
||||||
|
const isVerified = !!pool.verifiedAt;
|
||||||
|
// Admins can always edit; owners can edit their own unverified pools
|
||||||
|
const canEdit = isAdmin || (isOwner && !isVerified);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pool: { ...pool, slots: slotsWithBeatmaps },
|
pool: { ...pool, slots: slotsWithBeatmaps },
|
||||||
canEdit: isAdmin,
|
canEdit,
|
||||||
isAdmin
|
isAdmin,
|
||||||
|
isVerified
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Check that the user can edit this pool (admin or owner of unverified pool) */
|
||||||
|
async function requireCanEdit(locals: App.Locals, poolId: string) {
|
||||||
|
requireAuth(locals);
|
||||||
|
const pool = await db.query.mappool.findFirst({ where: eq(mappool.id, poolId) });
|
||||||
|
if (!pool) error(404, 'Mappool not found');
|
||||||
|
if (pool.verifiedAt) error(403, 'Verified pools cannot be edited');
|
||||||
|
const isAdmin = locals.user!.role === 'admin';
|
||||||
|
const isOwner = pool.createdBy === locals.user!.id;
|
||||||
|
if (!isAdmin && !isOwner) error(403, 'Not authorized to edit this pool');
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
rename: async ({ params, request, locals }) => {
|
rename: async ({ params, request, locals }) => {
|
||||||
requireAuth(locals);
|
await requireCanEdit(locals, params.id);
|
||||||
if (locals.user!.role !== 'admin') error(403, 'Admins only');
|
|
||||||
|
|
||||||
const pool = await db.query.mappool.findFirst({
|
|
||||||
where: eq(mappool.id, params.id)
|
|
||||||
});
|
|
||||||
if (!pool) error(404, 'Mappool not found');
|
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const name = form.get('name')?.toString()?.trim();
|
const name = form.get('name')?.toString()?.trim();
|
||||||
@@ -113,8 +122,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
addSlot: async ({ params, request, locals }) => {
|
addSlot: async ({ params, request, locals }) => {
|
||||||
requireAuth(locals);
|
await requireCanEdit(locals, params.id);
|
||||||
if (locals.user!.role !== 'admin') error(403, 'Admins only');
|
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
let beatmapId = form.get('beatmapId')?.toString()?.trim();
|
let beatmapId = form.get('beatmapId')?.toString()?.trim();
|
||||||
@@ -177,8 +185,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeSlot: async ({ params, request, locals }) => {
|
removeSlot: async ({ params, request, locals }) => {
|
||||||
requireAuth(locals);
|
await requireCanEdit(locals, params.id);
|
||||||
if (locals.user!.role !== 'admin') error(403, 'Admins only');
|
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const slotId = form.get('slotId')?.toString();
|
const slotId = form.get('slotId')?.toString();
|
||||||
@@ -207,8 +214,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
moveSlot: async ({ params, request, locals }) => {
|
moveSlot: async ({ params, request, locals }) => {
|
||||||
requireAuth(locals);
|
await requireCanEdit(locals, params.id);
|
||||||
if (locals.user!.role !== 'admin') error(403, 'Admins only');
|
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const slotId = form.get('slotId')?.toString();
|
const slotId = form.get('slotId')?.toString();
|
||||||
@@ -275,8 +281,7 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
bulkImport: async ({ params, request, locals }) => {
|
bulkImport: async ({ params, request, locals }) => {
|
||||||
requireAuth(locals);
|
await requireCanEdit(locals, params.id);
|
||||||
if (locals.user!.role !== 'admin') error(403, 'Admins only');
|
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const raw = form.get('data')?.toString()?.trim();
|
const raw = form.get('data')?.toString()?.trim();
|
||||||
|
|||||||
@@ -2,17 +2,28 @@ import { log } from '$lib/server/logger';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { match, teamMember } from '$lib/server/db/schema';
|
import { match, teamMember } from '$lib/server/db/schema';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { desc, eq } from 'drizzle-orm';
|
import { desc, eq, sql, inArray } from 'drizzle-orm';
|
||||||
import { createMatch } from '$lib/server/match/engine';
|
import { createMatch } from '$lib/server/match/engine';
|
||||||
import { initMatchLobby } from '$lib/server/match/orchestrator';
|
import { initMatchLobby } from '$lib/server/match/orchestrator';
|
||||||
import { createInvite } from '$lib/server/match/invites';
|
import { createInvite } from '$lib/server/match/invites';
|
||||||
import { requireAuth, requireRole, hasRole } from '$lib/server/permissions';
|
import {
|
||||||
|
requireAuth,
|
||||||
|
requirePermission,
|
||||||
|
hasPermission,
|
||||||
|
GlobalPermission
|
||||||
|
} from '$lib/server/permissions';
|
||||||
|
import { parseTableParams, buildSearchFilter, buildTableMeta } from '$lib/server/table';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
requireAuth(locals);
|
const params = parseTableParams(url, { sortBy: 'createdAt', limit: 25 });
|
||||||
|
const searchFilter = buildSearchFilter(params.search, [match.name]);
|
||||||
|
|
||||||
const matches = await db.query.match.findMany({
|
// Always fetch live matches separately (not paginated)
|
||||||
|
const liveStates = ['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING'];
|
||||||
|
|
||||||
|
const [liveMatches, rows, countResult] = await Promise.all([
|
||||||
|
db.query.match.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -24,36 +35,51 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
participants: {
|
participants: {
|
||||||
columns: {
|
columns: { teamId: true, score: true, slot: true },
|
||||||
teamId: true,
|
|
||||||
score: true,
|
|
||||||
slot: true
|
|
||||||
},
|
|
||||||
orderBy: (p, { asc }) => [asc(p.slot)],
|
orderBy: (p, { asc }) => [asc(p.slot)],
|
||||||
with: {
|
with: { team: { columns: { id: true, name: true, avatarUrl: true } } }
|
||||||
team: {
|
},
|
||||||
|
mappool: { columns: { id: true, name: true } }
|
||||||
|
},
|
||||||
|
where: inArray(match.state, liveStates),
|
||||||
|
orderBy: desc(match.createdAt)
|
||||||
|
}),
|
||||||
|
db.query.match.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
avatarUrl: true
|
state: true,
|
||||||
}
|
config: true,
|
||||||
}
|
createdAt: true,
|
||||||
}
|
finishedAt: true,
|
||||||
|
winnerId: true
|
||||||
},
|
},
|
||||||
mappool: {
|
with: {
|
||||||
columns: {
|
participants: {
|
||||||
id: true,
|
columns: { teamId: true, score: true, slot: true },
|
||||||
name: true
|
orderBy: (p, { asc }) => [asc(p.slot)],
|
||||||
}
|
with: { team: { columns: { id: true, name: true, avatarUrl: true } } }
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
mappool: { columns: { id: true, name: true } }
|
||||||
|
},
|
||||||
|
where: searchFilter,
|
||||||
orderBy: desc(match.createdAt),
|
orderBy: desc(match.createdAt),
|
||||||
limit: 50
|
limit: params.limit,
|
||||||
});
|
offset: (params.page - 1) * params.limit
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(match)
|
||||||
|
.where(searchFilter)
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
matches,
|
liveMatches,
|
||||||
canCreateMatch: hasRole(locals.user!.role, 'referee')
|
matches: rows,
|
||||||
|
meta: buildTableMeta(params, Number(countResult[0].count)),
|
||||||
|
canCreateMatch: locals.user
|
||||||
|
? hasPermission(locals.user.role, GlobalPermission.MATCH_CREATE)
|
||||||
|
: false
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,8 +110,11 @@ export const actions: Actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
createMatch: async ({ request, locals }) => {
|
createMatch: async ({ request, locals }) => {
|
||||||
// Only referees and admins can create matches manually
|
requirePermission(
|
||||||
requireRole(locals, 'referee', 'Only referees and admins can create matches');
|
locals,
|
||||||
|
GlobalPermission.MATCH_CREATE,
|
||||||
|
'Only referees and admins can create matches'
|
||||||
|
);
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const name = form.get('name')?.toString()?.trim() || 'Custom Match';
|
const name = form.get('name')?.toString()?.trim() || 'Custom Match';
|
||||||
@@ -115,7 +144,6 @@ export const actions: Actions = {
|
|||||||
return { error: e.message };
|
return { error: e.message };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create IRC lobby in background
|
|
||||||
initMatchLobby(result.id).catch((err) => {
|
initMatchLobby(result.id).catch((err) => {
|
||||||
log.match.error({ err, matchId: result.id }, 'IRC lobby failed');
|
log.match.error({ err, matchId: result.id }, 'IRC lobby failed');
|
||||||
});
|
});
|
||||||
@@ -146,7 +174,6 @@ export const actions: Actions = {
|
|||||||
return { error: 'All fields are required' };
|
return { error: 'All fields are required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate bestOf is odd and >= 1
|
|
||||||
if (bestOf < 1 || bestOf % 2 === 0) {
|
if (bestOf < 1 || bestOf % 2 === 0) {
|
||||||
return { error: 'Best of must be an odd number >= 1' };
|
return { error: 'Best of must be an odd number >= 1' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -45,7 +46,6 @@
|
|||||||
);
|
);
|
||||||
const hasMultipleTeams = $derived(nonPersonalUserTeams.length > 0);
|
const hasMultipleTeams = $derived(nonPersonalUserTeams.length > 0);
|
||||||
|
|
||||||
// Auto-select personal team by default
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!selectedCreatorTeamId && personalTeam) {
|
if (!selectedCreatorTeamId && personalTeam) {
|
||||||
selectedCreatorTeamId = personalTeam.id;
|
selectedCreatorTeamId = personalTeam.id;
|
||||||
@@ -53,10 +53,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectedCreatorTeam = $derived(
|
const selectedCreatorTeam = $derived(
|
||||||
(formData ?? data).teams.find((t: any) => t.id === selectedCreatorTeamId)
|
(formData ?? data).teams?.find((t: any) => t.id === selectedCreatorTeamId)
|
||||||
);
|
);
|
||||||
const selectedInvitedTeam = $derived(
|
const selectedInvitedTeam = $derived(
|
||||||
(formData ?? data).teams.find((t: any) => t.id === selectedInvitedTeamId)
|
(formData ?? data).teams?.find((t: any) => t.id === selectedInvitedTeamId)
|
||||||
);
|
);
|
||||||
const creatorIsPersonal = $derived(selectedCreatorTeam?.isPersonal ?? true);
|
const creatorIsPersonal = $derived(selectedCreatorTeam?.isPersonal ?? true);
|
||||||
|
|
||||||
@@ -65,13 +65,12 @@
|
|||||||
|
|
||||||
const effectiveBestOf = $derived(useCustomBo ? parseInt(customBestOf) || 5 : bestOf);
|
const effectiveBestOf = $derived(useCustomBo ? parseInt(customBestOf) || 5 : bestOf);
|
||||||
|
|
||||||
// Filter opponent teams for search
|
|
||||||
const filteredOpponentTeams = $derived(
|
const filteredOpponentTeams = $derived(
|
||||||
(formData ?? data).teams.filter((t: any) => {
|
(formData ?? data).teams?.filter((t: any) => {
|
||||||
if (t.id === selectedCreatorTeamId) return false;
|
if (t.id === selectedCreatorTeamId) return false;
|
||||||
if (!opponentSearch) return true;
|
if (!opponentSearch) return true;
|
||||||
return t.name.toLowerCase().includes(opponentSearch.toLowerCase());
|
return t.name.toLowerCase().includes(opponentSearch.toLowerCase());
|
||||||
})
|
}) ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
function teamDisplayName(t: any): string {
|
function teamDisplayName(t: any): string {
|
||||||
@@ -82,7 +81,6 @@
|
|||||||
const slots = p.slots ?? [];
|
const slots = p.slots ?? [];
|
||||||
if (!slots.length) return '0 maps';
|
if (!slots.length) return '0 maps';
|
||||||
const avg = slots.reduce((s: number, sl: any) => s + (sl.starRating ?? 0), 0) / slots.length;
|
const avg = slots.reduce((s: number, sl: any) => s + (sl.starRating ?? 0), 0) / slots.length;
|
||||||
// Count per category
|
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const sl of slots) {
|
for (const sl of slots) {
|
||||||
counts[sl.category] = (counts[sl.category] ?? 0) + 1;
|
counts[sl.category] = (counts[sl.category] ?? 0) + 1;
|
||||||
@@ -113,24 +111,12 @@
|
|||||||
CANCELLED: { label: 'Cancelled', color: 'text-red-400 border-red-500/30' }
|
CANCELLED: { label: 'Cancelled', color: 'text-red-400 border-red-500/30' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const liveStates = ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'];
|
|
||||||
|
|
||||||
const liveMatches = $derived(data.matches.filter((m: any) => liveStates.includes(m.state)));
|
|
||||||
const recentMatches = $derived(data.matches.filter((m: any) => !liveStates.includes(m.state)));
|
|
||||||
|
|
||||||
function teamDisplay(m: any) {
|
function teamDisplay(m: any) {
|
||||||
const p1 = m.participants[0];
|
const p1 = m.participants[0];
|
||||||
const p2 = m.participants[1];
|
const p2 = m.participants[1];
|
||||||
return { p1, p2 };
|
return { p1, p2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function avgSR(mappool: any): string | null {
|
|
||||||
const slots = mappool?.slots;
|
|
||||||
if (!slots?.length) return null;
|
|
||||||
const avg = slots.reduce((s: number, sl: any) => s + (sl.starRating ?? 0), 0) / slots.length;
|
|
||||||
return avg.toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(date: string | Date) {
|
function timeAgo(date: string | Date) {
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
const diff = Date.now() - d.getTime();
|
const diff = Date.now() - d.getTime();
|
||||||
@@ -142,6 +128,13 @@
|
|||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'teams', label: 'Match' },
|
||||||
|
{ key: 'format', label: 'Format' },
|
||||||
|
{ key: 'state', label: 'State' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true, class: 'hidden sm:table-cell' }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -155,13 +148,13 @@
|
|||||||
|
|
||||||
<div class="mx-auto max-w-5xl">
|
<div class="mx-auto max-w-5xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="font-700 text-2xl tracking-tight">Matches</h1>
|
<h1 class="font-700 text-2xl tracking-tight">Matches</h1>
|
||||||
<p class="mt-1 text-sm text-text-secondary">
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
{data.matches.length} match{data.matches.length !== 1 ? 'es' : ''}
|
{data.meta.total} match{data.meta.total !== 1 ? 'es' : ''}
|
||||||
{#if liveMatches.length > 0}
|
{#if data.liveMatches.length > 0}
|
||||||
· <span class="text-green-400">{liveMatches.length} live</span>
|
· <span class="text-green-400">{data.liveMatches.length} live</span>
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +204,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
|
class="mb-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
|
||||||
>
|
>
|
||||||
<h2 class="font-600 text-sm">Create Match</h2>
|
<h2 class="font-600 text-sm">Create Match</h2>
|
||||||
|
|
||||||
@@ -253,7 +246,7 @@
|
|||||||
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">Select team...</option>
|
<option value="">Select team...</option>
|
||||||
{#each formData?.teams as t}
|
{#each formData?.teams ?? [] as t}
|
||||||
<option value={t.id}>{t.name}</option>
|
<option value={t.id}>{t.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -268,7 +261,7 @@
|
|||||||
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">Select team...</option>
|
<option value="">Select team...</option>
|
||||||
{#each formData?.teams as t}
|
{#each formData?.teams ?? [] as t}
|
||||||
<option value={t.id}>{t.name}</option>
|
<option value={t.id}>{t.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -283,7 +276,7 @@
|
|||||||
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">Select mappool...</option>
|
<option value="">Select mappool...</option>
|
||||||
{#each formData?.mappools as p}
|
{#each formData?.mappools ?? [] as p}
|
||||||
<option value={p.id}>{p.name} ({p.slots.length} maps)</option>
|
<option value={p.id}>{p.name} ({p.slots.length} maps)</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -327,7 +320,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
|
class="mb-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
|
||||||
>
|
>
|
||||||
<h2 class="font-600 text-sm">Challenge</h2>
|
<h2 class="font-600 text-sm">Challenge</h2>
|
||||||
|
|
||||||
@@ -343,7 +336,6 @@
|
|||||||
<input type="hidden" name="invitedTeamId" value={selectedInvitedTeamId} />
|
<input type="hidden" name="invitedTeamId" value={selectedInvitedTeamId} />
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col gap-4">
|
<div class="mt-4 flex flex-col gap-4">
|
||||||
<!-- Your Team (only shown if you have non-personal teams) -->
|
|
||||||
{#if hasMultipleTeams}
|
{#if hasMultipleTeams}
|
||||||
<div>
|
<div>
|
||||||
<label for="inv-myTeam" class="font-500 text-xs text-text-secondary">Your Team</label>
|
<label for="inv-myTeam" class="font-500 text-xs text-text-secondary">Your Team</label>
|
||||||
@@ -362,7 +354,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Opponent (searchable) -->
|
|
||||||
<div>
|
<div>
|
||||||
<label for="inv-opponent-search" class="font-500 text-xs text-text-secondary"
|
<label for="inv-opponent-search" class="font-500 text-xs text-text-secondary"
|
||||||
>Opponent</label
|
>Opponent</label
|
||||||
@@ -422,7 +413,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Best Of -->
|
|
||||||
<div>
|
<div>
|
||||||
<span class="font-500 text-xs text-text-secondary">Best Of</span>
|
<span class="font-500 text-xs text-text-secondary">Best Of</span>
|
||||||
<div class="mt-1.5 flex flex-wrap gap-1.5">
|
<div class="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
@@ -457,7 +447,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<!-- Team sizes (only if creator is not personal team) -->
|
|
||||||
{#if !creatorIsPersonal && creatorMaxSize > 1}
|
{#if !creatorIsPersonal && creatorMaxSize > 1}
|
||||||
<div>
|
<div>
|
||||||
<label for="inv-ts1" class="font-500 text-xs text-text-secondary"
|
<label for="inv-ts1" class="font-500 text-xs text-text-secondary"
|
||||||
@@ -496,7 +485,6 @@
|
|||||||
<input type="hidden" name="teamSize2" value="1" />
|
<input type="hidden" name="teamSize2" value="1" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Scoring -->
|
|
||||||
<div>
|
<div>
|
||||||
<label for="inv-scoring" class="font-500 text-xs text-text-secondary">Scoring</label>
|
<label for="inv-scoring" class="font-500 text-xs text-text-secondary">Scoring</label>
|
||||||
<select
|
<select
|
||||||
@@ -511,7 +499,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mappool -->
|
|
||||||
<div>
|
<div>
|
||||||
<label for="inv-mappool" class="font-500 text-xs text-text-secondary">Mappool</label>
|
<label for="inv-mappool" class="font-500 text-xs text-text-secondary">Mappool</label>
|
||||||
<select
|
<select
|
||||||
@@ -521,14 +508,13 @@
|
|||||||
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">Select mappool...</option>
|
<option value="">Select mappool...</option>
|
||||||
{#each formData?.mappools as p}
|
{#each formData?.mappools ?? [] as p}
|
||||||
<option value={p.id}>{p.name} — {mappoolSummary(p)}</option>
|
<option value={p.id}>{p.name} — {mappoolSummary(p)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ELO toggle (default on) -->
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<label class="flex items-center gap-2 text-xs text-text-secondary">
|
<label class="flex items-center gap-2 text-xs text-text-secondary">
|
||||||
<input
|
<input
|
||||||
@@ -541,7 +527,6 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Message + Schedule -->
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="inv-message" class="font-500 text-xs text-text-secondary"
|
<label for="inv-message" class="font-500 text-xs text-text-secondary"
|
||||||
@@ -582,17 +567,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Live Matches -->
|
<!-- Live Matches -->
|
||||||
{#if liveMatches.length > 0}
|
{#if data.liveMatches.length > 0}
|
||||||
<div class="mt-6">
|
<div class="mb-6">
|
||||||
<h2 class="font-600 flex items-center gap-2 text-sm">
|
<h2 class="font-600 flex items-center gap-2 text-sm">
|
||||||
<div class="relative h-2 w-2">
|
<div class="relative h-2 w-2">
|
||||||
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
|
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
|
||||||
<div class="relative h-2 w-2 rounded-full bg-green-400"></div>
|
<div class="relative h-2 w-2 rounded-full bg-green-400"></div>
|
||||||
</div>
|
</div>
|
||||||
Live Now (max 4 until osu! gives me bot account lol)
|
Live Now
|
||||||
</h2>
|
</h2>
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
{#each liveMatches as m}
|
{#each data.liveMatches as m}
|
||||||
{@const { p1, p2 } = teamDisplay(m)}
|
{@const { p1, p2 } = teamDisplay(m)}
|
||||||
{@const sc = stateConfig[m.state]}
|
{@const sc = stateConfig[m.state]}
|
||||||
<a
|
<a
|
||||||
@@ -634,22 +619,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- All Matches -->
|
<!-- All Matches Table -->
|
||||||
<div class="mt-6">
|
<DataTable data={data.matches} meta={data.meta} {columns} searchPlaceholder="Search matches...">
|
||||||
{#if liveMatches.length > 0}
|
{#snippet row(m, _i)}
|
||||||
<h2 class="font-600 text-sm text-text-secondary">Past Matches</h2>
|
|
||||||
{/if}
|
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
|
||||||
{#each recentMatches as m}
|
|
||||||
{@const { p1, p2 } = teamDisplay(m)}
|
{@const { p1, p2 } = teamDisplay(m)}
|
||||||
{@const config = m.config as { bestOf: number }}
|
{@const config = m.config as { bestOf: number }}
|
||||||
{@const sc = stateConfig[m.state]}
|
{@const sc = stateConfig[m.state]}
|
||||||
<a
|
<tr class="transition-colors hover:bg-surface-800/50">
|
||||||
href="/matches/{m.id}"
|
<td class="px-4 py-3">
|
||||||
class="group flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
|
<a href="/matches/{m.id}" class="block hover:text-accent">
|
||||||
>
|
|
||||||
<!-- Match info -->
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
|
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
|
||||||
{#if m.state === 'FINISHED'}
|
{#if m.state === 'FINISHED'}
|
||||||
@@ -668,45 +646,28 @@
|
|||||||
<span class="text-xs text-text-secondary">vs</span>
|
<span class="text-xs text-text-secondary">vs</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
|
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
|
||||||
</div>
|
|
||||||
<p class="mt-0.5 text-xs text-text-secondary">
|
|
||||||
{m.name ? `${m.name} · ` : ''}BO{config.bestOf}{#if m.finishedAt}
|
|
||||||
· {timeAgo(m.finishedAt)}{:else if m.createdAt}
|
|
||||||
· {timeAgo(m.createdAt)}{/if}
|
|
||||||
</p>
|
|
||||||
{#if m.mappool}
|
|
||||||
<p class="text-xs text-text-secondary">
|
|
||||||
{m.mappool.name}{#if avgSR(m.mappool)}
|
|
||||||
({avgSR(m.mappool)}★){/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Winner indicator -->
|
|
||||||
{#if m.state === 'FINISHED' && m.winnerId}
|
{#if m.state === 'FINISHED' && m.winnerId}
|
||||||
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
|
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
|
||||||
<span class="font-500 text-xs text-green-400">🏆 {winner?.team.name}</span>
|
<span class="ml-1 text-xs text-green-400">W: {winner?.team.name}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
<!-- State -->
|
{#if m.name}
|
||||||
|
<p class="mt-0.5 text-xs text-text-secondary">{m.name}</p>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="text-xs text-text-secondary">BO{config.bestOf}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
<span class="font-500 rounded border px-2 py-0.5 text-xs {sc?.color ?? 'border-border'}">
|
<span class="font-500 rounded border px-2 py-0.5 text-xs {sc?.color ?? 'border-border'}">
|
||||||
{sc?.label ?? m.state}
|
{sc?.label ?? m.state}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</td>
|
||||||
{:else}
|
<td class="hidden px-4 py-3 text-sm text-text-secondary sm:table-cell">
|
||||||
{#if liveMatches.length === 0}
|
{timeAgo(m.finishedAt ?? m.createdAt)}
|
||||||
<div class="rounded-lg border border-dashed border-border py-12 text-center">
|
</td>
|
||||||
<p class="text-sm text-text-secondary">
|
</tr>
|
||||||
No matches yet.
|
{/snippet}
|
||||||
{#if data.canCreateMatch}
|
</DataTable>
|
||||||
Create one to get started.
|
|
||||||
{:else}
|
|
||||||
Join the ranked queue from the dashboard!
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,15 +8,13 @@ import { getLobby, getLobbyStatus } from '$lib/server/bancho/client';
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { getBeatmap } from '$lib/server/osu/api';
|
import { getBeatmap } from '$lib/server/osu/api';
|
||||||
import { proxyImage } from '$lib/server/storage/r2';
|
import { proxyImage } from '$lib/server/storage/r2';
|
||||||
import { hasRole } from '$lib/server/permissions';
|
import { hasPermission, GlobalPermission } from '$lib/server/permissions';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import type { MatchConfig } from '$lib/server/match/types';
|
import type { MatchConfig } from '$lib/server/match/types';
|
||||||
import { playerRating } from '$lib/server/db/schema';
|
import { playerRating } from '$lib/server/db/schema';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
if (!locals.user) redirect(302, '/');
|
|
||||||
|
|
||||||
let m;
|
let m;
|
||||||
try {
|
try {
|
||||||
m = await getMatchFull(params.id);
|
m = await getMatchFull(params.id);
|
||||||
@@ -99,7 +97,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStaff = hasRole(locals.user.role, 'referee');
|
const isStaff = locals.user
|
||||||
|
? hasPermission(locals.user.role, GlobalPermission.MATCH_REFEREE)
|
||||||
|
: false;
|
||||||
|
|
||||||
// Fetch player usernames for lobby status display
|
// Fetch player usernames for lobby status display
|
||||||
const playerNames: Record<string, string> = {};
|
const playerNames: Record<string, string> = {};
|
||||||
@@ -127,7 +127,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
return {
|
return {
|
||||||
match: m,
|
match: m,
|
||||||
beatmapCache,
|
beatmapCache,
|
||||||
userId: locals.user.id,
|
userId: locals.user?.id ?? null,
|
||||||
isStaff,
|
isStaff,
|
||||||
lobbyStatus,
|
lobbyStatus,
|
||||||
playerNames,
|
playerNames,
|
||||||
@@ -271,7 +271,7 @@ export const actions: Actions = {
|
|||||||
forceStart: async ({ params, locals }) => {
|
forceStart: async ({ params, locals }) => {
|
||||||
if (!locals.user) redirect(302, '/');
|
if (!locals.user) redirect(302, '/');
|
||||||
// Only staff can force start
|
// Only staff can force start
|
||||||
if (!hasRole(locals.user.role, 'referee')) {
|
if (!hasPermission(locals.user.role, GlobalPermission.MATCH_REFEREE)) {
|
||||||
return { error: 'Only referees and admins can force start' };
|
return { error: 'Only referees and admins can force start' };
|
||||||
}
|
}
|
||||||
await forceStartGame(params.id);
|
await forceStartGame(params.id);
|
||||||
@@ -281,7 +281,7 @@ export const actions: Actions = {
|
|||||||
cancel: async ({ params, locals }) => {
|
cancel: async ({ params, locals }) => {
|
||||||
if (!locals.user) redirect(302, '/');
|
if (!locals.user) redirect(302, '/');
|
||||||
// Only staff can cancel matches
|
// Only staff can cancel matches
|
||||||
if (!hasRole(locals.user.role, 'referee')) {
|
if (!hasPermission(locals.user.role, GlobalPermission.MATCH_REFEREE)) {
|
||||||
return { error: 'Only referees and admins can cancel matches' };
|
return { error: 'Only referees and admins can cancel matches' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,56 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { team, teamMember, user } from '$lib/server/db/schema';
|
import { team, teamMember } from '$lib/server/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, sql, and } from 'drizzle-orm';
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions';
|
import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions';
|
||||||
|
import {
|
||||||
|
parseTableParams,
|
||||||
|
buildSearchFilter,
|
||||||
|
buildOrderBy,
|
||||||
|
buildTableMeta
|
||||||
|
} from '$lib/server/table';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
const u = requireAuth(locals);
|
const params = parseTableParams(url, { sortBy: 'createdAt', limit: 25 });
|
||||||
|
const searchFilter = buildSearchFilter(params.search, [team.name]);
|
||||||
|
// Combine search filter with non-personal filter
|
||||||
|
const baseFilter = eq(team.isPersonal, false);
|
||||||
|
const where = searchFilter ? and(baseFilter, searchFilter) : baseFilter;
|
||||||
|
|
||||||
const teams = await db.query.team.findMany({
|
const [rows, countResult] = await Promise.all([
|
||||||
with: {
|
db
|
||||||
members: true
|
.select({
|
||||||
},
|
id: team.id,
|
||||||
orderBy: (t, { desc }) => [desc(t.createdAt)]
|
name: team.name,
|
||||||
});
|
isPersonal: team.isPersonal,
|
||||||
|
ownerId: team.ownerId,
|
||||||
// Fetch member user details
|
avatarUrl: team.avatarUrl,
|
||||||
const teamsWithUsers = await Promise.all(
|
createdAt: team.createdAt,
|
||||||
teams.map(async (t) => {
|
memberCount: sql<number>`(SELECT count(*) FROM team_member WHERE team_id = ${team.id})`
|
||||||
const membersWithUsers = await Promise.all(
|
|
||||||
t.members.map(async (m) => {
|
|
||||||
const usr = await db.query.user.findFirst({
|
|
||||||
where: eq(user.id, m.userId)
|
|
||||||
});
|
|
||||||
return { ...m, user: usr ? { id: usr.id, name: usr.name, image: usr.image } : null };
|
|
||||||
})
|
})
|
||||||
);
|
.from(team)
|
||||||
return { ...t, members: membersWithUsers };
|
.where(where)
|
||||||
})
|
.orderBy(
|
||||||
);
|
buildOrderBy(
|
||||||
|
params.sortBy,
|
||||||
|
params.sortDir,
|
||||||
|
{ name: team.name, createdAt: team.createdAt },
|
||||||
|
team.createdAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(params.limit)
|
||||||
|
.offset((params.page - 1) * params.limit),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(team)
|
||||||
|
.where(where)
|
||||||
|
]);
|
||||||
|
|
||||||
return { teams: teamsWithUsers, userId: u.id };
|
return {
|
||||||
|
teams: rows,
|
||||||
|
meta: buildTableMeta(params, Number(countResult[0].count)),
|
||||||
|
userId: locals.user?.id ?? null
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
@@ -51,7 +71,6 @@ export const actions: Actions = {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Add creator as captain
|
|
||||||
await db.insert(teamMember).values({
|
await db.insert(teamMember).values({
|
||||||
teamId: created.id,
|
teamId: created.id,
|
||||||
userId: u.id,
|
userId: u.id,
|
||||||
@@ -80,6 +99,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
addMember: async ({ request, locals }) => {
|
addMember: async ({ request, locals }) => {
|
||||||
requireAuth(locals);
|
requireAuth(locals);
|
||||||
|
const { user } = await import('$lib/server/db/schema');
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const teamId = form.get('teamId')?.toString();
|
const teamId = form.get('teamId')?.toString();
|
||||||
@@ -87,19 +107,16 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
if (!teamId || !username) return { error: 'Team and username are required' };
|
if (!teamId || !username) return { error: 'Team and username are required' };
|
||||||
|
|
||||||
// Only owner or admin can add members
|
|
||||||
const t = await db.query.team.findFirst({ where: eq(team.id, teamId) });
|
const t = await db.query.team.findFirst({ where: eq(team.id, teamId) });
|
||||||
if (!t) return { error: 'Team not found' };
|
if (!t) return { error: 'Team not found' };
|
||||||
requireOwnerOrAdmin(locals, t.ownerId);
|
requireOwnerOrAdmin(locals, t.ownerId);
|
||||||
|
|
||||||
// Find user by osu! username
|
|
||||||
const u = await db.query.user.findFirst({
|
const u = await db.query.user.findFirst({
|
||||||
where: eq(user.name, username)
|
where: eq(user.name, username)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!u) return { error: `User "${username}" not found. They need to log in first.` };
|
if (!u) return { error: `User "${username}" not found. They need to log in first.` };
|
||||||
|
|
||||||
// Check not already member
|
|
||||||
const existing = await db.query.teamMember.findFirst({
|
const existing = await db.query.teamMember.findFirst({
|
||||||
where: (m, { and, eq }) => and(eq(m.teamId, teamId), eq(m.userId, u.id))
|
where: (m, { and, eq }) => and(eq(m.teamId, teamId), eq(m.userId, u.id))
|
||||||
});
|
});
|
||||||
@@ -122,7 +139,6 @@ export const actions: Actions = {
|
|||||||
const memberId = form.get('memberId')?.toString();
|
const memberId = form.get('memberId')?.toString();
|
||||||
if (!memberId) return { error: 'Missing member ID' };
|
if (!memberId) return { error: 'Missing member ID' };
|
||||||
|
|
||||||
// Look up the member to find the team, then check ownership
|
|
||||||
const member = await db.query.teamMember.findFirst({
|
const member = await db.query.teamMember.findFirst({
|
||||||
where: eq(teamMember.id, memberId),
|
where: eq(teamMember.id, memberId),
|
||||||
with: { team: true }
|
with: { team: true }
|
||||||
|
|||||||
@@ -1,54 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { invalidateAll } from '$app/navigation';
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
let expandedTeam = $state<string | null>(null);
|
|
||||||
let addingMemberTo = $state<string | null>(null);
|
|
||||||
let renamingTeam = $state<string | null>(null);
|
|
||||||
let renameValue = $state('');
|
|
||||||
let memberUsername = $state('');
|
|
||||||
let actionError = $state('');
|
|
||||||
|
|
||||||
const myTeams = $derived(
|
const columns = [
|
||||||
data.teams.filter((t: any) => t.members.some((m: any) => m.userId === data.userId))
|
{ key: 'name', label: 'Team', sortable: true },
|
||||||
);
|
{ key: 'memberCount', label: 'Members' },
|
||||||
const otherTeams = $derived(
|
{ key: 'createdAt', label: 'Created', sortable: true, class: 'hidden sm:table-cell' }
|
||||||
data.teams.filter(
|
];
|
||||||
(t: any) => !t.isPersonal && !t.members.some((m: any) => m.userId === data.userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const teamCount = $derived(
|
|
||||||
data.teams.filter(
|
|
||||||
(t: any) => !t.isPersonal || t.members.some((m: any) => m.userId === data.userId)
|
|
||||||
).length
|
|
||||||
);
|
|
||||||
|
|
||||||
function isOwner(t: any) {
|
|
||||||
return t.ownerId === data.userId;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Teams — Vash Esports</title>
|
<title>Teams — Vash Esports</title>
|
||||||
<meta name="description" content="Browse and manage teams on Vash Esports." />
|
<meta name="description" content="Browse and manage teams on Vash Esports." />
|
||||||
<meta property="og:title" content="Teams — Vash Esports" />
|
|
||||||
<meta property="og:description" content="Browse and manage teams on Vash Esports." />
|
|
||||||
<meta property="og:site_name" content="Vash Esports" />
|
|
||||||
<meta name="twitter:card" content="summary" />
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl">
|
<div class="mx-auto max-w-4xl">
|
||||||
<!-- Header -->
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="font-700 text-2xl tracking-tight">Teams</h1>
|
<h1 class="font-700 text-2xl tracking-tight">Teams</h1>
|
||||||
<p class="mt-1 text-sm text-text-secondary">
|
|
||||||
{teamCount} team{teamCount !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onclick={() => (creating = !creating)}
|
onclick={() => (creating = !creating)}
|
||||||
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
|
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
@@ -57,7 +29,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Team -->
|
|
||||||
{#if creating}
|
{#if creating}
|
||||||
<form
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
@@ -70,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
class="mt-4 flex gap-3 rounded-lg border border-border bg-surface-800 p-4"
|
class="mb-6 flex gap-3 rounded-lg border border-border bg-surface-800 p-4"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -88,256 +59,43 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if actionError}
|
<DataTable data={data.teams} meta={data.meta} {columns} searchPlaceholder="Search teams...">
|
||||||
<p class="mt-3 text-sm text-red-400">{actionError}</p>
|
{#snippet row(team, _i)}
|
||||||
{/if}
|
<tr class="transition-colors hover:bg-surface-800/50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
<!-- My Teams -->
|
<a href="/teams/{team.id}" class="flex items-center gap-3 hover:text-accent">
|
||||||
{#if myTeams.length > 0}
|
{#if team.avatarUrl}
|
||||||
<div class="mt-6">
|
<img src={team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||||
<h2 class="font-600 text-sm text-text-secondary">My Teams</h2>
|
|
||||||
<div class="mt-3 flex flex-col gap-3">
|
|
||||||
{#each myTeams as t}
|
|
||||||
<div class="rounded-lg border border-border bg-surface-800 transition-colors">
|
|
||||||
<!-- Team header -->
|
|
||||||
<button
|
|
||||||
onclick={() => (expandedTeam = expandedTeam === t.id ? null : t.id)}
|
|
||||||
class="flex w-full items-center gap-4 p-4 text-left transition-colors hover:bg-surface-700"
|
|
||||||
>
|
|
||||||
{#if t.avatarUrl}
|
|
||||||
<img src={t.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="font-700 flex h-10 w-10 items-center justify-center rounded-full bg-surface-600 text-sm text-text-secondary"
|
|
||||||
>
|
|
||||||
{t.name.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a
|
|
||||||
href="/teams/{t.id}"
|
|
||||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
|
||||||
class="font-600 text-sm hover:text-accent hover:underline">{t.name}</a
|
|
||||||
>
|
|
||||||
{#if t.isPersonal}
|
|
||||||
<span
|
|
||||||
class="font-500 rounded bg-surface-600 px-1.5 py-0.5 text-[10px] text-text-secondary"
|
|
||||||
>solo</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="mt-0.5 text-xs text-text-secondary">
|
|
||||||
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-sm text-text-secondary transition-transform {expandedTeam === t.id
|
|
||||||
? 'rotate-90'
|
|
||||||
: ''}">›</span
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Expanded: members -->
|
|
||||||
{#if expandedTeam === t.id}
|
|
||||||
<div class="border-t border-border px-4 pt-3 pb-4">
|
|
||||||
<!-- Rename team -->
|
|
||||||
{#if isOwner(t) && !t.isPersonal}
|
|
||||||
{#if renamingTeam === t.id}
|
|
||||||
<form
|
|
||||||
method="post"
|
|
||||||
action="?/rename"
|
|
||||||
use:enhance={() => {
|
|
||||||
actionError = '';
|
|
||||||
return async ({ result, update }) => {
|
|
||||||
if (result.type === 'success' && (result.data as any)?.success) {
|
|
||||||
renamingTeam = null;
|
|
||||||
await update();
|
|
||||||
} else if (result.type === 'success' && (result.data as any)?.error) {
|
|
||||||
actionError = (result.data as any).error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="mb-3 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="teamId" value={t.id} />
|
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
bind:value={renameValue}
|
|
||||||
class="flex-1 rounded-md border border-accent bg-surface-700 px-3 py-1.5 text-sm text-text-primary focus:outline-none"
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="font-600 rounded-md bg-accent px-3 py-1.5 text-xs text-surface-900 hover:bg-accent-hover"
|
|
||||||
>Save</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (renamingTeam = null)}
|
|
||||||
class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-600"
|
|
||||||
>Cancel</button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
onclick={() => {
|
|
||||||
renamingTeam = t.id;
|
|
||||||
renameValue = t.name;
|
|
||||||
}}
|
|
||||||
class="mb-3 text-xs text-text-secondary transition-colors hover:text-accent"
|
|
||||||
>
|
|
||||||
✎ Rename team
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Member list -->
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{#each t.members as member}
|
|
||||||
<div class="flex items-center gap-3 rounded-md bg-surface-700 px-3 py-2">
|
|
||||||
{#if member.user?.image}
|
|
||||||
<img src={member.user.image} alt="" class="h-6 w-6 rounded-full" />
|
|
||||||
{:else}
|
|
||||||
<div class="h-6 w-6 rounded-full bg-surface-600"></div>
|
|
||||||
{/if}
|
|
||||||
<span class="flex-1 text-sm">{member.user?.name ?? 'Unknown'}</span>
|
|
||||||
<span class="text-xs text-text-secondary">{member.role}</span>
|
|
||||||
{#if isOwner(t) && member.userId !== data.userId}
|
|
||||||
<form
|
|
||||||
method="post"
|
|
||||||
action="?/removeMember"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async ({ update }) => {
|
|
||||||
await update();
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="memberId" value={member.id} />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100"
|
|
||||||
>×</button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add member -->
|
|
||||||
{#if isOwner(t) && !t.isPersonal}
|
|
||||||
{#if addingMemberTo === t.id}
|
|
||||||
<form
|
|
||||||
method="post"
|
|
||||||
action="?/addMember"
|
|
||||||
use:enhance={() => {
|
|
||||||
actionError = '';
|
|
||||||
return async ({ result, update }) => {
|
|
||||||
if (result.type === 'success' && (result.data as any)?.success) {
|
|
||||||
memberUsername = '';
|
|
||||||
addingMemberTo = null;
|
|
||||||
await update();
|
|
||||||
} else if (result.type === 'success' && (result.data as any)?.error) {
|
|
||||||
actionError = (result.data as any).error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="mt-3 flex gap-2"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="teamId" value={t.id} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
bind:value={memberUsername}
|
|
||||||
placeholder="osu! username"
|
|
||||||
required
|
|
||||||
class="flex-1 rounded-md border border-border bg-surface-600 px-3 py-1.5 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="font-600 rounded-md bg-accent px-3 py-1.5 text-xs text-surface-900 hover:bg-accent-hover"
|
|
||||||
>Add</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (addingMemberTo = null)}
|
|
||||||
class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-600"
|
|
||||||
>Cancel</button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
onclick={() => (addingMemberTo = t.id)}
|
|
||||||
class="mt-3 w-full rounded-md border border-dashed border-border py-2 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-accent"
|
|
||||||
>
|
|
||||||
+ Add Member
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Delete team -->
|
|
||||||
{#if isOwner(t) && !t.isPersonal}
|
|
||||||
<form method="post" action="?/deleteTeam" use:enhance>
|
|
||||||
<input type="hidden" name="teamId" value={t.id} />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (!confirm(`Delete ${t.name}?`)) e.preventDefault();
|
|
||||||
}}
|
|
||||||
class="mt-3 text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100"
|
|
||||||
>
|
|
||||||
Delete team
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Other Teams -->
|
|
||||||
{#if otherTeams.length > 0}
|
|
||||||
<div class="mt-6">
|
|
||||||
<h2 class="font-600 text-sm text-text-secondary">Other Teams</h2>
|
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
|
||||||
{#each otherTeams as t}
|
|
||||||
<a
|
|
||||||
href="/teams/{t.id}"
|
|
||||||
class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
|
|
||||||
>
|
|
||||||
{#if t.avatarUrl}
|
|
||||||
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||||
>
|
>
|
||||||
{t.name.charAt(0).toUpperCase()}
|
{team.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex-1">
|
<div>
|
||||||
<span class="font-600 text-sm">{t.name}</span>
|
<span class="font-600 text-sm">{team.name}</span>
|
||||||
<p class="text-xs text-text-secondary">
|
{#if team.isPersonal}
|
||||||
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
<span
|
||||||
</p>
|
class="ml-1.5 rounded bg-surface-600 px-1.5 py-0.5 text-[10px] text-text-secondary"
|
||||||
|
>solo</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if team.ownerId === data.userId}
|
||||||
|
<span class="ml-1.5 rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent"
|
||||||
|
>yours</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
</td>
|
||||||
</div>
|
<td class="px-4 py-3 text-sm text-text-secondary">
|
||||||
</div>
|
{team.memberCount}
|
||||||
{/if}
|
</td>
|
||||||
|
<td class="hidden px-4 py-3 text-sm text-text-secondary sm:table-cell">
|
||||||
<!-- Empty state -->
|
{new Date(team.createdAt).toLocaleDateString()}
|
||||||
{#if data.teams.length === 0}
|
</td>
|
||||||
<div class="mt-6 rounded-lg border border-dashed border-border py-12 text-center">
|
</tr>
|
||||||
<p class="text-sm text-text-secondary">
|
{/snippet}
|
||||||
No teams yet. Your personal team is created when you sign in.
|
</DataTable>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { team, teamMember, match, matchParticipant } from '$lib/server/db/schema';
|
import { team, teamMember, matchParticipant, profileComment } from '$lib/server/db/schema';
|
||||||
import { user } from '$lib/server/db/auth.schema';
|
import { user } from '$lib/server/db/auth.schema';
|
||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { requireAuth } from '$lib/server/permissions';
|
import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions';
|
||||||
import type { PageServerLoad } from './$types';
|
import { uploadImage } from '$lib/server/storage/r2';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
requireAuth(locals);
|
|
||||||
|
|
||||||
const t = await db.query.team.findFirst({
|
const t = await db.query.team.findFirst({
|
||||||
where: eq(team.id, params.id),
|
where: eq(team.id, params.id),
|
||||||
with: { members: true }
|
with: { members: true }
|
||||||
@@ -49,10 +48,71 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
(m) => m.state === 'FINISHED' && m.winnerId !== params.id
|
(m) => m.state === 'FINISHED' && m.winnerId !== params.id
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
const isOwner = locals.user ? t.ownerId === locals.user.id : false;
|
||||||
|
const isAdmin = locals.user?.role === 'admin';
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
const comments = await db.query.profileComment.findMany({
|
||||||
|
where: and(eq(profileComment.targetType, 'team'), eq(profileComment.targetId, params.id)),
|
||||||
|
with: { author: true },
|
||||||
|
orderBy: desc(profileComment.createdAt)
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
team: { ...t, members: membersWithUsers },
|
team: { ...t, members: membersWithUsers },
|
||||||
recentMatches,
|
recentMatches,
|
||||||
wins,
|
wins,
|
||||||
losses
|
losses,
|
||||||
|
canManage: isOwner || isAdmin,
|
||||||
|
comments
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
comment: async ({ params, request, locals }) => {
|
||||||
|
const u = requireAuth(locals);
|
||||||
|
const form = await request.formData();
|
||||||
|
const content = form.get('content')?.toString()?.trim();
|
||||||
|
if (!content) return { error: 'Comment cannot be empty' };
|
||||||
|
if (content.length > 1000) return { error: 'Comment too long (max 1000 characters)' };
|
||||||
|
|
||||||
|
await db.insert(profileComment).values({
|
||||||
|
targetType: 'team',
|
||||||
|
targetId: params.id,
|
||||||
|
authorId: u.id,
|
||||||
|
content
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadAvatar: async ({ params, request, locals }) => {
|
||||||
|
const t = await db.query.team.findFirst({ where: eq(team.id, params.id) });
|
||||||
|
if (!t) error(404, 'Team not found');
|
||||||
|
requireOwnerOrAdmin(locals, t.ownerId);
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const file = form.get('avatar');
|
||||||
|
if (!(file instanceof File) || file.size === 0) return { error: 'No file provided' };
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
return { error: 'Invalid file type. Use JPEG, PNG, WebP, or GIF.' };
|
||||||
|
}
|
||||||
|
if (file.size > MAX_AVATAR_SIZE) {
|
||||||
|
return { error: 'File too large. Max 2 MB.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = file.type.split('/')[1].replace('jpeg', 'jpg');
|
||||||
|
const key = `teams/${params.id}/avatar.${ext}`;
|
||||||
|
const buf = await file.arrayBuffer();
|
||||||
|
const cdnUrl = await uploadImage(key, buf, file.type);
|
||||||
|
|
||||||
|
if (!cdnUrl) return { error: 'Upload failed. Try again later.' };
|
||||||
|
|
||||||
|
await db.update(team).set({ avatarUrl: cdnUrl }).where(eq(team.id, params.id));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import ProfileComments from '$lib/components/ProfileComments.svelte';
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
const t = $derived(data.team);
|
const t = $derived(data.team);
|
||||||
|
let uploading = $state(false);
|
||||||
|
let avatarError = $state('');
|
||||||
|
|
||||||
function timeAgo(date: string | Date) {
|
function timeAgo(date: string | Date) {
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
@@ -23,8 +28,9 @@
|
|||||||
<div class="mx-auto max-w-4xl">
|
<div class="mx-auto max-w-4xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="relative">
|
||||||
{#if t.avatarUrl}
|
{#if t.avatarUrl}
|
||||||
<img src={t.avatarUrl} alt="" class="h-14 w-14 rounded-full" />
|
<img src={t.avatarUrl} alt="" class="h-14 w-14 rounded-full object-cover" />
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="font-700 flex h-14 w-14 items-center justify-center rounded-full bg-surface-600 text-lg text-text-secondary"
|
class="font-700 flex h-14 w-14 items-center justify-center rounded-full bg-surface-600 text-lg text-text-secondary"
|
||||||
@@ -32,6 +38,57 @@
|
|||||||
{t.name.charAt(0).toUpperCase()}
|
{t.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if data.canManage}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/uploadAvatar"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
use:enhance={() => {
|
||||||
|
uploading = true;
|
||||||
|
avatarError = '';
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
uploading = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
await update();
|
||||||
|
} else if (result.type === 'failure' && result.data?.error) {
|
||||||
|
avatarError = result.data.error as string;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity hover:opacity-100"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="avatar"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
class="hidden"
|
||||||
|
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h1 class="font-700 text-2xl tracking-tight">{t.name}</h1>
|
<h1 class="font-700 text-2xl tracking-tight">{t.name}</h1>
|
||||||
@@ -49,13 +106,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if avatarError}
|
||||||
|
<p class="mt-2 text-sm text-red-400">{avatarError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Players -->
|
<!-- Players -->
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h2 class="font-600 text-sm text-text-secondary">Players</h2>
|
<h2 class="font-600 text-sm text-text-secondary">Players</h2>
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
{#each t.members as member}
|
{#each t.members as member}
|
||||||
<div
|
<a
|
||||||
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3"
|
href="/users/{member.user?.id}"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
|
||||||
>
|
>
|
||||||
{#if member.user?.image}
|
{#if member.user?.image}
|
||||||
<img src={member.user.image} alt="" class="h-8 w-8 rounded-full" />
|
<img src={member.user.image} alt="" class="h-8 w-8 rounded-full" />
|
||||||
@@ -64,7 +126,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class="font-600 flex-1 text-sm">{member.user?.name ?? 'Unknown'}</span>
|
<span class="font-600 flex-1 text-sm">{member.user?.name ?? 'Unknown'}</span>
|
||||||
<span class="text-xs text-text-secondary">{member.role}</span>
|
<span class="text-xs text-text-secondary">{member.role}</span>
|
||||||
</div>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,4 +170,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ProfileComments comments={data.comments} actionUrl="?/comment" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
60
src/routes/(app)/tournaments/+page.server.ts
Normal file
60
src/routes/(app)/tournaments/+page.server.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { tournament } from '$lib/server/db/schema';
|
||||||
|
import { sql, and, inArray } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
parseTableParams,
|
||||||
|
buildSearchFilter,
|
||||||
|
buildOrderBy,
|
||||||
|
buildTableMeta
|
||||||
|
} from '$lib/server/table';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
|
const params = parseTableParams(url, { sortBy: 'createdAt', limit: 25 });
|
||||||
|
const searchFilter = buildSearchFilter(params.search, [tournament.name]);
|
||||||
|
|
||||||
|
// Build filter condition based on ?filter= param
|
||||||
|
const filterParam = params.filter;
|
||||||
|
let stateFilter;
|
||||||
|
if (filterParam === 'upcoming') {
|
||||||
|
stateFilter = inArray(tournament.state, ['DRAFT', 'REGISTRATION', 'QUALIFIERS', 'SEEDING']);
|
||||||
|
} else if (filterParam === 'ongoing') {
|
||||||
|
stateFilter = sql`${tournament.state} = 'BRACKET'`;
|
||||||
|
} else if (filterParam === 'finished') {
|
||||||
|
stateFilter = sql`${tournament.state} = 'FINISHED'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = stateFilter
|
||||||
|
? searchFilter
|
||||||
|
? and(searchFilter, stateFilter)
|
||||||
|
: stateFilter
|
||||||
|
: searchFilter;
|
||||||
|
|
||||||
|
const [rows, countResult] = await Promise.all([
|
||||||
|
db.query.tournament.findMany({
|
||||||
|
with: {
|
||||||
|
registrations: {
|
||||||
|
columns: { id: true, status: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
where,
|
||||||
|
orderBy: buildOrderBy(
|
||||||
|
params.sortBy,
|
||||||
|
params.sortDir,
|
||||||
|
{ name: tournament.name, createdAt: tournament.createdAt },
|
||||||
|
tournament.createdAt
|
||||||
|
),
|
||||||
|
limit: params.limit,
|
||||||
|
offset: (params.page - 1) * params.limit
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tournament)
|
||||||
|
.where(where)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournaments: rows,
|
||||||
|
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||||
|
};
|
||||||
|
};
|
||||||
120
src/routes/(app)/tournaments/+page.svelte
Normal file
120
src/routes/(app)/tournaments/+page.svelte
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
const formatLabels: Record<string, string> = {
|
||||||
|
single_elim: 'Single Elimination',
|
||||||
|
double_elim: 'Double Elimination',
|
||||||
|
groups_bracket: 'Groups + Bracket'
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateColors: Record<string, string> = {
|
||||||
|
DRAFT: 'bg-surface-600 text-text-secondary',
|
||||||
|
REGISTRATION: 'bg-green-900 text-green-300',
|
||||||
|
QUALIFIERS: 'bg-yellow-900 text-yellow-300',
|
||||||
|
SEEDING: 'bg-yellow-900 text-yellow-300',
|
||||||
|
BRACKET: 'bg-blue-900 text-blue-300',
|
||||||
|
FINISHED: 'bg-surface-700 text-text-secondary',
|
||||||
|
CANCELLED: 'bg-red-900 text-red-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentFilter = $derived(new URL($page.url).searchParams.get('filter') ?? 'all');
|
||||||
|
|
||||||
|
function setFilter(value: string) {
|
||||||
|
const url = new URL($page.url);
|
||||||
|
if (value === 'all') {
|
||||||
|
url.searchParams.delete('filter');
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('filter', value);
|
||||||
|
}
|
||||||
|
url.searchParams.set('page', '1');
|
||||||
|
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: 'Tournament', sortable: true },
|
||||||
|
{ key: 'format', label: 'Format' },
|
||||||
|
{ key: 'state', label: 'State' },
|
||||||
|
{ key: 'players', label: 'Players' },
|
||||||
|
{ key: 'createdAt', label: 'Created', sortable: true, class: 'hidden sm:table-cell' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Tournaments — Vash Esports</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-5xl">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-700 text-2xl tracking-tight">Tournaments</h1>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
{data.meta.total} tournament{data.meta.total !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/tournaments/new"
|
||||||
|
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Create Tournament
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="mb-4 flex gap-1.5">
|
||||||
|
{#each [['all', 'All'], ['upcoming', 'Upcoming'], ['ongoing', 'Ongoing'], ['finished', 'Finished']] as [value, label]}
|
||||||
|
<button
|
||||||
|
class="font-600 rounded-md px-3 py-1.5 text-xs transition-colors {currentFilter === value
|
||||||
|
? 'bg-accent text-surface-900'
|
||||||
|
: 'border border-border bg-surface-700 text-text-secondary hover:border-accent/40 hover:text-text-primary'}"
|
||||||
|
onclick={() => setFilter(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
data={data.tournaments}
|
||||||
|
meta={data.meta}
|
||||||
|
{columns}
|
||||||
|
searchPlaceholder="Search tournaments..."
|
||||||
|
>
|
||||||
|
{#snippet row(t, _i)}
|
||||||
|
{@const activeRegs = t.registrations.filter(
|
||||||
|
(r: any) => r.status !== 'withdrawn' && r.status !== 'eliminated'
|
||||||
|
).length}
|
||||||
|
<tr
|
||||||
|
onclick={() => goto(`/tournaments/${t.id}`)}
|
||||||
|
class="cursor-pointer transition-colors hover:bg-surface-800/50"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="font-600 text-sm">{t.name}</span>
|
||||||
|
{#if t.description}
|
||||||
|
<p class="mt-0.5 truncate text-xs text-text-secondary">{t.description}</p>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-text-secondary">
|
||||||
|
{formatLabels[t.format] ?? t.format}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="font-500 rounded px-2 py-0.5 text-xs {stateColors[t.state] ??
|
||||||
|
'bg-surface-600 text-text-secondary'}"
|
||||||
|
>
|
||||||
|
{t.state}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-text-secondary">
|
||||||
|
{activeRegs}/{t.maxSlots}
|
||||||
|
</td>
|
||||||
|
<td class="hidden px-4 py-3 text-sm text-text-secondary sm:table-cell">
|
||||||
|
{new Date(t.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/snippet}
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
290
src/routes/(app)/tournaments/[id]/+page.server.ts
Normal file
290
src/routes/(app)/tournaments/[id]/+page.server.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import {
|
||||||
|
tournament,
|
||||||
|
tournamentRound,
|
||||||
|
tournamentMatch,
|
||||||
|
tournamentRegistration,
|
||||||
|
tournamentGroup,
|
||||||
|
tournamentGroupEntry,
|
||||||
|
tournamentStaff,
|
||||||
|
teamMember,
|
||||||
|
mappool
|
||||||
|
} from '$lib/server/db/schema';
|
||||||
|
import { eq, and, asc, desc } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '$lib/server/permissions';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
const t = await db.query.tournament.findFirst({
|
||||||
|
where: eq(tournament.id, params.id),
|
||||||
|
with: {
|
||||||
|
rounds: {
|
||||||
|
orderBy: [asc(tournamentRound.roundOrder)],
|
||||||
|
with: { mappool: { with: { slots: true } } }
|
||||||
|
},
|
||||||
|
registrations: {
|
||||||
|
with: { team: true },
|
||||||
|
orderBy: [asc(tournamentRegistration.seed), asc(tournamentRegistration.registeredAt)]
|
||||||
|
},
|
||||||
|
staff: true,
|
||||||
|
groups: {
|
||||||
|
with: {
|
||||||
|
entries: { with: { team: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!t) error(404, 'Tournament not found');
|
||||||
|
|
||||||
|
const matches = await db.query.tournamentMatch.findMany({
|
||||||
|
where: eq(tournamentMatch.tournamentId, params.id),
|
||||||
|
with: {
|
||||||
|
round: true,
|
||||||
|
team1: true,
|
||||||
|
team2: true,
|
||||||
|
winner: true,
|
||||||
|
match: {
|
||||||
|
with: {
|
||||||
|
participants: {
|
||||||
|
with: { team: true },
|
||||||
|
orderBy: (p: any, { asc }: any) => [asc(p.slot)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// check if current user is staff
|
||||||
|
const isStaff = locals.user ? t.staff.some((s) => s.userId === locals.user!.id) : false;
|
||||||
|
|
||||||
|
// check if current user is registered
|
||||||
|
const userTeams = locals.user
|
||||||
|
? await db.query.teamMember.findMany({
|
||||||
|
where: eq(teamMember.userId, locals.user.id),
|
||||||
|
with: { team: true }
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const userRegistration = locals.user
|
||||||
|
? t.registrations.find((r) => userTeams.some((ut) => ut.teamId === r.teamId))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// load mappools for round config (staff only in DRAFT)
|
||||||
|
let mappools: { id: string; name: string; slotCount: number }[] = [];
|
||||||
|
if (isStaff) {
|
||||||
|
const allMappools = await db.query.mappool.findMany({
|
||||||
|
with: { slots: { columns: { id: true } } },
|
||||||
|
orderBy: desc(mappool.createdAt)
|
||||||
|
});
|
||||||
|
mappools = allMappools.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
slotCount: m.slots.length
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournament: t,
|
||||||
|
matches,
|
||||||
|
isStaff,
|
||||||
|
userTeams: userTeams.map((ut) => ut.team),
|
||||||
|
userRegistration,
|
||||||
|
mappools
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
register: async ({ params, request, locals }) => {
|
||||||
|
const user = requireAuth(locals);
|
||||||
|
const form = await request.formData();
|
||||||
|
const teamId = form.get('teamId')?.toString();
|
||||||
|
|
||||||
|
if (!teamId) return { error: 'Select a team' };
|
||||||
|
|
||||||
|
const { registerForTournament } = await import('$lib/server/tournament/lifecycle');
|
||||||
|
try {
|
||||||
|
await registerForTournament(params.id, teamId, user.id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
withdraw: async ({ params, request, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
const form = await request.formData();
|
||||||
|
const teamId = form.get('teamId')?.toString();
|
||||||
|
|
||||||
|
if (!teamId) return { error: 'Missing teamId' };
|
||||||
|
|
||||||
|
const { withdrawFromTournament } = await import('$lib/server/tournament/lifecycle');
|
||||||
|
try {
|
||||||
|
await withdrawFromTournament(params.id, teamId);
|
||||||
|
return { withdrawn: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openRegistration: async ({ params, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
const { openRegistration } = await import('$lib/server/tournament/lifecycle');
|
||||||
|
try {
|
||||||
|
await openRegistration(params.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeRegistration: async ({ params, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
const { closeRegistration } = await import('$lib/server/tournament/lifecycle');
|
||||||
|
try {
|
||||||
|
await closeRegistration(params.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
finalizeSeeding: async ({ params, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
const { finalizeSeeding } = await import('$lib/server/tournament/lifecycle');
|
||||||
|
try {
|
||||||
|
await finalizeSeeding(params.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: async ({ params, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
const { cancelTournament } = await import('$lib/server/tournament/lifecycle');
|
||||||
|
try {
|
||||||
|
await cancelTournament(params.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addRound: async ({ params, request, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
const form = await request.formData();
|
||||||
|
|
||||||
|
const name = form.get('name')?.toString()?.trim();
|
||||||
|
const abbreviation = form.get('abbreviation')?.toString()?.trim() || null;
|
||||||
|
const bestOf = parseInt(form.get('bestOf')?.toString() ?? '5');
|
||||||
|
const mappoolId = form.get('mappoolId')?.toString() || null;
|
||||||
|
const scheduledAt = form.get('scheduledAt')?.toString();
|
||||||
|
|
||||||
|
if (!name) return { error: 'Round name is required' };
|
||||||
|
if (bestOf < 1 || bestOf % 2 === 0) return { error: 'Best of must be an odd number' };
|
||||||
|
|
||||||
|
// get current max round order
|
||||||
|
const existing = await db.query.tournamentRound.findMany({
|
||||||
|
where: eq(tournamentRound.tournamentId, params.id),
|
||||||
|
orderBy: [desc(tournamentRound.roundOrder)]
|
||||||
|
});
|
||||||
|
const nextOrder = existing.length > 0 ? existing[0].roundOrder + 1 : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(tournamentRound).values({
|
||||||
|
tournamentId: params.id,
|
||||||
|
name,
|
||||||
|
abbreviation,
|
||||||
|
roundOrder: nextOrder,
|
||||||
|
bestOf,
|
||||||
|
mappoolId: mappoolId || null,
|
||||||
|
scheduledAt: scheduledAt ? new Date(scheduledAt) : null
|
||||||
|
});
|
||||||
|
return { roundAdded: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRound: async ({ params, request, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
const form = await request.formData();
|
||||||
|
|
||||||
|
const roundId = form.get('roundId')?.toString();
|
||||||
|
const name = form.get('name')?.toString()?.trim();
|
||||||
|
const abbreviation = form.get('abbreviation')?.toString()?.trim() || null;
|
||||||
|
const bestOf = parseInt(form.get('bestOf')?.toString() ?? '5');
|
||||||
|
const mappoolId = form.get('mappoolId')?.toString() || null;
|
||||||
|
const scheduledAt = form.get('scheduledAt')?.toString();
|
||||||
|
|
||||||
|
if (!roundId) return { error: 'Missing roundId' };
|
||||||
|
if (!name) return { error: 'Round name is required' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(tournamentRound)
|
||||||
|
.set({
|
||||||
|
name,
|
||||||
|
abbreviation,
|
||||||
|
bestOf,
|
||||||
|
mappoolId: mappoolId || null,
|
||||||
|
scheduledAt: scheduledAt ? new Date(scheduledAt) : null
|
||||||
|
})
|
||||||
|
.where(and(eq(tournamentRound.id, roundId), eq(tournamentRound.tournamentId, params.id)));
|
||||||
|
return { roundUpdated: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRound: async ({ params, request, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
const form = await request.formData();
|
||||||
|
const roundId = form.get('roundId')?.toString();
|
||||||
|
|
||||||
|
if (!roundId) return { error: 'Missing roundId' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(tournamentRound)
|
||||||
|
.where(and(eq(tournamentRound.id, roundId), eq(tournamentRound.tournamentId, params.id)));
|
||||||
|
return { roundDeleted: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addStaff: async ({ params, request, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
const form = await request.formData();
|
||||||
|
const userId = form.get('userId')?.toString();
|
||||||
|
const role = form.get('role')?.toString() ?? 'referee';
|
||||||
|
|
||||||
|
if (!userId) return { error: 'User is required' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(tournamentStaff).values({
|
||||||
|
tournamentId: params.id,
|
||||||
|
userId,
|
||||||
|
role
|
||||||
|
});
|
||||||
|
return { staffAdded: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeStaff: async ({ params, request, locals }) => {
|
||||||
|
requireAuth(locals);
|
||||||
|
const form = await request.formData();
|
||||||
|
const staffId = form.get('staffId')?.toString();
|
||||||
|
|
||||||
|
if (!staffId) return { error: 'Missing staffId' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(tournamentStaff)
|
||||||
|
.where(and(eq(tournamentStaff.id, staffId), eq(tournamentStaff.tournamentId, params.id)));
|
||||||
|
return { staffRemoved: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
903
src/routes/(app)/tournaments/[id]/+page.svelte
Normal file
903
src/routes/(app)/tournaments/[id]/+page.svelte
Normal file
@@ -0,0 +1,903 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
const t = $derived(data.tournament);
|
||||||
|
const matches = $derived(data.matches);
|
||||||
|
|
||||||
|
let activeTab = $state('overview');
|
||||||
|
let actionError = $state('');
|
||||||
|
let actionSuccess = $state('');
|
||||||
|
|
||||||
|
// round form state
|
||||||
|
let showAddRound = $state(false);
|
||||||
|
let editingRoundId = $state<string | null>(null);
|
||||||
|
|
||||||
|
const formatLabels: Record<string, string> = {
|
||||||
|
single_elim: 'Single Elimination',
|
||||||
|
double_elim: 'Double Elimination',
|
||||||
|
groups_bracket: 'Groups + Bracket'
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateColors: Record<string, string> = {
|
||||||
|
DRAFT: 'bg-surface-600 text-text-secondary',
|
||||||
|
REGISTRATION: 'bg-green-900 text-green-300',
|
||||||
|
QUALIFIERS: 'bg-yellow-900 text-yellow-300',
|
||||||
|
SEEDING: 'bg-yellow-900 text-yellow-300',
|
||||||
|
BRACKET: 'bg-blue-900 text-blue-300',
|
||||||
|
FINISHED: 'bg-surface-700 text-text-secondary',
|
||||||
|
CANCELLED: 'bg-red-900 text-red-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeRegs = $derived(
|
||||||
|
t.registrations.filter((r: any) => r.status !== 'withdrawn' && r.status !== 'eliminated')
|
||||||
|
);
|
||||||
|
|
||||||
|
// invite link
|
||||||
|
const inviteUrl = $derived(
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? `${window.location.origin}/tournaments/${t.id}`
|
||||||
|
: `/tournaments/${t.id}`
|
||||||
|
);
|
||||||
|
let copied = $state(false);
|
||||||
|
function copyInvite() {
|
||||||
|
navigator.clipboard.writeText(inviteUrl);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// bracket helpers
|
||||||
|
const wbMatches = $derived(
|
||||||
|
matches.filter((m: any) => m.round?.bracketType === 'winners' || !m.round?.bracketType)
|
||||||
|
);
|
||||||
|
const lbMatches = $derived(matches.filter((m: any) => m.round?.bracketType === 'losers'));
|
||||||
|
const gfMatches = $derived(matches.filter((m: any) => m.round?.bracketType === 'grand_final'));
|
||||||
|
|
||||||
|
function groupByRound(matchList: any[]) {
|
||||||
|
const rounds = new SvelteMap<number, { round: any; matches: any[] }>();
|
||||||
|
for (const m of matchList) {
|
||||||
|
const order = m.round?.roundOrder ?? 0;
|
||||||
|
if (!rounds.has(order)) {
|
||||||
|
rounds.set(order, { round: m.round, matches: [] });
|
||||||
|
}
|
||||||
|
rounds.get(order)!.matches.push(m);
|
||||||
|
}
|
||||||
|
return Array.from(rounds.values()).sort((a, b) => a.round.roundOrder - b.round.roundOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wbRounds = $derived(groupByRound(wbMatches));
|
||||||
|
const lbRounds = $derived(groupByRound(lbMatches));
|
||||||
|
|
||||||
|
function handleAction() {
|
||||||
|
return ({ result }: any) => {
|
||||||
|
if (result.type === 'failure' || (result.type === 'success' && result.data?.error)) {
|
||||||
|
actionError = result.data?.error ?? 'Something went wrong';
|
||||||
|
actionSuccess = '';
|
||||||
|
} else {
|
||||||
|
actionError = '';
|
||||||
|
invalidateAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionWithMessage(msg: string) {
|
||||||
|
return ({ result }: any) => {
|
||||||
|
if (result.type === 'failure' || (result.type === 'success' && result.data?.error)) {
|
||||||
|
actionError = result.data?.error ?? 'Something went wrong';
|
||||||
|
actionSuccess = '';
|
||||||
|
} else {
|
||||||
|
actionError = '';
|
||||||
|
actionSuccess = msg;
|
||||||
|
showAddRound = false;
|
||||||
|
editingRoundId = null;
|
||||||
|
invalidateAll();
|
||||||
|
setTimeout(() => (actionSuccess = ''), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// which tabs to show
|
||||||
|
const tabs = $derived(() => {
|
||||||
|
const list: [string, string][] = [['overview', 'Overview']];
|
||||||
|
if (matches.length > 0) list.push(['bracket', 'Bracket']);
|
||||||
|
list.push(['registrations', 'Registrations']);
|
||||||
|
list.push(['rounds', 'Rounds']);
|
||||||
|
if (t.format === 'groups_bracket' && t.groups.length > 0) list.push(['groups', 'Groups']);
|
||||||
|
if (data.isStaff) list.push(['settings', 'Settings']);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{t.name} — Vash Esports</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-5xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h1 class="font-700 text-2xl tracking-tight">{t.name}</h1>
|
||||||
|
<span
|
||||||
|
class="font-500 rounded px-2 py-0.5 text-xs {stateColors[t.state] ??
|
||||||
|
'bg-surface-600 text-text-secondary'}"
|
||||||
|
>
|
||||||
|
{t.state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if t.description}
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">{t.description}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
|
{formatLabels[t.format] ?? t.format}
|
||||||
|
· {activeRegs.length}/{t.maxSlots} players
|
||||||
|
{#if t.startAt}
|
||||||
|
· starts {new Date(t.startAt).toLocaleDateString()}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={copyInvite}
|
||||||
|
class="font-600 rounded-md border border-border px-3 py-2 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Errors / Success -->
|
||||||
|
{#if actionError}
|
||||||
|
<div class="mt-4 rounded-md border border-red-800 bg-red-900/30 px-4 py-3 text-sm text-red-300">
|
||||||
|
{actionError}
|
||||||
|
<button onclick={() => (actionError = '')} class="ml-2 text-red-400 hover:text-red-300"
|
||||||
|
>×</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if actionSuccess}
|
||||||
|
<div
|
||||||
|
class="mt-4 rounded-md border border-green-800 bg-green-900/30 px-4 py-3 text-sm text-green-300"
|
||||||
|
>
|
||||||
|
{actionSuccess}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Registration banner for players -->
|
||||||
|
{#if t.state === 'REGISTRATION' && !data.isStaff}
|
||||||
|
<div class="mt-4 rounded-lg border border-border bg-surface-800 p-4">
|
||||||
|
{#if data.userRegistration}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-green-400"
|
||||||
|
>You are registered with {data.userRegistration.team?.name ?? 'your team'}</span
|
||||||
|
>
|
||||||
|
<form method="POST" action="?/withdraw" use:enhance={() => handleAction()}>
|
||||||
|
<input type="hidden" name="teamId" value={data.userRegistration.teamId} />
|
||||||
|
<button
|
||||||
|
class="font-600 rounded-md border border-red-800 px-3 py-1.5 text-xs text-red-300 hover:bg-red-900/30"
|
||||||
|
>
|
||||||
|
Withdraw
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{:else if data.userTeams.length > 0}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/register"
|
||||||
|
use:enhance={() => handleAction()}
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span class="font-500 text-xs text-text-secondary">Register as:</span>
|
||||||
|
<select
|
||||||
|
name="teamId"
|
||||||
|
required
|
||||||
|
class="rounded-md border border-border bg-surface-700 px-3 py-1.5 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
{#each data.userTeams as team}
|
||||||
|
<option value={team.id}>{team.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="font-600 rounded-md bg-accent px-4 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-text-secondary">You need a team to register for this tournament</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="mt-6 flex border-b border-border">
|
||||||
|
{#each tabs() as [value, label]}
|
||||||
|
<button
|
||||||
|
class="font-500 border-b-2 px-4 py-2 text-sm transition-colors {activeTab === value
|
||||||
|
? 'border-accent text-text-primary'
|
||||||
|
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||||
|
onclick={() => (activeTab = value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="mt-4">
|
||||||
|
{#if activeTab === 'overview'}
|
||||||
|
{@render overviewTab()}
|
||||||
|
{:else if activeTab === 'bracket'}
|
||||||
|
{@render bracketTab()}
|
||||||
|
{:else if activeTab === 'registrations'}
|
||||||
|
{@render registrationsTab()}
|
||||||
|
{:else if activeTab === 'rounds'}
|
||||||
|
{@render roundsTab()}
|
||||||
|
{:else if activeTab === 'groups'}
|
||||||
|
{@render groupsTab()}
|
||||||
|
{:else if activeTab === 'settings'}
|
||||||
|
{@render settingsTab()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Overview Tab ─────────────────────────────────────────────────── -->
|
||||||
|
{#snippet overviewTab()}
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<!-- Tournament Info -->
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-5">
|
||||||
|
<h3 class="font-600 text-sm">Details</h3>
|
||||||
|
<dl class="mt-3 flex flex-col gap-2 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-text-secondary">Format</dt>
|
||||||
|
<dd class="font-500">{formatLabels[t.format] ?? t.format}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-text-secondary">Players</dt>
|
||||||
|
<dd class="font-500">{activeRegs.length} / {t.maxSlots}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-text-secondary">Team Size</dt>
|
||||||
|
<dd class="font-500">
|
||||||
|
{(t.config as any)?.teamSize ?? 1}v{(t.config as any)?.teamSize ?? 1}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-text-secondary">Scoring</dt>
|
||||||
|
<dd class="font-500">{(t.config as any)?.scoringType ?? 'score_v2'}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-text-secondary">Rounds</dt>
|
||||||
|
<dd class="font-500">{t.rounds.length}</dd>
|
||||||
|
</div>
|
||||||
|
{#if t.startAt}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-text-secondary">Start Date</dt>
|
||||||
|
<dd class="font-500">{new Date(t.startAt).toLocaleString()}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Staff -->
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-5">
|
||||||
|
<h3 class="font-600 text-sm">Staff</h3>
|
||||||
|
{#if t.staff.length === 0}
|
||||||
|
<p class="mt-3 text-sm text-text-secondary">No staff assigned</p>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-3 flex flex-col gap-1.5">
|
||||||
|
{#each t.staff as s}
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="font-500">{s.userId.slice(0, 8)}...</span>
|
||||||
|
<span class="rounded bg-surface-600 px-2 py-0.5 text-xs text-text-secondary"
|
||||||
|
>{s.role}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Share / Invite -->
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-5 md:col-span-2">
|
||||||
|
<h3 class="font-600 text-sm">Invite Players</h3>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
|
Share this link with players to let them register
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
value={inviteUrl}
|
||||||
|
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={copyInvite}
|
||||||
|
class="font-600 rounded-md bg-accent px-4 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Rounds Preview -->
|
||||||
|
{#if t.rounds.length > 0}
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-5 md:col-span-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="font-600 text-sm">Rounds</h3>
|
||||||
|
<button
|
||||||
|
onclick={() => (activeTab = 'rounds')}
|
||||||
|
class="text-xs text-accent hover:text-accent-hover">View all</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
{#each t.rounds as round}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 rounded-md border border-border bg-surface-700 px-3 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<span class="font-600">{round.abbreviation ?? round.name}</span>
|
||||||
|
<span class="text-text-secondary">BO{round.bestOf}</span>
|
||||||
|
{#if round.mappool}
|
||||||
|
<span class="text-text-secondary">· {round.mappool.name}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-yellow-400">no pool</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- ─── Bracket Tab ──────────────────────────────────────────────────── -->
|
||||||
|
{#snippet bracketTab()}
|
||||||
|
{#if matches.length === 0}
|
||||||
|
<div class="rounded-lg border border-dashed border-border py-12 text-center">
|
||||||
|
<p class="text-sm text-text-secondary">Bracket has not been generated yet</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Winners Bracket -->
|
||||||
|
{#if wbRounds.length > 0}
|
||||||
|
{#if t.format === 'double_elim'}
|
||||||
|
<h3 class="font-600 mb-3 text-xs text-text-secondary uppercase">Winners Bracket</h3>
|
||||||
|
{/if}
|
||||||
|
<div class="mb-8 flex gap-6 overflow-x-auto pb-4">
|
||||||
|
{#each wbRounds as { round, matches: roundMatches }}
|
||||||
|
<div class="shrink-0">
|
||||||
|
<div class="mb-2 text-center text-xs text-text-secondary">
|
||||||
|
{round?.name ?? 'Round'}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-around gap-3"
|
||||||
|
style="min-height: {roundMatches.length * 80}px"
|
||||||
|
>
|
||||||
|
{#each roundMatches.sort((a: any, b: any) => a.bracketPosition - b.bracketPosition) as m}
|
||||||
|
{@render matchCard(m)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Losers Bracket -->
|
||||||
|
{#if lbRounds.length > 0}
|
||||||
|
<h3 class="font-600 mb-3 text-xs text-text-secondary uppercase">Losers Bracket</h3>
|
||||||
|
<div class="mb-8 flex gap-6 overflow-x-auto pb-4">
|
||||||
|
{#each lbRounds as { round, matches: roundMatches }}
|
||||||
|
<div class="shrink-0">
|
||||||
|
<div class="mb-2 text-center text-xs text-text-secondary">
|
||||||
|
{round?.name ?? 'Round'}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-around gap-3">
|
||||||
|
{#each roundMatches.sort((a: any, b: any) => a.bracketPosition - b.bracketPosition) as m}
|
||||||
|
{@render matchCard(m)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Grand Finals -->
|
||||||
|
{#if gfMatches.length > 0}
|
||||||
|
<h3 class="font-600 mb-3 text-xs text-text-secondary uppercase">Grand Finals</h3>
|
||||||
|
<div class="mb-8 flex gap-6">
|
||||||
|
{#each gfMatches.sort((a: any, b: any) => (a.round?.roundOrder ?? 0) - (b.round?.roundOrder ?? 0)) as m}
|
||||||
|
{@render matchCard(m)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- ─── Registrations Tab ────────────────────────────────────────────── -->
|
||||||
|
{#snippet registrationsTab()}
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800">
|
||||||
|
{#if t.registrations.length === 0}
|
||||||
|
<div class="py-8 text-center text-sm text-text-secondary">No registrations yet</div>
|
||||||
|
{:else}
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-border text-left">
|
||||||
|
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Seed</th>
|
||||||
|
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Team</th>
|
||||||
|
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Status</th>
|
||||||
|
<th class="font-500 px-4 py-3 text-xs text-text-secondary">Registered</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each t.registrations as reg}
|
||||||
|
<tr class="border-b border-border/50 hover:bg-surface-700">
|
||||||
|
<td class="px-4 py-3 text-text-secondary">{reg.seed ?? '-'}</td>
|
||||||
|
<td class="font-500 px-4 py-3">{reg.team?.name ?? 'Unknown'}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="font-500 rounded px-2 py-0.5 text-xs {reg.status === 'registered' ||
|
||||||
|
reg.status === 'confirmed'
|
||||||
|
? 'bg-green-900 text-green-300'
|
||||||
|
: reg.status === 'withdrawn'
|
||||||
|
? 'bg-red-900 text-red-300'
|
||||||
|
: 'bg-surface-600 text-text-secondary'}"
|
||||||
|
>
|
||||||
|
{reg.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-text-secondary">
|
||||||
|
{new Date(reg.registeredAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- ─── Rounds Tab ───────────────────────────────────────────────────── -->
|
||||||
|
{#snippet roundsTab()}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#if t.rounds.length === 0 && !showAddRound}
|
||||||
|
<div class="rounded-lg border border-dashed border-border py-8 text-center">
|
||||||
|
<p class="text-sm text-text-secondary">No rounds configured yet</p>
|
||||||
|
{#if data.isStaff && t.state === 'DRAFT'}
|
||||||
|
<button
|
||||||
|
onclick={() => (showAddRound = true)}
|
||||||
|
class="font-600 mt-3 rounded-md bg-accent px-4 py-2 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Add First Round
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each t.rounds as round}
|
||||||
|
{#if editingRoundId === round.id && data.isStaff}
|
||||||
|
<!-- Inline edit form -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateRound"
|
||||||
|
use:enhance={() => handleActionWithMessage('Round updated')}
|
||||||
|
class="rounded-lg border border-accent/20 bg-surface-800 p-4"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="roundId" value={round.id} />
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<span class="font-500 text-xs text-text-secondary">Name</span>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={round.name}
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-500 text-xs text-text-secondary">Abbreviation</span>
|
||||||
|
<input
|
||||||
|
name="abbreviation"
|
||||||
|
type="text"
|
||||||
|
value={round.abbreviation ?? ''}
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-500 text-xs text-text-secondary">Best Of</span>
|
||||||
|
<select
|
||||||
|
name="bestOf"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
{#each [3, 5, 7, 9, 11, 13] as n}
|
||||||
|
<option value={n} selected={n === round.bestOf}>BO{n}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-500 text-xs text-text-secondary">Mappool</span>
|
||||||
|
<select
|
||||||
|
name="mappoolId"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{#each data.mappools as mp}
|
||||||
|
<option value={mp.id} selected={mp.id === round.mappoolId}
|
||||||
|
>{mp.name} ({mp.slotCount} maps)</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<span class="font-500 text-xs text-text-secondary">Scheduled At</span>
|
||||||
|
<input
|
||||||
|
name="scheduledAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={round.scheduledAt
|
||||||
|
? new Date(round.scheduledAt).toISOString().slice(0, 16)
|
||||||
|
: ''}
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editingRoundId = null)}
|
||||||
|
class="font-600 rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="font-600 rounded-md bg-accent px-4 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<!-- Round card -->
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-600 text-sm">{round.name}</span>
|
||||||
|
{#if round.abbreviation}
|
||||||
|
<span class="rounded bg-surface-600 px-2 py-0.5 text-xs text-text-secondary"
|
||||||
|
>{round.abbreviation}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span class="text-xs text-text-secondary">BO{round.bestOf}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if round.mappool}
|
||||||
|
<span class="text-xs text-text-secondary"
|
||||||
|
>{round.mappool.name} ({round.mappool.slots.length} maps)</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-yellow-400">no mappool</span>
|
||||||
|
{/if}
|
||||||
|
{#if round.scheduledAt}
|
||||||
|
<span class="text-xs text-text-secondary"
|
||||||
|
>{new Date(round.scheduledAt).toLocaleString()}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if data.isStaff && t.state === 'DRAFT'}
|
||||||
|
<button
|
||||||
|
onclick={() => (editingRoundId = round.id)}
|
||||||
|
class="text-xs text-accent hover:text-accent-hover"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteRound"
|
||||||
|
use:enhance={() => handleActionWithMessage('Round deleted')}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="roundId" value={round.id} />
|
||||||
|
<button class="text-xs text-red-400 hover:text-red-300">Delete</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add Round Form -->
|
||||||
|
{#if data.isStaff && t.state === 'DRAFT'}
|
||||||
|
{#if showAddRound}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addRound"
|
||||||
|
use:enhance={() => handleActionWithMessage('Round added')}
|
||||||
|
class="rounded-lg border border-accent/20 bg-surface-800 p-4"
|
||||||
|
>
|
||||||
|
<h3 class="font-600 text-sm">Add Round</h3>
|
||||||
|
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<span class="font-500 text-xs text-text-secondary">Name</span>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="e.g. Quarterfinals"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-500 text-xs text-text-secondary">Abbreviation</span>
|
||||||
|
<input
|
||||||
|
name="abbreviation"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. QF"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-500 text-xs text-text-secondary">Best Of</span>
|
||||||
|
<select
|
||||||
|
name="bestOf"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
{#each [3, 5, 7, 9, 11, 13] as n}
|
||||||
|
<option value={n} selected={n === 5}>BO{n}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-500 text-xs text-text-secondary">Mappool</span>
|
||||||
|
<select
|
||||||
|
name="mappoolId"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{#each data.mappools as mp}
|
||||||
|
<option value={mp.id}>{mp.name} ({mp.slotCount} maps)</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<span class="font-500 text-xs text-text-secondary">Scheduled At (optional)</span>
|
||||||
|
<input
|
||||||
|
name="scheduledAt"
|
||||||
|
type="datetime-local"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showAddRound = false)}
|
||||||
|
class="font-600 rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="font-600 rounded-md bg-accent px-4 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Add Round
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else if t.rounds.length > 0}
|
||||||
|
<button
|
||||||
|
onclick={() => (showAddRound = true)}
|
||||||
|
class="font-600 self-start rounded-md border border-dashed border-border px-4 py-2 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
+ Add Round
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- ─── Groups Tab ───────────────────────────────────────────────────── -->
|
||||||
|
{#snippet groupsTab()}
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
{#each t.groups as group}
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||||
|
<h3 class="font-600 text-sm">{group.name}</h3>
|
||||||
|
<table class="mt-3 w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-border text-left">
|
||||||
|
<th class="font-500 px-2 py-2 text-xs text-text-secondary">Team</th>
|
||||||
|
<th class="font-500 px-2 py-2 text-center text-xs text-text-secondary">W</th>
|
||||||
|
<th class="font-500 px-2 py-2 text-center text-xs text-text-secondary">L</th>
|
||||||
|
<th class="font-500 px-2 py-2 text-center text-xs text-text-secondary">MD</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each group.entries.sort((a: any, b: any) => b.wins - a.wins || b.mapWins - b.mapLosses - (a.mapWins - a.mapLosses)) as entry}
|
||||||
|
<tr class="border-b border-border/50">
|
||||||
|
<td class="font-500 px-2 py-2">{entry.team?.name ?? 'TBD'}</td>
|
||||||
|
<td class="px-2 py-2 text-center text-green-400">{entry.wins}</td>
|
||||||
|
<td class="px-2 py-2 text-center text-red-400">{entry.losses}</td>
|
||||||
|
<td class="px-2 py-2 text-center text-text-secondary">
|
||||||
|
{entry.mapWins - entry.mapLosses > 0 ? '+' : ''}{entry.mapWins - entry.mapLosses}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- ─── Settings Tab (Staff Only) ────────────────────────────────────── -->
|
||||||
|
{#snippet settingsTab()}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Lifecycle Actions -->
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-5">
|
||||||
|
<h3 class="font-600 text-sm">Tournament Actions</h3>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">Manage the tournament state and lifecycle</p>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
{#if t.state === 'DRAFT'}
|
||||||
|
<form method="POST" action="?/openRegistration" use:enhance={() => handleAction()}>
|
||||||
|
<button
|
||||||
|
class="font-600 rounded-md bg-green-800 px-4 py-2 text-xs text-green-200 transition-colors hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Open Registration
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if t.state === 'REGISTRATION'}
|
||||||
|
<form method="POST" action="?/closeRegistration" use:enhance={() => handleAction()}>
|
||||||
|
<button
|
||||||
|
class="font-600 rounded-md bg-yellow-800 px-4 py-2 text-xs text-yellow-200 transition-colors hover:bg-yellow-700"
|
||||||
|
>
|
||||||
|
Close Registration
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if t.state === 'SEEDING' || t.state === 'QUALIFIERS'}
|
||||||
|
<form method="POST" action="?/finalizeSeeding" use:enhance={() => handleAction()}>
|
||||||
|
<button
|
||||||
|
class="font-600 rounded-md bg-blue-800 px-4 py-2 text-xs text-blue-200 transition-colors hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Generate Bracket
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if t.state !== 'FINISHED' && t.state !== 'CANCELLED'}
|
||||||
|
<form method="POST" action="?/cancel" use:enhance={() => handleAction()}>
|
||||||
|
<button
|
||||||
|
class="font-600 rounded-md bg-red-800 px-4 py-2 text-xs text-red-200 transition-colors hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Cancel Tournament
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if t.state === 'FINISHED' || t.state === 'CANCELLED'}
|
||||||
|
<p class="self-center text-xs text-text-secondary">This tournament has ended</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if t.state === 'DRAFT' && t.rounds.length === 0}
|
||||||
|
<p class="mt-3 text-xs text-yellow-400">
|
||||||
|
You need to add at least one round before opening registration
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Staff Management -->
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-5">
|
||||||
|
<h3 class="font-600 text-sm">Staff</h3>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
{#each t.staff as s}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-md bg-surface-700 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-500">{s.userId.slice(0, 8)}...</span>
|
||||||
|
<span class="rounded bg-surface-600 px-2 py-0.5 text-xs text-text-secondary"
|
||||||
|
>{s.role}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if t.staff.length > 1}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/removeStaff"
|
||||||
|
use:enhance={() => handleActionWithMessage('Staff removed')}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="staffId" value={s.id} />
|
||||||
|
<button class="text-xs text-red-400 hover:text-red-300">Remove</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tournament Info -->
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-5">
|
||||||
|
<h3 class="font-600 text-sm">Info</h3>
|
||||||
|
<dl class="mt-3 flex flex-col gap-1 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-text-secondary">ID</dt>
|
||||||
|
<dd class="font-mono text-xs text-text-secondary">{t.id}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-text-secondary">Created</dt>
|
||||||
|
<dd class="text-text-secondary">{new Date(t.createdAt).toLocaleString()}</dd>
|
||||||
|
</div>
|
||||||
|
{#if t.registrationOpenAt}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-text-secondary">Registration Opened</dt>
|
||||||
|
<dd class="text-text-secondary">{new Date(t.registrationOpenAt).toLocaleString()}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if t.finishedAt}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-text-secondary">Finished</dt>
|
||||||
|
<dd class="text-text-secondary">{new Date(t.finishedAt).toLocaleString()}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- ─── Match Card Snippet ───────────────────────────────────────────── -->
|
||||||
|
{#snippet matchCard(m: any)}
|
||||||
|
<div
|
||||||
|
class="w-52 overflow-hidden rounded-lg border bg-surface-800 {m.state === 'LIVE'
|
||||||
|
? 'border-blue-500/50'
|
||||||
|
: 'border-border'}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b border-border/50 px-3 py-2 {m.winnerId ===
|
||||||
|
m.team1Id
|
||||||
|
? 'bg-green-900/20'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<span class="font-500 truncate text-sm {m.team1 ? '' : 'text-text-secondary'}">
|
||||||
|
{m.team1?.name ?? 'TBD'}
|
||||||
|
</span>
|
||||||
|
{#if m.match?.participants}
|
||||||
|
{@const p = m.match.participants.find((p: any) => p.teamId === m.team1Id)}
|
||||||
|
{#if p}
|
||||||
|
<span
|
||||||
|
class="font-700 ml-2 font-mono text-sm tabular-nums {m.winnerId === m.team1Id
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-text-secondary'}">{p.score}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-3 py-2 {m.winnerId === m.team2Id
|
||||||
|
? 'bg-green-900/20'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<span class="font-500 truncate text-sm {m.team2 ? '' : 'text-text-secondary'}">
|
||||||
|
{m.team2?.name ?? 'TBD'}
|
||||||
|
</span>
|
||||||
|
{#if m.match?.participants}
|
||||||
|
{@const p = m.match.participants.find((p: any) => p.teamId === m.team2Id)}
|
||||||
|
{#if p}
|
||||||
|
<span
|
||||||
|
class="font-700 ml-2 font-mono text-sm tabular-nums {m.winnerId === m.team2Id
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-text-secondary'}">{p.score}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if m.state === 'LIVE'}
|
||||||
|
<div class="bg-blue-900/30 px-3 py-1 text-center text-xs text-blue-300">LIVE</div>
|
||||||
|
{:else if m.state === 'BYE'}
|
||||||
|
<div class="bg-surface-700 px-3 py-1 text-center text-xs text-text-secondary">BYE</div>
|
||||||
|
{:else if m.matchId}
|
||||||
|
<a
|
||||||
|
href="/matches/{m.matchId}"
|
||||||
|
class="block bg-surface-700 px-3 py-1 text-center text-xs text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
View Match
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
55
src/routes/(app)/tournaments/new/+page.server.ts
Normal file
55
src/routes/(app)/tournaments/new/+page.server.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { requireAuth } from '$lib/server/permissions';
|
||||||
|
import { createTournament } from '$lib/server/tournament/lifecycle';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/login');
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request, locals }) => {
|
||||||
|
const user = requireAuth(locals);
|
||||||
|
const form = await request.formData();
|
||||||
|
|
||||||
|
const name = form.get('name')?.toString()?.trim();
|
||||||
|
const description = form.get('description')?.toString()?.trim();
|
||||||
|
const format = form.get('format')?.toString() as
|
||||||
|
| 'single_elim'
|
||||||
|
| 'double_elim'
|
||||||
|
| 'groups_bracket';
|
||||||
|
const maxSlots = parseInt(form.get('maxSlots')?.toString() ?? '16');
|
||||||
|
const teamSize = parseInt(form.get('teamSize')?.toString() ?? '1');
|
||||||
|
const scoringType = (form.get('scoringType')?.toString() ?? 'score_v2') as
|
||||||
|
| 'score'
|
||||||
|
| 'score_v2'
|
||||||
|
| 'accuracy'
|
||||||
|
| 'combo';
|
||||||
|
const startAt = form.get('startAt')?.toString();
|
||||||
|
|
||||||
|
if (!name) return { error: 'Tournament name is required' };
|
||||||
|
if (!format) return { error: 'Format is required' };
|
||||||
|
|
||||||
|
let tournamentId: string;
|
||||||
|
try {
|
||||||
|
const t = await createTournament({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
format,
|
||||||
|
config: {
|
||||||
|
teamSize,
|
||||||
|
scoringType
|
||||||
|
},
|
||||||
|
maxSlots,
|
||||||
|
createdBy: user.id,
|
||||||
|
startAt: startAt ? new Date(startAt) : undefined
|
||||||
|
});
|
||||||
|
tournamentId = t.id;
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(303, `/tournaments/${tournamentId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
158
src/routes/(app)/tournaments/new/+page.svelte
Normal file
158
src/routes/(app)/tournaments/new/+page.svelte
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let error = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let format = $state('single_elim');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Create Tournament — Vash Esports</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<h1 class="font-700 text-2xl tracking-tight">Create Tournament</h1>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
Set up your tournament, then configure rounds and mappools
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="mt-4 text-sm text-red-400">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
use:enhance={() => {
|
||||||
|
submitting = true;
|
||||||
|
error = '';
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
submitting = false;
|
||||||
|
if (
|
||||||
|
result.type === 'failure' ||
|
||||||
|
(result.type === 'success' && (result.data as any)?.error)
|
||||||
|
) {
|
||||||
|
error = (result.data as any)?.error ?? 'Failed to create tournament';
|
||||||
|
} else {
|
||||||
|
await update();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-6 flex flex-col gap-5"
|
||||||
|
>
|
||||||
|
<!-- Name -->
|
||||||
|
<div>
|
||||||
|
<label for="name" class="font-500 text-xs text-text-secondary">Tournament Name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="e.g. Vash Open #1"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label for="description" class="font-500 text-xs text-text-secondary"
|
||||||
|
>Description (optional)</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What's this tournament about?"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Format + Slots -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="format" class="font-500 text-xs text-text-secondary">Format</label>
|
||||||
|
<select
|
||||||
|
id="format"
|
||||||
|
name="format"
|
||||||
|
required
|
||||||
|
bind:value={format}
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="single_elim">Single Elimination</option>
|
||||||
|
<option value="double_elim">Double Elimination</option>
|
||||||
|
<option value="groups_bracket">Groups + Bracket</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="maxSlots" class="font-500 text-xs text-text-secondary">Max Players</label>
|
||||||
|
<select
|
||||||
|
id="maxSlots"
|
||||||
|
name="maxSlots"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
{#each [4, 8, 16, 32, 64, 128] as n}
|
||||||
|
<option value={n} selected={n === 16}>{n}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Size + Scoring -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="teamSize" class="font-500 text-xs text-text-secondary">Team Size</label>
|
||||||
|
<select
|
||||||
|
id="teamSize"
|
||||||
|
name="teamSize"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="1" selected>1v1</option>
|
||||||
|
<option value="2">2v2</option>
|
||||||
|
<option value="3">3v3</option>
|
||||||
|
<option value="4">4v4</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="scoringType" class="font-500 text-xs text-text-secondary">Scoring</label>
|
||||||
|
<select
|
||||||
|
id="scoringType"
|
||||||
|
name="scoringType"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="score_v2" selected>ScoreV2</option>
|
||||||
|
<option value="score">Score V1</option>
|
||||||
|
<option value="accuracy">Accuracy</option>
|
||||||
|
<option value="combo">Combo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start Date -->
|
||||||
|
<div>
|
||||||
|
<label for="startAt" class="font-500 text-xs text-text-secondary">Start Date (optional)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="startAt"
|
||||||
|
name="startAt"
|
||||||
|
type="datetime-local"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
class="font-600 rounded-md bg-accent px-5 py-2 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Creating...' : 'Create Tournament'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
101
src/routes/(app)/users/[id]/+page.server.ts
Normal file
101
src/routes/(app)/users/[id]/+page.server.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user } from '$lib/server/db/auth.schema';
|
||||||
|
import {
|
||||||
|
playerRating,
|
||||||
|
teamMember,
|
||||||
|
matchParticipantPlayer,
|
||||||
|
profileComment
|
||||||
|
} from '$lib/server/db/schema';
|
||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { requireAuth } from '$lib/server/permissions';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const u = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, params.id)
|
||||||
|
});
|
||||||
|
if (!u) error(404, 'User not found');
|
||||||
|
|
||||||
|
// Player rating
|
||||||
|
const rating = await db.query.playerRating.findFirst({
|
||||||
|
where: eq(playerRating.userId, params.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Teams (non-personal)
|
||||||
|
const memberships = await db.query.teamMember.findMany({
|
||||||
|
where: eq(teamMember.userId, params.id),
|
||||||
|
with: { team: true }
|
||||||
|
});
|
||||||
|
const teams = memberships
|
||||||
|
.filter((m) => !m.team.isPersonal)
|
||||||
|
.map((m) => ({ id: m.team.id, name: m.team.name, avatarUrl: m.team.avatarUrl, role: m.role }));
|
||||||
|
|
||||||
|
// Recent matches via matchParticipantPlayer
|
||||||
|
const playerEntries = await db.query.matchParticipantPlayer.findMany({
|
||||||
|
where: eq(matchParticipantPlayer.userId, params.id),
|
||||||
|
with: {
|
||||||
|
participant: {
|
||||||
|
with: {
|
||||||
|
match: {
|
||||||
|
with: {
|
||||||
|
participants: {
|
||||||
|
with: { team: true },
|
||||||
|
orderBy: (p: any, { asc }: any) => [asc(p.slot)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentMatches = playerEntries
|
||||||
|
.map((pe) => pe.participant.match)
|
||||||
|
.filter((m) => m.state === 'FINISHED' || m.state === 'CANCELLED')
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, 20);
|
||||||
|
|
||||||
|
const personalTeam = memberships.find((m) => m.team.isPersonal)?.team;
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
const comments = await db.query.profileComment.findMany({
|
||||||
|
where: and(eq(profileComment.targetType, 'user'), eq(profileComment.targetId, params.id)),
|
||||||
|
with: { author: true },
|
||||||
|
orderBy: desc(profileComment.createdAt)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: {
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
image: u.image,
|
||||||
|
role: u.role,
|
||||||
|
createdAt: u.createdAt
|
||||||
|
},
|
||||||
|
rating: rating ? { elo: rating.elo, wins: rating.wins, losses: rating.losses } : null,
|
||||||
|
teams,
|
||||||
|
recentMatches,
|
||||||
|
personalTeamId: personalTeam?.id ?? null,
|
||||||
|
comments
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
comment: async ({ params, request, locals }) => {
|
||||||
|
const u = requireAuth(locals);
|
||||||
|
const form = await request.formData();
|
||||||
|
const content = form.get('content')?.toString()?.trim();
|
||||||
|
if (!content) return { error: 'Comment cannot be empty' };
|
||||||
|
if (content.length > 1000) return { error: 'Comment too long (max 1000 characters)' };
|
||||||
|
|
||||||
|
await db.insert(profileComment).values({
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: params.id,
|
||||||
|
authorId: u.id,
|
||||||
|
content
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
154
src/routes/(app)/users/[id]/+page.svelte
Normal file
154
src/routes/(app)/users/[id]/+page.svelte
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ProfileComments from '$lib/components/ProfileComments.svelte';
|
||||||
|
|
||||||
|
const { data } = $props();
|
||||||
|
|
||||||
|
const p = $derived(data.profile);
|
||||||
|
|
||||||
|
const roleBadge: Record<string, { label: string; class: string }> = {
|
||||||
|
admin: { label: 'Admin', class: 'bg-red-500/20 text-red-400' },
|
||||||
|
referee: { label: 'Referee', class: 'bg-blue-500/20 text-blue-400' },
|
||||||
|
player: { label: 'Player', class: 'bg-surface-600 text-text-secondary' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const badge = $derived(roleBadge[p.role] ?? roleBadge.player);
|
||||||
|
|
||||||
|
function timeAgo(date: string | Date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: string | Date) {
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{p.name} — Vash Esports</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#if p.image}
|
||||||
|
<img src={p.image} alt="" class="h-16 w-16 rounded-full" />
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="font-700 flex h-16 w-16 items-center justify-center rounded-full bg-surface-600 text-xl text-text-secondary"
|
||||||
|
>
|
||||||
|
{p.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="font-700 text-2xl tracking-tight">{p.name}</h1>
|
||||||
|
<span class="font-600 rounded px-1.5 py-0.5 text-[10px] {badge.class}">{badge.label}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-sm text-text-secondary">
|
||||||
|
Joined {formatDate(p.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
{#if data.rating}
|
||||||
|
<div class="mt-6 grid grid-cols-3 gap-3">
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 px-4 py-3 text-center">
|
||||||
|
<p class="font-700 text-xl text-accent">{data.rating.elo}</p>
|
||||||
|
<p class="mt-0.5 text-[11px] text-text-secondary">ELO</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 px-4 py-3 text-center">
|
||||||
|
<p class="font-700 text-xl text-green-400">{data.rating.wins}</p>
|
||||||
|
<p class="mt-0.5 text-[11px] text-text-secondary">Wins</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 px-4 py-3 text-center">
|
||||||
|
<p class="font-700 text-xl text-red-400">{data.rating.losses}</p>
|
||||||
|
<p class="mt-0.5 text-[11px] text-text-secondary">Losses</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Teams -->
|
||||||
|
{#if data.teams.length > 0}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="font-600 text-sm text-text-secondary">Teams</h2>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
{#each data.teams as team}
|
||||||
|
<a
|
||||||
|
href="/teams/{team.id}"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
|
||||||
|
>
|
||||||
|
{#if team.avatarUrl}
|
||||||
|
<img src={team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||||
|
>
|
||||||
|
{team.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="font-600 flex-1 text-sm">{team.name}</span>
|
||||||
|
<span class="text-xs text-text-secondary">{team.role}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Recent Matches -->
|
||||||
|
{#if data.recentMatches.length > 0}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="font-600 text-sm text-text-secondary">Recent Matches</h2>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
{#each data.recentMatches as m}
|
||||||
|
{@const p1 = m.participants[0]}
|
||||||
|
{@const p2 = m.participants[1]}
|
||||||
|
{@const won = data.personalTeamId ? m.winnerId === data.personalTeamId : false}
|
||||||
|
<a
|
||||||
|
href="/matches/{m.id}"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
|
||||||
|
>
|
||||||
|
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
|
||||||
|
{#if m.state === 'FINISHED'}
|
||||||
|
<span
|
||||||
|
class="font-700 text-xs tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-text-secondary'}">{p1?.score ?? 0}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-text-secondary">-</span>
|
||||||
|
<span
|
||||||
|
class="font-700 text-xs tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0)
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-text-secondary'}">{p2?.score ?? 0}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-text-secondary">vs</span>
|
||||||
|
{/if}
|
||||||
|
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
|
||||||
|
{#if m.state === 'FINISHED'}
|
||||||
|
<span class="ml-auto text-xs {won ? 'font-600 text-green-400' : 'text-red-400'}"
|
||||||
|
>{won ? 'W' : 'L'}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="ml-auto text-xs text-text-secondary">{m.state}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-xs text-text-secondary/50">{timeAgo(m.createdAt)}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ProfileComments comments={data.comments} actionUrl="?/comment" />
|
||||||
|
</div>
|
||||||
@@ -1,26 +1,27 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { createInvite, getInvitesForUser } from '$lib/server/match/invites';
|
import { createInvite, getInvitesForUser } from '$lib/server/match/invites';
|
||||||
|
import { requireAuth } from '$lib/server/permissions';
|
||||||
|
import { createInviteSchema, parseBody } from '$lib/server/validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ locals }) => {
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
const user = requireAuth(locals);
|
||||||
const invites = await getInvitesForUser(locals.user.id);
|
const invites = await getInvitesForUser(user.id);
|
||||||
return json(invites);
|
return json(invites);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
const user = requireAuth(locals);
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } = body;
|
const { creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } = parseBody(
|
||||||
|
createInviteSchema,
|
||||||
if (!creatorTeamId || !invitedTeamId || !config || !mappoolId) {
|
body
|
||||||
error(400, 'Missing required fields');
|
);
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const invite = await createInvite({
|
const invite = await createInvite({
|
||||||
createdBy: locals.user.id,
|
createdBy: user.id,
|
||||||
creatorTeamId,
|
creatorTeamId,
|
||||||
invitedTeamId,
|
invitedTeamId,
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { mappool } from '$lib/server/db/schema';
|
import { mappool } from '$lib/server/db/schema';
|
||||||
|
import { requireAuth, requirePermission, GlobalPermission } from '$lib/server/permissions';
|
||||||
|
import { createMappoolSchema, parseBody } from '$lib/server/validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
// List all mappools
|
// List all mappools
|
||||||
export const GET: RequestHandler = async ({ locals }) => {
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
requireAuth(locals);
|
||||||
|
|
||||||
const mappools = await db.query.mappool.findMany({
|
const mappools = await db.query.mappool.findMany({
|
||||||
orderBy: (m, { desc }) => [desc(m.createdAt)]
|
orderBy: (m, { desc }) => [desc(m.createdAt)]
|
||||||
@@ -14,18 +16,22 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
return json(mappools);
|
return json(mappools);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a mappool
|
// Create a mappool (admin only)
|
||||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
requirePermission(
|
||||||
|
locals,
|
||||||
|
GlobalPermission.MAPPOOL_CREATE,
|
||||||
|
'Only authorized users can create mappools'
|
||||||
|
);
|
||||||
|
|
||||||
const { name } = await request.json();
|
const body = await request.json();
|
||||||
if (!name?.trim()) error(400, 'Name is required');
|
const { name } = parseBody(createMappoolSchema, body);
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(mappool)
|
.insert(mappool)
|
||||||
.values({
|
.values({
|
||||||
name: name.trim(),
|
name,
|
||||||
createdBy: locals.user.id
|
createdBy: locals.user!.id
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { json, error } from '@sveltejs/kit';
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { mappool, mappoolSlot } from '$lib/server/db/schema';
|
import { mappool, mappoolSlot } from '$lib/server/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
// Get a mappool with its slots
|
// Get a mappool with its slots
|
||||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
requireAuth(locals);
|
||||||
|
|
||||||
const pool = await db.query.mappool.findFirst({
|
const pool = await db.query.mappool.findFirst({
|
||||||
where: eq(mappool.id, params.id)
|
where: eq(mappool.id, params.id)
|
||||||
@@ -24,7 +25,17 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
|||||||
|
|
||||||
// Delete a mappool
|
// Delete a mappool
|
||||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
const pool = await db.query.mappool.findFirst({
|
||||||
|
where: eq(mappool.id, params.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pool) error(404, 'Mappool not found');
|
||||||
|
|
||||||
|
requireOwnerOrAdmin(
|
||||||
|
locals,
|
||||||
|
pool.createdBy,
|
||||||
|
'Only the mappool creator or an admin can delete this'
|
||||||
|
);
|
||||||
|
|
||||||
await db.delete(mappool).where(eq(mappool.id, params.id));
|
await db.delete(mappool).where(eq(mappool.id, params.id));
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { mappoolSlot } from '$lib/server/db/schema';
|
import { mappool, mappoolSlot } from '$lib/server/db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { getBeatmap } from '$lib/server/osu/api';
|
import { requireOwnerOrAdmin } from '$lib/server/permissions';
|
||||||
|
import { createMappoolSlotSchema, parseBody } from '$lib/server/validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
// Add a slot to a mappool
|
// Add a slot to a mappool
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
const pool = await db.query.mappool.findFirst({
|
||||||
|
where: eq(mappool.id, params.id)
|
||||||
|
});
|
||||||
|
|
||||||
const { beatmapId, category, mods } = await request.json();
|
if (!pool) error(404, 'Mappool not found');
|
||||||
|
|
||||||
if (!beatmapId || !category) {
|
requireOwnerOrAdmin(locals, pool.createdBy, 'Only the mappool creator or an admin can add slots');
|
||||||
error(400, 'beatmapId and category are required');
|
|
||||||
}
|
const body = await request.json();
|
||||||
|
const { beatmapId, category, mods } = parseBody(createMappoolSlotSchema, body);
|
||||||
|
|
||||||
// Count existing slots in this category to auto-increment order
|
// Count existing slots in this category to auto-increment order
|
||||||
const existing = await db.query.mappoolSlot.findMany({
|
const existing = await db.query.mappoolSlot.findMany({
|
||||||
where: and(
|
where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, category))
|
||||||
eq(mappoolSlot.mappoolId, params.id),
|
|
||||||
eq(mappoolSlot.category, category.toUpperCase())
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(mappoolSlot)
|
.insert(mappoolSlot)
|
||||||
.values({
|
.values({
|
||||||
mappoolId: params.id,
|
mappoolId: params.id,
|
||||||
beatmapId: String(beatmapId),
|
beatmapId,
|
||||||
category: category.toUpperCase(),
|
category,
|
||||||
orderInCategory: existing.length + 1,
|
orderInCategory: existing.length + 1,
|
||||||
mods: mods ?? []
|
mods: mods ?? []
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { mappoolSlot } from '$lib/server/db/schema';
|
import { mappool, mappoolSlot } from '$lib/server/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireOwnerOrAdmin } from '$lib/server/permissions';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
const pool = await db.query.mappool.findFirst({
|
||||||
|
where: eq(mappool.id, params.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pool) error(404, 'Mappool not found');
|
||||||
|
|
||||||
|
requireOwnerOrAdmin(
|
||||||
|
locals,
|
||||||
|
pool.createdBy,
|
||||||
|
'Only the mappool creator or an admin can delete slots'
|
||||||
|
);
|
||||||
|
|
||||||
await db.delete(mappoolSlot).where(eq(mappoolSlot.id, params.slotId));
|
await db.delete(mappoolSlot).where(eq(mappoolSlot.id, params.slotId));
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { createMatch } from '$lib/server/match/engine';
|
import { createMatch } from '$lib/server/match/engine';
|
||||||
|
import { requireAuth, requirePermission, GlobalPermission } from '$lib/server/permissions';
|
||||||
|
import { createMatchSchema, parseBody } from '$lib/server/validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ locals }) => {
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
requireAuth(locals);
|
||||||
|
|
||||||
const matches = await db.query.match.findMany({
|
const matches = await db.query.match.findMany({
|
||||||
with: {
|
with: {
|
||||||
@@ -18,14 +20,14 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
requirePermission(
|
||||||
|
locals,
|
||||||
|
GlobalPermission.MATCH_CREATE,
|
||||||
|
'Only referees or admins can create matches'
|
||||||
|
);
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, config, mappoolId, teams } = body;
|
const { name, config, mappoolId, teams } = parseBody(createMatchSchema, body);
|
||||||
|
|
||||||
if (!name || !config || !mappoolId || !teams?.length) {
|
|
||||||
error(400, 'Missing required fields');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createMatch({
|
const result = await createMatch({
|
||||||
@@ -33,7 +35,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
config,
|
config,
|
||||||
mappoolId,
|
mappoolId,
|
||||||
teams,
|
teams,
|
||||||
createdBy: locals.user.id
|
createdBy: locals.user!.id
|
||||||
});
|
});
|
||||||
return json(result, { status: 201 });
|
return json(result, { status: 201 });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
// src/routes/api/matches/[id]/lobby/+server.ts
|
// src/routes/api/matches/[id]/lobby/+server.ts
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { moveToLobby } from '$lib/server/match/engine';
|
import { moveToLobby } from '$lib/server/match/engine';
|
||||||
|
import { requirePermission, GlobalPermission } from '$lib/server/permissions';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
requirePermission(
|
||||||
|
locals,
|
||||||
|
GlobalPermission.MATCH_REFEREE,
|
||||||
|
'Only referees or admins can move matches to lobby'
|
||||||
|
);
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
// src/routes/api/matches/[id]/pick/+server.ts
|
// src/routes/api/matches/[id]/pick/+server.ts
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { pickMap } from '$lib/server/match/engine';
|
import { pickMap } from '$lib/server/match/engine';
|
||||||
|
import { requireAuth, hasPermission, GlobalPermission } from '$lib/server/permissions';
|
||||||
|
import { getMatchFull } from '$lib/server/match/helpers';
|
||||||
|
import { pickMapSchema, parseBody } from '$lib/server/validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
const user = requireAuth(locals);
|
||||||
|
|
||||||
const { participantId, mappoolSlotId } = await request.json();
|
const body = await request.json();
|
||||||
if (!participantId || !mappoolSlotId) {
|
const { mappoolSlotId } = parseBody(pickMapSchema, body);
|
||||||
error(400, 'participantId and mappoolSlotId required');
|
|
||||||
|
const match = await getMatchFull(params.id);
|
||||||
|
|
||||||
|
// Find the participant linked to the authenticated user
|
||||||
|
const participant = match.participants.find((p) => p.players.some((pl) => pl.userId === user.id));
|
||||||
|
|
||||||
|
if (!participant && !hasPermission(user.role, GlobalPermission.MATCH_REFEREE)) {
|
||||||
|
error(403, 'You are not a participant in this match');
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantId = participant?.id;
|
||||||
|
if (!participantId) {
|
||||||
|
error(400, 'Could not resolve participant');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
// src/routes/api/matches/[id]/roll/+server.ts
|
// src/routes/api/matches/[id]/roll/+server.ts
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { submitRoll } from '$lib/server/match/engine';
|
import { submitRoll } from '$lib/server/match/engine';
|
||||||
|
import { requireAuth, hasPermission, GlobalPermission } from '$lib/server/permissions';
|
||||||
|
import { getMatchFull } from '$lib/server/match/helpers';
|
||||||
|
import { submitRollSchema, parseBody } from '$lib/server/validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
const user = requireAuth(locals);
|
||||||
|
|
||||||
const { participantId, value } = await request.json();
|
const body = await request.json();
|
||||||
if (!participantId || value == null) {
|
const { value } = parseBody(submitRollSchema, body);
|
||||||
error(400, 'participantId and value required');
|
|
||||||
|
const match = await getMatchFull(params.id);
|
||||||
|
|
||||||
|
// Find the participant linked to the authenticated user
|
||||||
|
const participant = match.participants.find((p) => p.players.some((pl) => pl.userId === user.id));
|
||||||
|
|
||||||
|
if (!participant && !hasPermission(user.role, GlobalPermission.MATCH_REFEREE)) {
|
||||||
|
error(403, 'You are not a participant in this match');
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantId = participant?.id;
|
||||||
|
if (!participantId) {
|
||||||
|
error(400, 'Could not resolve participant');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
// src/routes/api/matches/[id]/rolling/+server.ts
|
// src/routes/api/matches/[id]/rolling/+server.ts
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { moveToRolling } from '$lib/server/match/engine';
|
import { moveToRolling } from '$lib/server/match/engine';
|
||||||
|
import { requirePermission, GlobalPermission } from '$lib/server/permissions';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
requirePermission(
|
||||||
|
locals,
|
||||||
|
GlobalPermission.MATCH_REFEREE,
|
||||||
|
'Only referees or admins can move matches to rolling'
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const m = await moveToRolling(params.id);
|
const m = await moveToRolling(params.id);
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
// src/routes/api/matches/[id]/score/+server.ts
|
// src/routes/api/matches/[id]/score/+server.ts
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import { submitGameScores } from '$lib/server/match/engine';
|
import { submitGameScores } from '$lib/server/match/engine';
|
||||||
|
import { requirePermission, GlobalPermission } from '$lib/server/permissions';
|
||||||
|
import { submitScoreSchema, parseBody } from '$lib/server/validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not logged in');
|
requirePermission(
|
||||||
|
locals,
|
||||||
|
GlobalPermission.MATCH_REFEREE,
|
||||||
|
'Only referees or admins can submit scores'
|
||||||
|
);
|
||||||
|
|
||||||
const { matchGameId, scores } = await request.json();
|
const body = await request.json();
|
||||||
if (!matchGameId || !scores?.length) {
|
const { matchGameId, scores } = parseBody(submitScoreSchema, body);
|
||||||
error(400, 'matchGameId and scores required');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const m = await submitGameScores(matchGameId, scores);
|
const m = await submitGameScores(matchGameId, scores);
|
||||||
|
|||||||
56
src/routes/api/search/+server.ts
Normal file
56
src/routes/api/search/+server.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user } from '$lib/server/db/auth.schema';
|
||||||
|
import { team, mappool, tournament, match } from '$lib/server/db/schema';
|
||||||
|
import { ilike, and, eq } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const q = url.searchParams.get('q')?.trim();
|
||||||
|
if (!q || q.length < 2) return json({ results: {} });
|
||||||
|
|
||||||
|
const pattern = `%${q}%`;
|
||||||
|
const limit = 5;
|
||||||
|
|
||||||
|
const [users, teams, mappools, tournaments, matches] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ id: user.id, name: user.name, image: user.image })
|
||||||
|
.from(user)
|
||||||
|
.where(ilike(user.name, pattern))
|
||||||
|
.limit(limit),
|
||||||
|
db
|
||||||
|
.select({ id: team.id, name: team.name, avatarUrl: team.avatarUrl })
|
||||||
|
.from(team)
|
||||||
|
.where(and(ilike(team.name, pattern), eq(team.isPersonal, false)))
|
||||||
|
.limit(limit),
|
||||||
|
db
|
||||||
|
.select({ id: mappool.id, name: mappool.name })
|
||||||
|
.from(mappool)
|
||||||
|
.where(ilike(mappool.name, pattern))
|
||||||
|
.limit(limit),
|
||||||
|
db
|
||||||
|
.select({ id: tournament.id, name: tournament.name, state: tournament.state })
|
||||||
|
.from(tournament)
|
||||||
|
.where(ilike(tournament.name, pattern))
|
||||||
|
.limit(limit),
|
||||||
|
db
|
||||||
|
.select({ id: match.id, name: match.name, state: match.state })
|
||||||
|
.from(match)
|
||||||
|
.where(ilike(match.name, pattern))
|
||||||
|
.limit(limit)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
results: {
|
||||||
|
users: users.map((u) => ({ ...u, type: 'user', href: `/users/${u.id}` })),
|
||||||
|
teams: teams.map((t) => ({ ...t, type: 'team', href: `/teams/${t.id}` })),
|
||||||
|
mappools: mappools.map((m) => ({ ...m, type: 'mappool', href: `/mappools/${m.id}` })),
|
||||||
|
tournaments: tournaments.map((t) => ({
|
||||||
|
...t,
|
||||||
|
type: 'tournament',
|
||||||
|
href: `/tournaments/${t.id}`
|
||||||
|
})),
|
||||||
|
matches: matches.map((m) => ({ ...m, type: 'match', href: `/matches/${m.id}` }))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
76
src/routes/api/tournaments/+server.ts
Normal file
76
src/routes/api/tournaments/+server.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { tournament } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import { requireAuth } from '$lib/server/permissions';
|
||||||
|
import { createTournament } from '$lib/server/tournament/lifecycle';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
const format = url.searchParams.get('format');
|
||||||
|
|
||||||
|
const query = db.query.tournament.findMany({
|
||||||
|
with: {
|
||||||
|
registrations: true,
|
||||||
|
staff: true
|
||||||
|
},
|
||||||
|
orderBy: [desc(tournament.createdAt)]
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await query;
|
||||||
|
|
||||||
|
// filter in memory (drizzle doesn't support dynamic where chaining easily)
|
||||||
|
let filtered = results;
|
||||||
|
if (state) filtered = filtered.filter((t) => t.state === state);
|
||||||
|
if (format) filtered = filtered.filter((t) => t.format === format);
|
||||||
|
|
||||||
|
return json(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
const user = requireAuth(locals);
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
format,
|
||||||
|
config,
|
||||||
|
maxSlots,
|
||||||
|
bannerUrl,
|
||||||
|
registrationOpenAt,
|
||||||
|
registrationCloseAt,
|
||||||
|
startAt
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!name || !format || !config || !maxSlots) {
|
||||||
|
error(400, 'Missing required fields: name, format, config, maxSlots');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['single_elim', 'double_elim', 'groups_bracket'].includes(format)) {
|
||||||
|
error(400, 'Invalid format. Must be single_elim, double_elim, or groups_bracket');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.teamSize || !config.scoringType) {
|
||||||
|
error(400, 'Config must include teamSize and scoringType');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createTournament({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
format,
|
||||||
|
config,
|
||||||
|
maxSlots,
|
||||||
|
bannerUrl,
|
||||||
|
createdBy: user.id,
|
||||||
|
registrationOpenAt: registrationOpenAt ? new Date(registrationOpenAt) : undefined,
|
||||||
|
registrationCloseAt: registrationCloseAt ? new Date(registrationCloseAt) : undefined,
|
||||||
|
startAt: startAt ? new Date(startAt) : undefined
|
||||||
|
});
|
||||||
|
return json(result, { status: 201 });
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
73
src/routes/api/tournaments/[id]/+server.ts
Normal file
73
src/routes/api/tournaments/[id]/+server.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { tournament } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||||
|
import { getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const result = await getTournamentFull(params.id);
|
||||||
|
if (!result) error(404, 'Tournament not found');
|
||||||
|
return json(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_SETTINGS);
|
||||||
|
|
||||||
|
const t = await db.query.tournament.findFirst({
|
||||||
|
where: eq(tournament.id, params.id)
|
||||||
|
});
|
||||||
|
if (!t) error(404, 'Tournament not found');
|
||||||
|
if (t.state !== 'DRAFT') error(400, 'Can only edit tournaments in DRAFT state');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const allowed = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'format',
|
||||||
|
'config',
|
||||||
|
'maxSlots',
|
||||||
|
'bannerUrl',
|
||||||
|
'registrationOpenAt',
|
||||||
|
'registrationCloseAt',
|
||||||
|
'startAt'
|
||||||
|
];
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (body[key] !== undefined) {
|
||||||
|
if (key.endsWith('At') && body[key]) {
|
||||||
|
updates[key] = new Date(body[key]);
|
||||||
|
} else {
|
||||||
|
updates[key] = body[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
error(400, 'No valid fields to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(tournament).set(updates).where(eq(tournament.id, params.id));
|
||||||
|
return json(await getTournamentFull(params.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_SETTINGS);
|
||||||
|
|
||||||
|
const t = await db.query.tournament.findFirst({
|
||||||
|
where: eq(tournament.id, params.id)
|
||||||
|
});
|
||||||
|
if (!t) error(404, 'Tournament not found');
|
||||||
|
|
||||||
|
if (t.state === 'DRAFT') {
|
||||||
|
await db.delete(tournament).where(eq(tournament.id, params.id));
|
||||||
|
return json({ deleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// for non-draft, cancel instead of delete
|
||||||
|
const { cancelTournament } = await import('$lib/server/tournament/lifecycle');
|
||||||
|
await cancelTournament(params.id);
|
||||||
|
return json(await getTournamentFull(params.id));
|
||||||
|
};
|
||||||
28
src/routes/api/tournaments/[id]/bracket/+server.ts
Normal file
28
src/routes/api/tournaments/[id]/bracket/+server.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { tournamentMatch, tournamentRound } from '$lib/server/db/schema';
|
||||||
|
import { eq, asc } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const rounds = await db.query.tournamentRound.findMany({
|
||||||
|
where: eq(tournamentRound.tournamentId, params.id),
|
||||||
|
orderBy: [asc(tournamentRound.roundOrder)]
|
||||||
|
});
|
||||||
|
|
||||||
|
const matches = await db.query.tournamentMatch.findMany({
|
||||||
|
where: eq(tournamentMatch.tournamentId, params.id),
|
||||||
|
with: {
|
||||||
|
team1: true,
|
||||||
|
team2: true,
|
||||||
|
winner: true,
|
||||||
|
match: {
|
||||||
|
with: {
|
||||||
|
participants: { with: { team: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ rounds, matches });
|
||||||
|
};
|
||||||
15
src/routes/api/tournaments/[id]/cancel/+server.ts
Normal file
15
src/routes/api/tournaments/[id]/cancel/+server.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||||
|
import { cancelTournament, getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_SETTINGS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cancelTournament(params.id);
|
||||||
|
return json(await getTournamentFull(params.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||||
|
import { closeRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_REGISTRATIONS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await closeRegistration(params.id);
|
||||||
|
return json(await getTournamentFull(params.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
15
src/routes/api/tournaments/[id]/finalize-seeding/+server.ts
Normal file
15
src/routes/api/tournaments/[id]/finalize-seeding/+server.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||||
|
import { finalizeSeeding, getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_BRACKET);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await finalizeSeeding(params.id);
|
||||||
|
return json(await getTournamentFull(params.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
25
src/routes/api/tournaments/[id]/matches/+server.ts
Normal file
25
src/routes/api/tournaments/[id]/matches/+server.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { tournamentMatch } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const matches = await db.query.tournamentMatch.findMany({
|
||||||
|
where: eq(tournamentMatch.tournamentId, params.id),
|
||||||
|
with: {
|
||||||
|
round: true,
|
||||||
|
team1: true,
|
||||||
|
team2: true,
|
||||||
|
winner: true,
|
||||||
|
match: {
|
||||||
|
with: {
|
||||||
|
participants: { with: { team: true } },
|
||||||
|
games: { with: { slot: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(matches);
|
||||||
|
};
|
||||||
15
src/routes/api/tournaments/[id]/open-registration/+server.ts
Normal file
15
src/routes/api/tournaments/[id]/open-registration/+server.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||||
|
import { openRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_REGISTRATIONS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openRegistration(params.id);
|
||||||
|
return json(await getTournamentFull(params.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
41
src/routes/api/tournaments/[id]/qualifiers/+server.ts
Normal file
41
src/routes/api/tournaments/[id]/qualifiers/+server.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
getQualifierRankings,
|
||||||
|
getQualifierMappool,
|
||||||
|
submitQualifierScore
|
||||||
|
} from '$lib/server/tournament/qualifiers';
|
||||||
|
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const [rankings, mappool] = await Promise.all([
|
||||||
|
getQualifierRankings(params.id),
|
||||||
|
getQualifierMappool(params.id)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return json({ rankings, mappool });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.REFEREE_MATCHES);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { teamId, mappoolSlotId, totalScore, accuracy } = body;
|
||||||
|
|
||||||
|
if (!teamId || !mappoolSlotId || totalScore === undefined) {
|
||||||
|
error(400, 'Missing required fields: teamId, mappoolSlotId, totalScore');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const score = await submitQualifierScore({
|
||||||
|
tournamentId: params.id,
|
||||||
|
teamId,
|
||||||
|
mappoolSlotId,
|
||||||
|
totalScore,
|
||||||
|
accuracy
|
||||||
|
});
|
||||||
|
return json(score, { status: 201 });
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
32
src/routes/api/tournaments/[id]/register/+server.ts
Normal file
32
src/routes/api/tournaments/[id]/register/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { requireAuth } from '$lib/server/permissions';
|
||||||
|
import { registerForTournament, withdrawFromTournament } from '$lib/server/tournament/lifecycle';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const user = requireAuth(locals);
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body.teamId) error(400, 'Missing teamId');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reg = await registerForTournament(params.id, body.teamId, user.id);
|
||||||
|
return json(reg, { status: 201 });
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
const user = requireAuth(locals);
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body.teamId) error(400, 'Missing teamId');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withdrawFromTournament(params.id, body.teamId);
|
||||||
|
return json({ withdrawn: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
44
src/routes/api/tournaments/[id]/rounds/+server.ts
Normal file
44
src/routes/api/tournaments/[id]/rounds/+server.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { tournamentRound } from '$lib/server/db/schema';
|
||||||
|
import { eq, asc } from 'drizzle-orm';
|
||||||
|
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const rounds = await db.query.tournamentRound.findMany({
|
||||||
|
where: eq(tournamentRound.tournamentId, params.id),
|
||||||
|
with: { mappool: { with: { slots: true } } },
|
||||||
|
orderBy: [asc(tournamentRound.roundOrder)]
|
||||||
|
});
|
||||||
|
return json(rounds);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_ROUNDS);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, abbreviation, bestOf, mappoolId, bracketType, scheduledAt } = body;
|
||||||
|
|
||||||
|
if (!name || !bestOf) error(400, 'Missing required fields: name, bestOf');
|
||||||
|
|
||||||
|
const existing = await db.query.tournamentRound.findMany({
|
||||||
|
where: eq(tournamentRound.tournamentId, params.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(tournamentRound)
|
||||||
|
.values({
|
||||||
|
tournamentId: params.id,
|
||||||
|
name,
|
||||||
|
abbreviation,
|
||||||
|
roundOrder: existing.length,
|
||||||
|
bestOf,
|
||||||
|
mappoolId,
|
||||||
|
bracketType,
|
||||||
|
scheduledAt: scheduledAt ? new Date(scheduledAt) : undefined
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return json(created, { status: 201 });
|
||||||
|
};
|
||||||
40
src/routes/api/tournaments/[id]/rounds/[roundId]/+server.ts
Normal file
40
src/routes/api/tournaments/[id]/rounds/[roundId]/+server.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { tournamentRound } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_ROUNDS);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (body.name !== undefined) updates.name = body.name;
|
||||||
|
if (body.abbreviation !== undefined) updates.abbreviation = body.abbreviation;
|
||||||
|
if (body.bestOf !== undefined) updates.bestOf = body.bestOf;
|
||||||
|
if (body.mappoolId !== undefined) updates.mappoolId = body.mappoolId;
|
||||||
|
if (body.scheduledAt !== undefined)
|
||||||
|
updates.scheduledAt = body.scheduledAt ? new Date(body.scheduledAt) : null;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
error(400, 'No valid fields to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(tournamentRound).set(updates).where(eq(tournamentRound.id, params.roundId));
|
||||||
|
|
||||||
|
const updated = await db.query.tournamentRound.findFirst({
|
||||||
|
where: eq(tournamentRound.id, params.roundId),
|
||||||
|
with: { mappool: { with: { slots: true } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_ROUNDS);
|
||||||
|
|
||||||
|
await db.delete(tournamentRound).where(eq(tournamentRound.id, params.roundId));
|
||||||
|
return json({ deleted: true });
|
||||||
|
};
|
||||||
39
src/routes/api/tournaments/[id]/staff/+server.ts
Normal file
39
src/routes/api/tournaments/[id]/staff/+server.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { tournamentStaff } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const staff = await db.query.tournamentStaff.findMany({
|
||||||
|
where: eq(tournamentStaff.tournamentId, params.id)
|
||||||
|
});
|
||||||
|
return json(staff);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_STAFF);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.userId || !body.role) error(400, 'Missing userId or role');
|
||||||
|
|
||||||
|
const validRoles = ['organizer', 'admin', 'referee', 'pooler', 'streamer'];
|
||||||
|
if (!validRoles.includes(body.role)) {
|
||||||
|
error(400, `Invalid role. Must be one of: ${validRoles.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(tournamentStaff)
|
||||||
|
.values({
|
||||||
|
tournamentId: params.id,
|
||||||
|
userId: body.userId,
|
||||||
|
role: body.role
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return json(created, { status: 201 });
|
||||||
|
} catch {
|
||||||
|
error(400, 'User is already a staff member');
|
||||||
|
}
|
||||||
|
};
|
||||||
12
src/routes/api/tournaments/[id]/staff/[staffId]/+server.ts
Normal file
12
src/routes/api/tournaments/[id]/staff/[staffId]/+server.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { tournamentStaff } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_STAFF);
|
||||||
|
await db.delete(tournamentStaff).where(eq(tournamentStaff.id, params.staffId));
|
||||||
|
return json({ deleted: true });
|
||||||
|
};
|
||||||
17
src/routes/api/tournaments/[id]/start-qualifiers/+server.ts
Normal file
17
src/routes/api/tournaments/[id]/start-qualifiers/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||||
|
import { getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
|
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_BRACKET);
|
||||||
|
|
||||||
|
const { finalizeQualifiers } = await import('$lib/server/tournament/qualifiers');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await finalizeQualifiers(params.id);
|
||||||
|
return json(await getTournamentFull(params.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user