feat: add search and data tables

This commit is contained in:
2026-03-27 19:16:53 +01:00
parent ae4a77f5dd
commit 8d71480e9a
54 changed files with 2577 additions and 1577 deletions

View File

@@ -33,6 +33,10 @@ if (!building) {
.then(({ startTournamentScheduler }) => startTournamentScheduler()) .then(({ startTournamentScheduler }) => startTournamentScheduler())
.catch((err) => log.tournament.warn({ err: err.message }, 'Tournament scheduler init skipped')); .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');
}); });

View 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}

View 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>

View 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}

View File

@@ -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);
@@ -232,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}
@@ -330,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
@@ -381,6 +382,7 @@
</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">
@@ -487,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(
@@ -498,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"

View File

@@ -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', {
@@ -581,6 +582,30 @@ export const tournamentGroupEntryRelations = relations(tournamentGroupEntry, ({
team: one(team, { fields: [tournamentGroupEntry.teamId], references: [team.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 }) => ({ export const tournamentQualifierScoreRelations = relations(tournamentQualifierScore, ({ one }) => ({
tournament: one(tournament, { tournament: one(tournament, {
fields: [tournamentQualifierScore.tournamentId], fields: [tournamentQualifierScore.tournamentId],

View File

@@ -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;

View File

@@ -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', () => ({

View 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])
};

View File

@@ -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));

View File

@@ -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
View 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))
};
}

View File

@@ -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}

View File

@@ -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
} }
}; };

View File

@@ -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}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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', async function loadPlayers(params: ReturnType<typeof parseTableParams>) {
image: u?.image ?? null, const [rows, countResult] = await Promise.all([
elo: r.elo, db
wins: r.wins, .select({
losses: r.losses, userId: playerRating.userId,
winRate: r.wins + r.losses > 0 ? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1) : '—' 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)
]);
// ── Top teams by match wins ── const topPlayers = rows.map((r, i) => ({
// Count wins for each team from matchParticipant scores ...r,
const teamWins = await db 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))
}; };
}; }

View File

@@ -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}

View File

@@ -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}`);

View File

@@ -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>

View File

@@ -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();

View File

@@ -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' };
} }

View File

@@ -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}
&middot; <span class="text-green-400">{liveMatches.length} live</span> &middot; <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}
&middot; {timeAgo(m.finishedAt)}{:else if m.createdAt}
&middot; {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>

View File

@@ -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' };
} }

View File

@@ -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 }

View File

@@ -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'
: ''}">&rsaquo;</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"
>&times;</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>

View File

@@ -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 };
}
};

View File

@@ -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>

View File

@@ -1,23 +1,60 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { tournament } from '$lib/server/db/schema'; import { tournament } from '$lib/server/db/schema';
import { desc } from 'drizzle-orm'; import { sql, and, inArray } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions'; import {
parseTableParams,
buildSearchFilter,
buildOrderBy,
buildTableMeta
} from '$lib/server/table';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } 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, [tournament.name]);
const tournaments = await db.query.tournament.findMany({ // 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: { with: {
registrations: { registrations: {
columns: { id: true, status: true } columns: { id: true, status: true }
},
staff: {
columns: { userId: true, role: true }
} }
}, },
orderBy: desc(tournament.createdAt) 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 }; return {
tournaments: rows,
meta: buildTableMeta(params, Number(countResult[0].count))
};
}; };

View File

@@ -1,4 +1,8 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import DataTable from '$lib/components/DataTable.svelte';
const { data } = $props(); const { data } = $props();
const formatLabels: Record<string, string> = { const formatLabels: Record<string, string> = {
@@ -17,31 +21,26 @@
CANCELLED: 'bg-red-900 text-red-300' CANCELLED: 'bg-red-900 text-red-300'
}; };
let filter = $state('all'); const currentFilter = $derived(new URL($page.url).searchParams.get('filter') ?? 'all');
const filtered = $derived( function setFilter(value: string) {
filter === 'all' const url = new URL($page.url);
? data.tournaments if (value === 'all') {
: data.tournaments.filter((t: any) => { url.searchParams.delete('filter');
if (filter === 'upcoming') } else {
return ['DRAFT', 'REGISTRATION', 'QUALIFIERS', 'SEEDING'].includes(t.state); url.searchParams.set('filter', value);
if (filter === 'ongoing') return t.state === 'BRACKET';
if (filter === 'finished') return t.state === 'FINISHED';
return true;
})
);
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`;
} }
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> </script>
<svelte:head> <svelte:head>
@@ -49,11 +48,11 @@
</svelte:head> </svelte:head>
<div class="mx-auto max-w-5xl"> <div class="mx-auto max-w-5xl">
<div class="flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<div> <div>
<h1 class="font-700 text-2xl tracking-tight">Tournaments</h1> <h1 class="font-700 text-2xl tracking-tight">Tournaments</h1>
<p class="mt-1 text-sm text-text-secondary"> <p class="mt-1 text-sm text-text-secondary">
{data.tournaments.length} tournament{data.tournaments.length !== 1 ? 's' : ''} {data.meta.total} tournament{data.meta.total !== 1 ? 's' : ''}
</p> </p>
</div> </div>
<a <a
@@ -65,67 +64,57 @@
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="mt-4 flex gap-1.5"> <div class="mb-4 flex gap-1.5">
{#each [['all', 'All'], ['upcoming', 'Upcoming'], ['ongoing', 'Ongoing'], ['finished', 'Finished']] as [value, label]} {#each [['all', 'All'], ['upcoming', 'Upcoming'], ['ongoing', 'Ongoing'], ['finished', 'Finished']] as [value, label]}
<button <button
class="font-600 rounded-md px-3 py-1.5 text-xs transition-colors {filter === value class="font-600 rounded-md px-3 py-1.5 text-xs transition-colors {currentFilter === value
? 'bg-accent text-surface-900' ? 'bg-accent text-surface-900'
: 'border border-border bg-surface-700 text-text-secondary hover:border-accent/40 hover:text-text-primary'}" : 'border border-border bg-surface-700 text-text-secondary hover:border-accent/40 hover:text-text-primary'}"
onclick={() => (filter = value)} onclick={() => setFilter(value)}
> >
{label} {label}
</button> </button>
{/each} {/each}
</div> </div>
<!-- Tournament List --> <DataTable
{#if filtered.length === 0} data={data.tournaments}
<div class="mt-6 rounded-lg border border-dashed border-border py-12 text-center"> meta={data.meta}
<p class="text-sm text-text-secondary">No tournaments found</p> {columns}
</div> searchPlaceholder="Search tournaments..."
{:else}
<div class="mt-6 flex flex-col gap-2">
{#each filtered as t (t.id)}
<a
href="/tournaments/{t.id}"
class="group flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-4 transition-colors hover:border-accent/30 hover:bg-surface-700"
> >
<div class="min-w-0 flex-1"> {#snippet row(t, _i)}
<div class="flex items-center gap-2"> {@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> <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 <span
class="font-500 rounded px-2 py-0.5 text-xs {stateColors[t.state] ?? class="font-500 rounded px-2 py-0.5 text-xs {stateColors[t.state] ??
'bg-surface-600 text-text-secondary'}" 'bg-surface-600 text-text-secondary'}"
> >
{t.state} {t.state}
</span> </span>
</div> </td>
{#if t.description} <td class="px-4 py-3 text-sm text-text-secondary">
<p class="mt-0.5 truncate text-xs text-text-secondary">{t.description}</p> {activeRegs}/{t.maxSlots}
{/if} </td>
<p class="mt-1 text-xs text-text-secondary"> <td class="hidden px-4 py-3 text-sm text-text-secondary sm:table-cell">
{formatLabels[t.format] ?? t.format} {new Date(t.createdAt).toLocaleDateString()}
&middot; {t.registrations.filter( </td>
(r: any) => r.status !== 'withdrawn' && r.status !== 'eliminated' </tr>
).length}/{t.maxSlots} players {/snippet}
{#if t.startAt} </DataTable>
&middot; starts {new Date(t.startAt).toLocaleDateString()}
{/if}
&middot; {timeAgo(t.createdAt)}
</p>
</div>
<svg
class="h-4 w-4 text-text-secondary transition-colors group-hover:text-text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
{/each}
</div>
{/if}
</div> </div>

View File

@@ -16,8 +16,6 @@ import { requireAuth } from '$lib/server/permissions';
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 t = await db.query.tournament.findFirst({ const t = await db.query.tournament.findFirst({
where: eq(tournament.id, params.id), where: eq(tournament.id, params.id),
with: { with: {
@@ -59,17 +57,18 @@ export const load: PageServerLoad = async ({ params, locals }) => {
}); });
// check if current user is staff // check if current user is staff
const isStaff = t.staff.some((s) => s.userId === locals.user!.id); const isStaff = locals.user ? t.staff.some((s) => s.userId === locals.user!.id) : false;
// check if current user is registered // check if current user is registered
const userTeams = await db.query.teamMember.findMany({ const userTeams = locals.user
where: eq(teamMember.userId, locals.user!.id), ? await db.query.teamMember.findMany({
where: eq(teamMember.userId, locals.user.id),
with: { team: true } with: { team: true }
}); })
: [];
const userRegistration = t.registrations.find((r) => const userRegistration = locals.user
userTeams.some((ut) => ut.teamId === r.teamId) ? t.registrations.find((r) => userTeams.some((ut) => ut.teamId === r.teamId))
); : undefined;
// load mappools for round config (staff only in DRAFT) // load mappools for round config (staff only in DRAFT)
let mappools: { id: string; name: string; slotCount: number }[] = []; let mappools: { id: string; name: string; slotCount: number }[] = [];

View File

@@ -4,7 +4,7 @@ import { createTournament } from '$lib/server/tournament/lifecycle';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
requireAuth(locals); if (!locals.user) redirect(302, '/login');
return {}; return {};
}; };

View 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 };
}
};

View 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>

View File

@@ -1,7 +1,7 @@
import { json } 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, requireRole } from '$lib/server/permissions'; import { requireAuth, requirePermission, GlobalPermission } from '$lib/server/permissions';
import { createMappoolSchema, parseBody } from '$lib/server/validation'; import { createMappoolSchema, parseBody } from '$lib/server/validation';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
@@ -18,7 +18,11 @@ export const GET: RequestHandler = async ({ locals }) => {
// Create a mappool (admin only) // Create a mappool (admin only)
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
requireRole(locals, 'admin', 'Only admins can create mappools'); requirePermission(
locals,
GlobalPermission.MAPPOOL_CREATE,
'Only authorized users can create mappools'
);
const body = await request.json(); const body = await request.json();
const { name } = parseBody(createMappoolSchema, body); const { name } = parseBody(createMappoolSchema, body);

View File

@@ -2,7 +2,7 @@
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, requireRole } from '$lib/server/permissions'; import { requireAuth, requirePermission, GlobalPermission } from '$lib/server/permissions';
import { createMatchSchema, parseBody } from '$lib/server/validation'; import { createMatchSchema, parseBody } from '$lib/server/validation';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
@@ -20,7 +20,11 @@ export const GET: RequestHandler = async ({ locals }) => {
}; };
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
requireRole(locals, 'referee', 'Only referees or admins can create matches'); 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 } = parseBody(createMatchSchema, body); const { name, config, mappoolId, teams } = parseBody(createMatchSchema, body);

View File

@@ -1,11 +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 { requireRole } from '$lib/server/permissions'; 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 }) => {
requireRole(locals, 'referee', 'Only referees or admins can move matches to lobby'); 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(() => ({}));

View File

@@ -1,7 +1,7 @@
// 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, hasRole } from '$lib/server/permissions'; import { requireAuth, hasPermission, GlobalPermission } from '$lib/server/permissions';
import { getMatchFull } from '$lib/server/match/helpers'; import { getMatchFull } from '$lib/server/match/helpers';
import { pickMapSchema, parseBody } from '$lib/server/validation'; import { pickMapSchema, parseBody } from '$lib/server/validation';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
@@ -17,7 +17,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
// Find the participant linked to the authenticated user // Find the participant linked to the authenticated user
const participant = match.participants.find((p) => p.players.some((pl) => pl.userId === user.id)); const participant = match.participants.find((p) => p.players.some((pl) => pl.userId === user.id));
if (!participant && !hasRole(user.role, 'referee')) { if (!participant && !hasPermission(user.role, GlobalPermission.MATCH_REFEREE)) {
error(403, 'You are not a participant in this match'); error(403, 'You are not a participant in this match');
} }

View File

@@ -1,7 +1,7 @@
// 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, hasRole } from '$lib/server/permissions'; import { requireAuth, hasPermission, GlobalPermission } from '$lib/server/permissions';
import { getMatchFull } from '$lib/server/match/helpers'; import { getMatchFull } from '$lib/server/match/helpers';
import { submitRollSchema, parseBody } from '$lib/server/validation'; import { submitRollSchema, parseBody } from '$lib/server/validation';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
@@ -17,7 +17,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
// Find the participant linked to the authenticated user // Find the participant linked to the authenticated user
const participant = match.participants.find((p) => p.players.some((pl) => pl.userId === user.id)); const participant = match.participants.find((p) => p.players.some((pl) => pl.userId === user.id));
if (!participant && !hasRole(user.role, 'referee')) { if (!participant && !hasPermission(user.role, GlobalPermission.MATCH_REFEREE)) {
error(403, 'You are not a participant in this match'); error(403, 'You are not a participant in this match');
} }

View File

@@ -1,11 +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 { requireRole } from '$lib/server/permissions'; 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 }) => {
requireRole(locals, 'referee', 'Only referees or admins can move matches to rolling'); 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);

View File

@@ -1,12 +1,16 @@
// 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 { requireRole } from '$lib/server/permissions'; import { requirePermission, GlobalPermission } from '$lib/server/permissions';
import { submitScoreSchema, parseBody } from '$lib/server/validation'; 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 }) => {
requireRole(locals, 'referee', 'Only referees or admins can submit scores'); requirePermission(
locals,
GlobalPermission.MATCH_REFEREE,
'Only referees or admins can submit scores'
);
const body = await request.json(); const body = await request.json();
const { matchGameId, scores } = parseBody(submitScoreSchema, body); const { matchGameId, scores } = parseBody(submitScoreSchema, body);

View 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}` }))
}
});
};

View File

@@ -1,8 +1,8 @@
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 { tournament, tournamentStaff } from '$lib/server/db/schema'; import { tournament } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions'; import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
import { getTournamentFull } from '$lib/server/tournament/lifecycle'; import { getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
@@ -13,8 +13,7 @@ export const GET: RequestHandler = async ({ params }) => {
}; };
export const PATCH: RequestHandler = async ({ params, request, locals }) => { export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_SETTINGS);
await requireTournamentStaff(params.id, user.id);
const t = await db.query.tournament.findFirst({ const t = await db.query.tournament.findFirst({
where: eq(tournament.id, params.id) where: eq(tournament.id, params.id)
@@ -55,8 +54,7 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
}; };
export const DELETE: RequestHandler = async ({ params, locals }) => { export const DELETE: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_SETTINGS);
await requireTournamentStaff(params.id, user.id);
const t = await db.query.tournament.findFirst({ const t = await db.query.tournament.findFirst({
where: eq(tournament.id, params.id) where: eq(tournament.id, params.id)
@@ -73,11 +71,3 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
await cancelTournament(params.id); await cancelTournament(params.id);
return json(await getTournamentFull(params.id)); return json(await getTournamentFull(params.id));
}; };
async function requireTournamentStaff(tournamentId: string, userId: string) {
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');
return staff;
}

View File

@@ -1,18 +1,10 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db'; import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { cancelTournament, getTournamentFull } from '$lib/server/tournament/lifecycle'; import { cancelTournament, getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, locals }) => { export const POST: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_SETTINGS);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
try { try {
await cancelTournament(params.id); await cancelTournament(params.id);

View File

@@ -1,18 +1,10 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db'; import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { closeRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle'; import { closeRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, locals }) => { export const POST: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_REGISTRATIONS);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
try { try {
await closeRegistration(params.id); await closeRegistration(params.id);

View File

@@ -1,18 +1,10 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db'; import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { finalizeSeeding, getTournamentFull } from '$lib/server/tournament/lifecycle'; import { finalizeSeeding, getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, locals }) => { export const POST: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_BRACKET);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
try { try {
await finalizeSeeding(params.id); await finalizeSeeding(params.id);

View File

@@ -1,18 +1,10 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db'; import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { openRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle'; import { openRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, locals }) => { export const POST: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_REGISTRATIONS);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
try { try {
await openRegistration(params.id); await openRegistration(params.id);

View File

@@ -4,10 +4,7 @@ import {
getQualifierMappool, getQualifierMappool,
submitQualifierScore submitQualifierScore
} from '$lib/server/tournament/qualifiers'; } from '$lib/server/tournament/qualifiers';
import { db } from '$lib/server/db'; import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
@@ -20,13 +17,7 @@ export const GET: RequestHandler = async ({ params }) => {
}; };
export const POST: RequestHandler = async ({ params, request, locals }) => { export const POST: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.REFEREE_MATCHES);
// only staff can submit qualifier scores (referees run qualifier lobbies)
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Only tournament staff can submit qualifier scores');
const body = await request.json(); const body = await request.json();
const { teamId, mappoolSlotId, totalScore, accuracy } = body; const { teamId, mappoolSlotId, totalScore, accuracy } = body;

View File

@@ -1,8 +1,8 @@
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 { tournamentRound, tournamentStaff } from '$lib/server/db/schema'; import { tournamentRound } from '$lib/server/db/schema';
import { eq, and, asc } from 'drizzle-orm'; import { eq, asc } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions'; import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
@@ -15,19 +15,13 @@ export const GET: RequestHandler = async ({ params }) => {
}; };
export const POST: RequestHandler = async ({ params, request, locals }) => { export const POST: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_ROUNDS);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
const body = await request.json(); const body = await request.json();
const { name, abbreviation, bestOf, mappoolId, bracketType, scheduledAt } = body; const { name, abbreviation, bestOf, mappoolId, bracketType, scheduledAt } = body;
if (!name || !bestOf) error(400, 'Missing required fields: name, bestOf'); if (!name || !bestOf) error(400, 'Missing required fields: name, bestOf');
// get next round order
const existing = await db.query.tournamentRound.findMany({ const existing = await db.query.tournamentRound.findMany({
where: eq(tournamentRound.tournamentId, params.id) where: eq(tournamentRound.tournamentId, params.id)
}); });

View File

@@ -1,17 +1,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 { tournamentRound, tournamentStaff } from '$lib/server/db/schema'; import { tournamentRound } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions'; import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const PATCH: RequestHandler = async ({ params, request, locals }) => { export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_ROUNDS);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
const body = await request.json(); const body = await request.json();
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
@@ -38,12 +33,7 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
}; };
export const DELETE: RequestHandler = async ({ params, locals }) => { export const DELETE: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_ROUNDS);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
await db.delete(tournamentRound).where(eq(tournamentRound.id, params.roundId)); await db.delete(tournamentRound).where(eq(tournamentRound.id, params.roundId));
return json({ deleted: true }); return json({ deleted: true });

View File

@@ -1,8 +1,8 @@
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 { tournamentStaff } from '$lib/server/db/schema'; import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions'; import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
@@ -13,15 +13,7 @@ export const GET: RequestHandler = async ({ params }) => {
}; };
export const POST: RequestHandler = async ({ params, request, locals }) => { export const POST: RequestHandler = async ({ params, request, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_STAFF);
// only organizers can add staff
const actor = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!actor || (actor.role !== 'organizer' && actor.role !== 'admin')) {
error(403, 'Only organizers can manage staff');
}
const body = await request.json(); const body = await request.json();
if (!body.userId || !body.role) error(400, 'Missing userId or role'); if (!body.userId || !body.role) error(400, 'Missing userId or role');
@@ -41,7 +33,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
}) })
.returning(); .returning();
return json(created, { status: 201 }); return json(created, { status: 201 });
} catch (e: any) { } catch {
error(400, 'User is already a staff member'); error(400, 'User is already a staff member');
} }
}; };

View File

@@ -1,20 +1,12 @@
import { json, error } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { tournamentStaff } from '$lib/server/db/schema'; import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions'; import { requireTournamentStaffOrAdmin, TournamentPermission } 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 }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_STAFF);
const actor = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!actor || (actor.role !== 'organizer' && actor.role !== 'admin')) {
error(403, 'Only organizers can manage staff');
}
await db.delete(tournamentStaff).where(eq(tournamentStaff.id, params.staffId)); await db.delete(tournamentStaff).where(eq(tournamentStaff.id, params.staffId));
return json({ deleted: true }); return json({ deleted: true });
}; };

View File

@@ -1,21 +1,11 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db'; import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
import { tournamentStaff } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '$lib/server/permissions';
import { getTournamentFull } from '$lib/server/tournament/lifecycle'; import { getTournamentFull } from '$lib/server/tournament/lifecycle';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, locals }) => { export const POST: RequestHandler = async ({ params, locals }) => {
const user = requireAuth(locals); await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_BRACKET);
const staff = await db.query.tournamentStaff.findFirst({
where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id))
});
if (!staff) error(403, 'Not a staff member of this tournament');
// qualifiers are started via closeRegistration when qualifiers are enabled
// this endpoint is for manually triggering qualifier finalization
const { finalizeQualifiers } = await import('$lib/server/tournament/qualifiers'); const { finalizeQualifiers } = await import('$lib/server/tournament/qualifiers');
try { try {