feat: add search and data tables
This commit is contained in:
@@ -33,6 +33,10 @@ if (!building) {
|
||||
.then(({ startTournamentScheduler }) => startTournamentScheduler())
|
||||
.catch((err) => log.tournament.warn({ err: err.message }, 'Tournament scheduler init skipped'));
|
||||
|
||||
import('$lib/server/discord/client')
|
||||
.then(({ initDiscordClient }) => initDiscordClient())
|
||||
.catch((err) => log.discord.warn({ err: err.message }, 'Discord client init skipped'));
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log.http.error({ err: reason }, 'Unhandled rejection');
|
||||
});
|
||||
|
||||
222
src/lib/components/DataTable.svelte
Normal file
222
src/lib/components/DataTable.svelte
Normal file
@@ -0,0 +1,222 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { TableMeta } from '$lib/server/table';
|
||||
|
||||
interface Column {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
meta,
|
||||
columns,
|
||||
row,
|
||||
searchPlaceholder = 'Search...'
|
||||
}: {
|
||||
data: T[];
|
||||
meta: TableMeta;
|
||||
columns: Column[];
|
||||
row: Snippet<[T, number]>;
|
||||
searchPlaceholder?: string;
|
||||
} = $props();
|
||||
|
||||
let searchInput = $derived.by(() => meta.search);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const offset = $derived((meta.page - 1) * meta.limit);
|
||||
const showingStart = $derived(meta.total === 0 ? 0 : offset + 1);
|
||||
const showingEnd = $derived(Math.min(offset + meta.limit, meta.total));
|
||||
|
||||
const pageButtons = $derived.by(() => {
|
||||
const total = meta.totalPages;
|
||||
const current = meta.page;
|
||||
const pages: (number | '...')[] = [];
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (current > 3) pages.push('...');
|
||||
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (current < total - 2) pages.push('...');
|
||||
pages.push(total);
|
||||
}
|
||||
return pages;
|
||||
});
|
||||
|
||||
function updateParams(updates: Record<string, string>) {
|
||||
const url = new URL(page.url);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
} else {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
// reset to page 1 when changing search/sort/filter
|
||||
if (!('page' in updates)) {
|
||||
url.searchParams.set('page', '1');
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
updateParams({ search: searchInput });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function toggleSort(key: string) {
|
||||
if (meta.sortBy === key) {
|
||||
updateParams({ sortBy: key, sortDir: meta.sortDir === 'asc' ? 'desc' : 'asc' });
|
||||
} else {
|
||||
updateParams({ sortBy: key, sortDir: 'desc' });
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(p: number) {
|
||||
updateParams({ page: String(p) });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- search bar -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="relative flex-1">
|
||||
<svg
|
||||
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-text-secondary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
bind:value={searchInput}
|
||||
oninput={handleSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
class="w-full rounded-md border border-border bg-surface-800 py-2 pr-3 pl-9 text-sm text-text-primary transition-colors outline-none placeholder:text-text-secondary focus:border-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-text-secondary"
|
||||
>{meta.total} result{meta.total !== 1 ? 's' : ''}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- table -->
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-surface-800">
|
||||
{#each columns as col}
|
||||
<th
|
||||
class="{col.class ??
|
||||
'px-4 text-left'} py-3 text-[11px] font-semibold tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
{#if col.sortable}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 transition-colors hover:text-text-primary"
|
||||
onclick={() => toggleSort(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
{#if meta.sortBy === col.key}
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
>
|
||||
{#if meta.sortDir === 'asc'}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||
{:else}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
{/if}
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-3 w-3 opacity-30"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
{col.label}
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/50">
|
||||
{#each data as item, i}
|
||||
{@render row(item, i)}
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan={columns.length} class="px-4 py-8 text-center text-sm text-text-secondary">
|
||||
{meta.search ? `No results for "${meta.search}"` : 'No data'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- pagination -->
|
||||
{#if meta.totalPages > 1}
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-xs text-text-secondary">
|
||||
Showing {showingStart}–{showingEnd} of {meta.total}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
disabled={meta.page <= 1}
|
||||
onclick={() => goToPage(meta.page - 1)}
|
||||
class="rounded-md px-2.5 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-700 hover:text-text-primary disabled:pointer-events-none disabled:opacity-30"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{#each pageButtons as p}
|
||||
{#if p === '...'}
|
||||
<span class="px-1 text-xs text-text-secondary">...</span>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => goToPage(p as number)}
|
||||
class="rounded-md px-2.5 py-1.5 text-xs transition-colors {p === meta.page
|
||||
? 'bg-accent font-semibold text-surface-900'
|
||||
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
<button
|
||||
disabled={meta.page >= meta.totalPages}
|
||||
onclick={() => goToPage(meta.page + 1)}
|
||||
class="rounded-md px-2.5 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-700 hover:text-text-primary disabled:pointer-events-none disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
114
src/lib/components/ProfileComments.svelte
Normal file
114
src/lib/components/ProfileComments.svelte
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string | Date;
|
||||
author: { id: string; name: string; image: string | null };
|
||||
};
|
||||
|
||||
const { comments, actionUrl }: { comments: Comment[]; actionUrl: string } = $props();
|
||||
|
||||
const user = $derived(page.data?.user);
|
||||
let content = $state('');
|
||||
let posting = $state(false);
|
||||
|
||||
function timeAgo(date: string | Date) {
|
||||
const d = new Date(date);
|
||||
const diff = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-6">
|
||||
<h2 class="font-600 text-sm text-text-secondary">Comments</h2>
|
||||
|
||||
{#if user}
|
||||
<form
|
||||
method="POST"
|
||||
action={actionUrl}
|
||||
use:enhance={() => {
|
||||
posting = true;
|
||||
return async ({ result, update }) => {
|
||||
posting = false;
|
||||
if (result.type === 'success') {
|
||||
content = '';
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="mt-3"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
{#if user.image}
|
||||
<img src={user.image} alt="" class="h-8 w-8 shrink-0 rounded-full" />
|
||||
{:else}
|
||||
<div class="h-8 w-8 shrink-0 rounded-full bg-surface-600"></div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<textarea
|
||||
name="content"
|
||||
bind:value={content}
|
||||
placeholder="Leave a comment..."
|
||||
rows="2"
|
||||
class="w-full resize-none rounded-lg border border-border bg-surface-800 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||
></textarea>
|
||||
<div class="mt-1.5 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={posting || !content.trim()}
|
||||
class="font-600 rounded-md bg-accent px-3 py-1.5 text-xs text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{posting ? 'Posting...' : 'Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="mt-3 text-sm text-text-secondary">Sign in to leave a comment.</p>
|
||||
{/if}
|
||||
|
||||
{#if comments.length > 0}
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
{#each comments as comment}
|
||||
<div class="flex gap-3">
|
||||
{#if comment.author.image}
|
||||
<a href="/users/{comment.author.id}" class="shrink-0">
|
||||
<img src={comment.author.image} alt="" class="h-8 w-8 rounded-full" />
|
||||
</a>
|
||||
{:else}
|
||||
<a href="/users/{comment.author.id}" class="shrink-0">
|
||||
<div
|
||||
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||
>
|
||||
{comment.author.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/users/{comment.author.id}" class="font-600 text-sm hover:text-accent"
|
||||
>{comment.author.name}</a
|
||||
>
|
||||
<span class="text-[10px] text-text-secondary/50">{timeAgo(comment.createdAt)}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-sm break-words whitespace-pre-wrap text-text-secondary">
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !user}
|
||||
<p class="mt-4 text-sm text-text-secondary/50">No comments yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
253
src/lib/components/SearchModal.svelte
Normal file
253
src/lib/components/SearchModal.svelte
Normal file
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type: string;
|
||||
href: string;
|
||||
image?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
state?: string | null;
|
||||
}
|
||||
|
||||
let { open = $bindable(false) }: { open: boolean } = $props();
|
||||
let query = $state('');
|
||||
let results = $state<Record<string, SearchResult[]>>({});
|
||||
let loading = $state(false);
|
||||
let selectedIndex = $state(0);
|
||||
let inputRef = $state<HTMLInputElement | null>(null);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const flatResults = $derived(Object.values(results).flat());
|
||||
|
||||
const typeIcons: Record<string, string> = {
|
||||
users: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||
teams:
|
||||
'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
mappools:
|
||||
'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3',
|
||||
tournaments:
|
||||
'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
|
||||
matches:
|
||||
'M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
users: 'Users',
|
||||
teams: 'Teams',
|
||||
mappools: 'Mappools',
|
||||
tournaments: 'Tournaments',
|
||||
matches: 'Matches'
|
||||
};
|
||||
|
||||
function openModal() {
|
||||
open = true;
|
||||
query = '';
|
||||
results = {};
|
||||
selectedIndex = 0;
|
||||
}
|
||||
|
||||
// Focus input whenever modal opens (whether via cmd+k or click)
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
// wait for DOM to render the modal
|
||||
setTimeout(() => inputRef?.focus(), 10);
|
||||
}
|
||||
});
|
||||
|
||||
function closeModal() {
|
||||
open = false;
|
||||
query = '';
|
||||
results = {};
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (open) {
|
||||
closeModal();
|
||||
} else {
|
||||
openModal();
|
||||
}
|
||||
} else if (e.key === 'Escape' && open) {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function handleModalKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, flatResults.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||
} else if (e.key === 'Enter' && flatResults[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
navigateTo(flatResults[selectedIndex].href);
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(href: string) {
|
||||
closeModal();
|
||||
goto(href);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (query.length < 2) {
|
||||
results = {};
|
||||
return;
|
||||
}
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
results = data.results;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
selectedIndex = 0;
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// compute a flat index for each item to support keyboard selection
|
||||
function getFlatIndex(type: string, itemIndex: number): number {
|
||||
const types = Object.keys(results);
|
||||
let idx = 0;
|
||||
for (const t of types) {
|
||||
if (t === type) return idx + itemIndex;
|
||||
idx += results[t].length;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||
|
||||
{#if open}
|
||||
<!-- backdrop -->
|
||||
<button
|
||||
class="fixed inset-0 z-[60] bg-black/60 backdrop-blur-sm"
|
||||
onclick={closeModal}
|
||||
aria-label="Close search"
|
||||
tabindex="-1"
|
||||
></button>
|
||||
|
||||
<!-- modal -->
|
||||
<div
|
||||
class="fixed top-[15%] left-1/2 z-[60] w-full max-w-lg -translate-x-1/2 rounded-xl border border-border bg-surface-800 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-label="Search"
|
||||
onkeydown={handleModalKeydown}
|
||||
>
|
||||
<!-- search input -->
|
||||
<div class="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0 text-text-secondary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
bind:this={inputRef}
|
||||
bind:value={query}
|
||||
placeholder="Search users, teams, tournaments..."
|
||||
class="flex-1 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-secondary"
|
||||
/>
|
||||
{#if loading}
|
||||
<div
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-text-secondary border-t-transparent"
|
||||
></div>
|
||||
{:else}
|
||||
<kbd
|
||||
class="rounded bg-surface-700 px-1.5 py-0.5 text-[10px] font-medium text-text-secondary"
|
||||
>ESC</kbd
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- results -->
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
{#if query.length < 2}
|
||||
<div class="px-4 py-8 text-center text-sm text-text-secondary">
|
||||
Type at least 2 characters to search
|
||||
</div>
|
||||
{:else if !loading && flatResults.length === 0 && query.length >= 2}
|
||||
<div class="px-4 py-8 text-center text-sm text-text-secondary">
|
||||
No results for "{query}"
|
||||
</div>
|
||||
{:else}
|
||||
{#each Object.entries(results) as [type, items]}
|
||||
{#if items.length > 0}
|
||||
<div class="px-4 pt-3 pb-1">
|
||||
<span class="text-[10px] font-semibold tracking-wider text-text-secondary uppercase"
|
||||
>{typeLabels[type] ?? type}</span
|
||||
>
|
||||
</div>
|
||||
{#each items as item, i}
|
||||
{@const flatIdx = getFlatIndex(type, i)}
|
||||
<button
|
||||
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm transition-colors {flatIdx ===
|
||||
selectedIndex
|
||||
? 'bg-surface-700 text-text-primary'
|
||||
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
|
||||
onclick={() => navigateTo(item.href)}
|
||||
onmouseenter={() => (selectedIndex = flatIdx)}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={typeIcons[type] ?? ''} />
|
||||
</svg>
|
||||
{#if item.image || item.avatarUrl}
|
||||
<img src={item.image ?? item.avatarUrl} alt="" class="h-5 w-5 rounded-full" />
|
||||
{/if}
|
||||
<span class="flex-1 truncate">{item.name ?? 'Unnamed'}</span>
|
||||
{#if item.state}
|
||||
<span
|
||||
class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-medium text-text-secondary"
|
||||
>{item.state}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- footer -->
|
||||
<div
|
||||
class="flex items-center gap-4 border-t border-border px-4 py-2 text-[10px] text-text-secondary"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<kbd class="rounded bg-surface-700 px-1 py-0.5 font-mono">↑↓</kbd> navigate
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<kbd class="rounded bg-surface-700 px-1 py-0.5 font-mono">↵</kbd> select
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<kbd class="rounded bg-surface-700 px-1 py-0.5 font-mono">esc</kbd> close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -6,7 +6,7 @@
|
||||
const activeMatch = $derived(page.data?.activeMatch);
|
||||
const 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 notifications = $state<any[]>([]);
|
||||
let loadingNotifs = $state(false);
|
||||
@@ -232,7 +232,7 @@
|
||||
<span class="font-700 text-base font-bold tracking-tight text-text-primary">Vash Esports</span
|
||||
>
|
||||
</a>
|
||||
{#if user}
|
||||
{#if user && !guest}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={toggleNotifications}
|
||||
@@ -330,6 +330,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Queue Button -->
|
||||
{#if !guest}
|
||||
<div class="px-3 pb-4">
|
||||
{#if activeMatch && page.url.pathname !== `/matches/${activeMatch.id}`}
|
||||
<a
|
||||
@@ -381,6 +382,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="mt-0 flex flex-1 flex-col gap-0.5 px-3">
|
||||
@@ -487,6 +489,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if !guest}
|
||||
<a
|
||||
href="/settings"
|
||||
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')}
|
||||
Settings
|
||||
</a>
|
||||
{/if}
|
||||
<div class="mt-2 flex gap-3 px-3 pb-1">
|
||||
<a
|
||||
href="/terms"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export * from './auth.schema';
|
||||
import { user } from './auth.schema';
|
||||
|
||||
// ── Teams ───────────────────────────────────────────────────────────────
|
||||
export const team = pgTable('team', {
|
||||
@@ -581,6 +582,30 @@ export const tournamentGroupEntryRelations = relations(tournamentGroupEntry, ({
|
||||
team: one(team, { fields: [tournamentGroupEntry.teamId], references: [team.id] })
|
||||
}));
|
||||
|
||||
// ── Profile Comments ────────────────────────────────────────────────────
|
||||
export const profileComment = pgTable(
|
||||
'profile_comment',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// 'user' or 'team'
|
||||
targetType: text('target_type').notNull(),
|
||||
targetId: text('target_id').notNull(),
|
||||
authorId: text('author_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
content: text('content').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(t) => [
|
||||
index('idx_profile_comment_target').on(t.targetType, t.targetId),
|
||||
index('idx_profile_comment_author').on(t.authorId)
|
||||
]
|
||||
);
|
||||
|
||||
export const profileCommentRelations = relations(profileComment, ({ one }) => ({
|
||||
author: one(user, { fields: [profileComment.authorId], references: [user.id] })
|
||||
}));
|
||||
|
||||
export const tournamentQualifierScoreRelations = relations(tournamentQualifierScore, ({ one }) => ({
|
||||
tournament: one(tournament, {
|
||||
fields: [tournamentQualifierScore.tournamentId],
|
||||
|
||||
@@ -34,6 +34,15 @@ async function getChannel(channelId: string): Promise<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: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
|
||||
@@ -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
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
@@ -10,7 +10,8 @@ vi.mock('$lib/server/db', () => ({
|
||||
}));
|
||||
|
||||
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
|
||||
vi.mock('@sveltejs/kit', () => ({
|
||||
|
||||
74
src/lib/server/permissions/constants.ts
Normal file
74
src/lib/server/permissions/constants.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// ── Global Permissions ──────────────────────────────────────────────────
|
||||
export const GlobalPermission = {
|
||||
// Match
|
||||
MATCH_CREATE: 'match:create',
|
||||
MATCH_REFEREE: 'match:referee',
|
||||
MATCH_CANCEL_ANY: 'match:cancel_any',
|
||||
|
||||
// Mappool
|
||||
MAPPOOL_CREATE: 'mappool:create',
|
||||
MAPPOOL_DELETE_ANY: 'mappool:delete_any',
|
||||
|
||||
// Tournament
|
||||
TOURNAMENT_CREATE: 'tournament:create',
|
||||
|
||||
// Admin
|
||||
ADMIN_VIEW: 'admin:view',
|
||||
ADMIN_MANAGE_USERS: 'admin:manage_users',
|
||||
ADMIN_MANAGE_ROLES: 'admin:manage_roles',
|
||||
ADMIN_CANCEL_MATCHES: 'admin:cancel_matches',
|
||||
ADMIN_CLEAR_QUEUE: 'admin:clear_queue',
|
||||
ADMIN_RESET_RATINGS: 'admin:reset_ratings',
|
||||
ADMIN_CLEAR_DATA: 'admin:clear_data'
|
||||
} as const;
|
||||
|
||||
export type GlobalPermission = (typeof GlobalPermission)[keyof typeof GlobalPermission];
|
||||
|
||||
const PLAYER_PERMISSIONS: Set<GlobalPermission> = new Set([GlobalPermission.MAPPOOL_CREATE]);
|
||||
|
||||
const REFEREE_PERMISSIONS: Set<GlobalPermission> = new Set([
|
||||
...PLAYER_PERMISSIONS,
|
||||
GlobalPermission.MATCH_CREATE,
|
||||
GlobalPermission.MATCH_REFEREE,
|
||||
GlobalPermission.TOURNAMENT_CREATE
|
||||
]);
|
||||
|
||||
const ADMIN_PERMISSIONS: Set<GlobalPermission> = new Set(
|
||||
Object.values(GlobalPermission) as GlobalPermission[]
|
||||
);
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<string, Set<GlobalPermission>> = {
|
||||
player: PLAYER_PERMISSIONS,
|
||||
referee: REFEREE_PERMISSIONS,
|
||||
admin: ADMIN_PERMISSIONS
|
||||
};
|
||||
|
||||
// ── Tournament-scoped Permissions ──────────────────────────────────────
|
||||
export const TournamentPermission = {
|
||||
MANAGE_SETTINGS: 'tournament:manage_settings',
|
||||
MANAGE_STAFF: 'tournament:manage_staff',
|
||||
MANAGE_ROUNDS: 'tournament:manage_rounds',
|
||||
MANAGE_MAPPOOL: 'tournament:manage_mappool',
|
||||
MANAGE_REGISTRATIONS: 'tournament:manage_registrations',
|
||||
MANAGE_BRACKET: 'tournament:manage_bracket',
|
||||
REFEREE_MATCHES: 'tournament:referee_matches',
|
||||
STREAM: 'tournament:stream'
|
||||
} as const;
|
||||
|
||||
export type TournamentPermission = (typeof TournamentPermission)[keyof typeof TournamentPermission];
|
||||
|
||||
export const TOURNAMENT_ROLE_PERMISSIONS: Record<string, Set<TournamentPermission>> = {
|
||||
organizer: new Set(Object.values(TournamentPermission) as TournamentPermission[]),
|
||||
admin: new Set([
|
||||
TournamentPermission.MANAGE_SETTINGS,
|
||||
TournamentPermission.MANAGE_STAFF,
|
||||
TournamentPermission.MANAGE_ROUNDS,
|
||||
TournamentPermission.MANAGE_MAPPOOL,
|
||||
TournamentPermission.MANAGE_REGISTRATIONS,
|
||||
TournamentPermission.MANAGE_BRACKET,
|
||||
TournamentPermission.REFEREE_MATCHES
|
||||
]),
|
||||
referee: new Set([TournamentPermission.REFEREE_MATCHES]),
|
||||
pooler: new Set([TournamentPermission.MANAGE_MAPPOOL]),
|
||||
streamer: new Set([TournamentPermission.STREAM])
|
||||
};
|
||||
@@ -1,9 +1,19 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
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 {
|
||||
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';
|
||||
|
||||
const ROLE_HIERARCHY: Record<UserRole, number> = {
|
||||
@@ -12,27 +22,69 @@ const ROLE_HIERARCHY: Record<UserRole, number> = {
|
||||
admin: 2
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a role meets the minimum required level.
|
||||
*/
|
||||
// ── Permission-based checks (preferred) ────────────────────────────────
|
||||
|
||||
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 {
|
||||
const level = ROLE_HIERARCHY[userRole as UserRole] ?? 0;
|
||||
const required = ROLE_HIERARCHY[minRole];
|
||||
return level >= required;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** @deprecated Use requirePermission() for granular checks */
|
||||
export function requireRole(
|
||||
locals: App.Locals,
|
||||
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) {
|
||||
if (!locals.user) {
|
||||
error(401, 'Not logged in');
|
||||
@@ -56,9 +105,6 @@ export function requireAuth(locals: App.Locals) {
|
||||
return locals.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the current user owns the resource OR is an admin.
|
||||
*/
|
||||
export function requireOwnerOrAdmin(
|
||||
locals: App.Locals,
|
||||
ownerId: string | null | undefined,
|
||||
@@ -71,12 +117,13 @@ export function requireOwnerOrAdmin(
|
||||
return u;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote/demote a user's global role with full safety checks.
|
||||
* - Only admins can change roles
|
||||
* - Only root admin can promote TO admin or demote FROM admin
|
||||
* - Root admin cannot be demoted by anyone
|
||||
*/
|
||||
// ── Root 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();
|
||||
}
|
||||
|
||||
export async function setUserRole(
|
||||
actorLocals: App.Locals,
|
||||
targetUserId: string,
|
||||
@@ -93,17 +140,14 @@ export async function setUserRole(
|
||||
});
|
||||
if (!target) error(404, 'User not found');
|
||||
|
||||
// Protect root admin from demotion
|
||||
if (isRootAdmin(target.email)) {
|
||||
error(403, "Cannot change the root admin's role");
|
||||
}
|
||||
|
||||
// Only root admin can promote TO admin
|
||||
if (newRole === 'admin' && !isRootAdmin(actor.email)) {
|
||||
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)) {
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's role from the database.
|
||||
*/
|
||||
export async function getUserRole(userId: string): Promise<UserRole> {
|
||||
const u = await db.query.user.findFirst({
|
||||
where: eq(user.id, userId)
|
||||
@@ -121,9 +162,6 @@ export async function getUserRole(userId: string): Promise<UserRole> {
|
||||
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 }) {
|
||||
if (isRootAdmin(userRecord.email) && userRecord.role !== 'admin') {
|
||||
await db.update(user).set({ role: 'admin' }).where(eq(user.id, userRecord.id));
|
||||
@@ -77,3 +77,28 @@ export async function proxyImage(osuUrl: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload raw image data to R2 under the given key.
|
||||
* Returns the CDN URL or null if R2 is not configured.
|
||||
*/
|
||||
export async function uploadImage(
|
||||
key: string,
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
contentType: string
|
||||
): Promise<string | null> {
|
||||
if (!isConfigured()) return null;
|
||||
|
||||
try {
|
||||
const r2 = await getClient();
|
||||
const file = r2.file(key);
|
||||
await file.write(data, { type: contentType });
|
||||
knownKeys.add(key);
|
||||
const cdnUrl = `${env.R2_PUBLIC_URL}/${key}`;
|
||||
log.r2.info({ key }, 'uploaded');
|
||||
return cdnUrl;
|
||||
} catch (err: unknown) {
|
||||
log.r2.error({ err, key }, 'uploadImage failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
61
src/lib/server/table.ts
Normal file
61
src/lib/server/table.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ilike, or, asc, desc, type SQL, type Column } from 'drizzle-orm';
|
||||
|
||||
export interface TableParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
sortBy: string;
|
||||
sortDir: 'asc' | 'desc';
|
||||
filter: string;
|
||||
}
|
||||
|
||||
export interface TableMeta {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
search: string;
|
||||
sortBy: string;
|
||||
sortDir: 'asc' | 'desc';
|
||||
filter: string;
|
||||
}
|
||||
|
||||
export function parseTableParams(url: URL, defaults?: Partial<TableParams>): TableParams {
|
||||
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1'));
|
||||
const limit = Math.min(
|
||||
100,
|
||||
Math.max(1, parseInt(url.searchParams.get('limit') ?? String(defaults?.limit ?? 25)))
|
||||
);
|
||||
const search = url.searchParams.get('search')?.trim() ?? '';
|
||||
const sortBy = url.searchParams.get('sortBy') ?? defaults?.sortBy ?? '';
|
||||
const sortDir = url.searchParams.get('sortDir') === 'asc' ? 'asc' : 'desc';
|
||||
const filter = url.searchParams.get('filter') ?? defaults?.filter ?? '';
|
||||
return { page, limit, search, sortBy, sortDir, filter };
|
||||
}
|
||||
|
||||
export function buildSearchFilter(search: string, columns: Column[]): SQL | undefined {
|
||||
if (!search) return undefined;
|
||||
const pattern = `%${search}%`;
|
||||
const conditions = columns.map((col) => ilike(col, pattern));
|
||||
if (conditions.length === 0) return undefined;
|
||||
if (conditions.length === 1) return conditions[0];
|
||||
return or(...conditions);
|
||||
}
|
||||
|
||||
export function buildOrderBy(
|
||||
sortBy: string,
|
||||
sortDir: 'asc' | 'desc',
|
||||
columnMap: Record<string, Column | SQL>,
|
||||
fallback: Column | SQL
|
||||
): SQL {
|
||||
const col = columnMap[sortBy] ?? fallback;
|
||||
return sortDir === 'asc' ? asc(col) : desc(col);
|
||||
}
|
||||
|
||||
export function buildTableMeta(params: TableParams, total: number): TableMeta {
|
||||
return {
|
||||
...params,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / params.limit))
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import './layout.css';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import SearchModal from '$lib/components/SearchModal.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { startPolling, stopPolling } from '$lib/stores/queue.svelte';
|
||||
@@ -12,6 +13,7 @@
|
||||
const activeMatch = $derived(page.data?.activeMatch);
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
let searchOpen = $state(false);
|
||||
|
||||
const stateLabel: Record<string, string> = {
|
||||
CREATED: 'Starting',
|
||||
@@ -52,6 +54,26 @@
|
||||
<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>
|
||||
<Header />
|
||||
</header>
|
||||
@@ -86,16 +108,51 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<SearchModal bind:open={searchOpen} />
|
||||
{:else}
|
||||
<!-- Unauthenticated: clean layout, no sidebar -->
|
||||
<div class="min-h-dvh">
|
||||
<!-- Unauthenticated: clean layout with nav links -->
|
||||
<div class="flex min-h-dvh">
|
||||
<Sidebar bind:open={sidebarOpen} guest />
|
||||
<div class="flex flex-1 flex-col lg:ml-56">
|
||||
<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">
|
||||
<img src="/logo.png" alt="Vash Esports" class="h-8 w-8" />
|
||||
<span class="font-700 text-base tracking-tight text-text-primary">Vash Esports</span>
|
||||
</a>
|
||||
<button
|
||||
class="rounded-md p-2 text-text-secondary hover:bg-surface-700 hover:text-text-primary lg:hidden"
|
||||
onclick={() => (sidebarOpen = !sidebarOpen)}
|
||||
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
|
||||
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"
|
||||
@@ -103,8 +160,10 @@
|
||||
Sign in with osu!
|
||||
</a>
|
||||
</header>
|
||||
<main class="p-6">
|
||||
<main class="flex-1 p-4 lg:p-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<SearchModal bind:open={searchOpen} />
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// src/routes/(app)/+page.server.ts
|
||||
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 { user } from '$lib/server/db/auth.schema';
|
||||
import { getQueueStatus } from '$lib/server/match/queue';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
@@ -12,7 +13,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
authenticated: false as const,
|
||||
recentMatches: [],
|
||||
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 [
|
||||
recentMatches,
|
||||
liveMatches,
|
||||
[matchCount],
|
||||
[finishedCount],
|
||||
[tournamentCount],
|
||||
[userCount],
|
||||
[teamCount],
|
||||
[poolCount],
|
||||
[finishedCount],
|
||||
queueStatus
|
||||
] = await Promise.all([
|
||||
db.query.match.findMany({
|
||||
@@ -35,10 +37,11 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
with: { participants: { with: { team: true } }, mappool: { with: { slots: true } } },
|
||||
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(mappool),
|
||||
db.select({ count: count() }).from(match).where(eq(match.state, 'FINISHED')),
|
||||
getQueueStatus(locals.user.id)
|
||||
]);
|
||||
|
||||
@@ -49,9 +52,9 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
hasActiveMatch: !!queueStatus.matchedMatchId,
|
||||
activeMatchId: queueStatus.matchedMatchId ?? null,
|
||||
stats: {
|
||||
matches: matchCount.count,
|
||||
finished: finishedCount.count,
|
||||
teams: teamCount.count,
|
||||
completed: finishedCount.count,
|
||||
tournaments: tournamentCount.count,
|
||||
usersAndTeams: userCount.count + teamCount.count,
|
||||
mappools: poolCount.count
|
||||
}
|
||||
};
|
||||
|
||||
@@ -102,16 +102,16 @@
|
||||
<!-- Stats -->
|
||||
<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">
|
||||
<p class="font-800 text-2xl text-text-primary tabular-nums">{data.stats.matches}</p>
|
||||
<p class="mt-1 text-xs text-text-secondary">Total 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">Completed Matches</p>
|
||||
</div>
|
||||
<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="mt-1 text-xs text-text-secondary">Completed</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">Tournaments</p>
|
||||
</div>
|
||||
<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="mt-1 text-xs text-text-secondary">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">Total Users + Teams</p>
|
||||
</div>
|
||||
<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>
|
||||
@@ -207,127 +207,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-4 lg:grid-cols-3">
|
||||
<!-- Quick Actions -->
|
||||
<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">
|
||||
<!-- Recent Matches -->
|
||||
<div class="mt-8">
|
||||
<h2 class="font-600 text-sm">Recent Matches</h2>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
{#each data.recentMatches as m}
|
||||
@@ -389,13 +270,11 @@
|
||||
{:else}
|
||||
<div class="rounded-lg border border-dashed border-border py-8 text-center">
|
||||
<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>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -14,37 +14,68 @@ import {
|
||||
matchParticipantPlayer
|
||||
} from '$lib/server/db/schema';
|
||||
import { account } from '$lib/server/db/auth.schema';
|
||||
import { eq, and, desc, inArray, sql } from 'drizzle-orm';
|
||||
import { requireRole, setUserRole, isRootAdmin } from '$lib/server/permissions';
|
||||
import { eq, and, inArray, sql } from 'drizzle-orm';
|
||||
import {
|
||||
requirePermission,
|
||||
setUserRole,
|
||||
isRootAdmin,
|
||||
GlobalPermission
|
||||
} from '$lib/server/permissions';
|
||||
import { getUser as getOsuUser } from '$lib/server/osu/api';
|
||||
import {
|
||||
parseTableParams,
|
||||
buildSearchFilter,
|
||||
buildOrderBy,
|
||||
buildTableMeta
|
||||
} from '$lib/server/table';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
requireRole(locals, 'admin');
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
requirePermission(locals, GlobalPermission.ADMIN_VIEW);
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
orderBy: desc(user.createdAt)
|
||||
});
|
||||
const params = parseTableParams(url, { sortBy: 'createdAt', limit: 25 });
|
||||
const searchFilter = buildSearchFilter(params.search, [user.name, user.email]);
|
||||
|
||||
const usersWithRatings = await Promise.all(
|
||||
users.map(async (u) => {
|
||||
const rating = await db.query.playerRating.findFirst({
|
||||
where: eq(playerRating.userId, u.id)
|
||||
});
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
image: u.image,
|
||||
role: u.role ?? 'player',
|
||||
createdAt: u.createdAt,
|
||||
elo: rating?.elo ?? 1000,
|
||||
wins: rating?.wins ?? 0,
|
||||
losses: rating?.losses ?? 0,
|
||||
isRootAdmin: isRootAdmin(u.email)
|
||||
};
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
elo: playerRating.elo,
|
||||
wins: playerRating.wins,
|
||||
losses: playerRating.losses
|
||||
})
|
||||
);
|
||||
.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([
|
||||
db
|
||||
@@ -59,7 +90,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
]);
|
||||
|
||||
return {
|
||||
users: usersWithRatings,
|
||||
users,
|
||||
meta: buildTableMeta(params, Number(countResult[0].count)),
|
||||
actorIsRootAdmin: isRootAdmin(locals.user!.email),
|
||||
stats: {
|
||||
activeMatches: Number(activeMatches[0].count),
|
||||
@@ -71,7 +103,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
|
||||
export const actions: Actions = {
|
||||
setRole: async ({ request, locals }) => {
|
||||
requireRole(locals, 'admin');
|
||||
requirePermission(locals, GlobalPermission.ADMIN_MANAGE_ROLES);
|
||||
|
||||
const form = await request.formData();
|
||||
const userId = form.get('userId')?.toString();
|
||||
@@ -85,7 +117,6 @@ export const actions: Actions = {
|
||||
try {
|
||||
await setUserRole(locals, userId, role as any);
|
||||
} catch (e: any) {
|
||||
// setUserRole throws SvelteKit errors; extract the message
|
||||
return { error: e.body?.message ?? e.message ?? 'Failed to update role' };
|
||||
}
|
||||
|
||||
@@ -93,7 +124,7 @@ export const actions: Actions = {
|
||||
},
|
||||
|
||||
cancelAllMatches: async ({ locals }) => {
|
||||
requireRole(locals, 'admin');
|
||||
requirePermission(locals, GlobalPermission.ADMIN_CANCEL_MATCHES);
|
||||
|
||||
const cancelled = await db
|
||||
.update(match)
|
||||
@@ -110,7 +141,7 @@ export const actions: Actions = {
|
||||
},
|
||||
|
||||
clearQueue: async ({ locals }) => {
|
||||
requireRole(locals, 'admin');
|
||||
requirePermission(locals, GlobalPermission.ADMIN_CLEAR_QUEUE);
|
||||
const deleted = await db.delete(matchQueue).returning({ id: matchQueue.id });
|
||||
return {
|
||||
success: true,
|
||||
@@ -119,7 +150,7 @@ export const actions: Actions = {
|
||||
},
|
||||
|
||||
expireInvites: async ({ locals }) => {
|
||||
requireRole(locals, 'admin');
|
||||
requirePermission(locals, GlobalPermission.ADMIN_MANAGE_USERS);
|
||||
const expired = await db
|
||||
.update(matchInvite)
|
||||
.set({ status: 'expired' })
|
||||
@@ -132,7 +163,7 @@ export const actions: Actions = {
|
||||
},
|
||||
|
||||
clearNotifications: async ({ locals }) => {
|
||||
requireRole(locals, 'admin');
|
||||
requirePermission(locals, GlobalPermission.ADMIN_MANAGE_USERS);
|
||||
const deleted = await db.delete(notification).returning({ id: notification.id });
|
||||
return {
|
||||
success: true,
|
||||
@@ -141,7 +172,7 @@ export const actions: Actions = {
|
||||
},
|
||||
|
||||
resetRatings: async ({ locals }) => {
|
||||
requireRole(locals, 'admin');
|
||||
requirePermission(locals, GlobalPermission.ADMIN_RESET_RATINGS);
|
||||
|
||||
const allUsers = await db.query.user.findMany();
|
||||
let updated = 0;
|
||||
@@ -161,12 +192,10 @@ export const actions: Actions = {
|
||||
// API failure — treat as unranked
|
||||
}
|
||||
|
||||
// Unranked → rank 10,000,000 (yields 0 ELO)
|
||||
const effectiveRank = rank && rank > 0 ? rank : 10_000_000;
|
||||
const rawElo = 3500 - Math.log10(effectiveRank) * 500;
|
||||
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({
|
||||
where: eq(playerRating.userId, u.id)
|
||||
});
|
||||
@@ -205,7 +234,7 @@ export const actions: Actions = {
|
||||
},
|
||||
|
||||
clearMatchHistory: async ({ locals }) => {
|
||||
requireRole(locals, 'admin');
|
||||
requirePermission(locals, GlobalPermission.ADMIN_CLEAR_DATA);
|
||||
|
||||
await db.delete(matchGameScore);
|
||||
await db.delete(matchGame);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -15,17 +16,23 @@
|
||||
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(
|
||||
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>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="font-700 text-2xl tracking-tight">Admin Panel</h1>
|
||||
<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}
|
||||
<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
|
||||
@@ -35,30 +42,20 @@
|
||||
</div>
|
||||
|
||||
{#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 actionSuccess}
|
||||
<p class="mt-3 text-sm text-green-400">{actionSuccess}</p>
|
||||
<p class="mb-4 text-sm text-green-400">{actionSuccess}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="mt-6 rounded-lg border border-border bg-surface-800">
|
||||
<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}
|
||||
<DataTable data={data.users} meta={data.meta} {columns} searchPlaceholder="Search users...">
|
||||
{#snippet row(u, _i)}
|
||||
{@const badge = roleBadge[u.role] ?? roleBadge.player}
|
||||
{@const isProtected = u.isRootAdmin}
|
||||
{@const canChangeRole = !isProtected && (data.actorIsRootAdmin || u.role !== 'admin')}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<tr class="transition-colors hover:bg-surface-800/50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if u.image}
|
||||
<img src={u.image} alt="" class="h-8 w-8 rounded-full" />
|
||||
{:else}
|
||||
@@ -68,30 +65,32 @@
|
||||
{u.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Name -->
|
||||
<div class="min-w-0">
|
||||
<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}
|
||||
<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
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="truncate text-xs text-text-secondary">{u.email}</p>
|
||||
</div>
|
||||
|
||||
<!-- ELO -->
|
||||
<span class="font-600 text-sm text-text-primary tabular-nums">{u.elo}</span>
|
||||
|
||||
<!-- W/L -->
|
||||
<span class="text-xs text-text-secondary tabular-nums">
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-600 text-sm tabular-nums">{u.elo}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs tabular-nums">
|
||||
<span class="text-green-400">{u.wins}W</span> /
|
||||
<span class="text-red-400">{u.losses}L</span>
|
||||
</span>
|
||||
|
||||
<!-- Role selector -->
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if canChangeRole}
|
||||
<form
|
||||
method="post"
|
||||
@@ -107,7 +106,6 @@
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="userId" value={u.id} />
|
||||
<select
|
||||
@@ -128,15 +126,14 @@
|
||||
{badge.label}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<!-- Admin Tools -->
|
||||
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
|
||||
<h2 class="font-600 text-sm">Tools</h2>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="mt-3 flex gap-4 text-xs text-text-secondary">
|
||||
<span
|
||||
>{data.stats.activeMatches} active match{data.stats.activeMatches !== 1 ? 'es' : ''}</span
|
||||
@@ -148,10 +145,8 @@
|
||||
: ''}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<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
|
||||
method="post"
|
||||
action="?/{tool.action}"
|
||||
@@ -180,30 +175,4 @@
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
@@ -2,140 +2,195 @@ import { db } from '$lib/server/db';
|
||||
import {
|
||||
playerRating,
|
||||
team,
|
||||
teamMember,
|
||||
matchParticipant,
|
||||
matchGameScore,
|
||||
matchGame,
|
||||
matchParticipantPlayer,
|
||||
mappoolSlot,
|
||||
match
|
||||
} from '$lib/server/db/schema';
|
||||
import { user } from '$lib/server/db/auth.schema';
|
||||
import { eq, desc, sql, and } from 'drizzle-orm';
|
||||
import { parseTableParams, buildTableMeta } from '$lib/server/table';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// ── Top players by ELO ──
|
||||
const topPlayers = await db.query.playerRating.findMany({
|
||||
orderBy: desc(playerRating.elo),
|
||||
limit: 25
|
||||
});
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const tab = url.searchParams.get('tab') ?? 'players';
|
||||
const params = parseTableParams(url, { sortBy: 'elo', limit: 25 });
|
||||
|
||||
const playersWithUsers = await Promise.all(
|
||||
topPlayers.map(async (r, i) => {
|
||||
const u = await db.query.user.findFirst({
|
||||
where: eq(user.id, r.userId)
|
||||
});
|
||||
return {
|
||||
rank: i + 1,
|
||||
userId: r.userId,
|
||||
name: u?.name ?? 'Unknown',
|
||||
image: u?.image ?? null,
|
||||
elo: r.elo,
|
||||
wins: r.wins,
|
||||
losses: r.losses,
|
||||
winRate: r.wins + r.losses > 0 ? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1) : '—'
|
||||
};
|
||||
if (tab === 'teams') {
|
||||
return loadTeams(params);
|
||||
} else if (tab === 'scores') {
|
||||
return loadScores(params);
|
||||
}
|
||||
return loadPlayers(params);
|
||||
};
|
||||
|
||||
async function loadPlayers(params: ReturnType<typeof parseTableParams>) {
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
userId: playerRating.userId,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
elo: playerRating.elo,
|
||||
wins: playerRating.wins,
|
||||
losses: playerRating.losses
|
||||
})
|
||||
);
|
||||
.from(playerRating)
|
||||
.innerJoin(user, eq(playerRating.userId, user.id))
|
||||
.orderBy(desc(playerRating.elo))
|
||||
.limit(params.limit)
|
||||
.offset((params.page - 1) * params.limit),
|
||||
db.select({ count: sql<number>`count(*)` }).from(playerRating)
|
||||
]);
|
||||
|
||||
// ── Top teams by match wins ──
|
||||
// Count wins for each team from matchParticipant scores
|
||||
const teamWins = await db
|
||||
const topPlayers = rows.map((r, i) => ({
|
||||
...r,
|
||||
rank: (params.page - 1) * params.limit + i + 1,
|
||||
winRate: r.wins + r.losses > 0 ? ((r.wins / (r.wins + r.losses)) * 100).toFixed(1) : '—'
|
||||
}));
|
||||
|
||||
return {
|
||||
tab: 'players' as const,
|
||||
topPlayers,
|
||||
topTeams: [],
|
||||
highScores: [],
|
||||
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTeams(params: ReturnType<typeof parseTableParams>) {
|
||||
// Get teams ranked by match wins, excluding personal teams
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
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)
|
||||
.where(and(sql`${match.winnerId} IS NOT NULL`, eq(match.state, 'FINISHED')))
|
||||
.groupBy(match.winnerId)
|
||||
.innerJoin(team, eq(match.winnerId, team.id))
|
||||
.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`)
|
||||
.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(
|
||||
teamWins.map(async (tw, i) => {
|
||||
if (!tw.teamId) return null;
|
||||
const t = await db.query.team.findFirst({
|
||||
where: eq(team.id, tw.teamId),
|
||||
with: { members: true }
|
||||
});
|
||||
if (!t) return null;
|
||||
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})` })
|
||||
// Batch-fetch total match counts for these teams
|
||||
const teamIds = rows.map((r) => r.teamId).filter(Boolean) as string[];
|
||||
const matchCounts: Record<string, number> = {};
|
||||
if (teamIds.length > 0) {
|
||||
const counts = await db
|
||||
.select({
|
||||
teamId: matchParticipant.teamId,
|
||||
count: sql<number>`count(DISTINCT ${matchParticipant.matchId})`
|
||||
})
|
||||
.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 {
|
||||
rank: i + 1,
|
||||
teamId: t.id,
|
||||
name: t.name,
|
||||
avatarUrl: t.avatarUrl,
|
||||
isPersonal: t.isPersonal,
|
||||
memberCount: t.members.length,
|
||||
wins: Number(tw.wins),
|
||||
totalMatches: Number(totalMatches[0]?.count ?? 0)
|
||||
tab: 'teams' as const,
|
||||
topPlayers: [],
|
||||
topTeams,
|
||||
highScores: [],
|
||||
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
.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))
|
||||
.limit(15)
|
||||
.then((rows) =>
|
||||
Promise.all(
|
||||
rows.map((s) =>
|
||||
db.query.matchGameScore.findFirst({
|
||||
where: eq(matchGameScore.id, s.id),
|
||||
with: {
|
||||
player: true,
|
||||
game: {
|
||||
with: { slot: true, match: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
.limit(params.limit)
|
||||
.offset((params.page - 1) * params.limit),
|
||||
db.select({ count: sql<number>`count(*)` }).from(matchGameScore)
|
||||
]);
|
||||
|
||||
const highScoresWithNames = await Promise.all(
|
||||
recentHighScores.map(async (s, i) => {
|
||||
if (!s) return null;
|
||||
const u = await db.query.user.findFirst({
|
||||
where: eq(user.id, s.player.userId)
|
||||
});
|
||||
return {
|
||||
rank: i + 1,
|
||||
playerName: u?.name ?? 'Unknown',
|
||||
playerImage: u?.image ?? null,
|
||||
const highScores = rows.map((s, i) => ({
|
||||
rank: (params.page - 1) * params.limit + i + 1,
|
||||
playerUserId: s.playerUserId,
|
||||
playerName: s.playerName ?? 'Unknown',
|
||||
playerImage: s.playerImage,
|
||||
score: s.score,
|
||||
accuracy: s.accuracy,
|
||||
maxCombo: s.maxCombo,
|
||||
count300: s.count300,
|
||||
count100: s.count100,
|
||||
count50: s.count50,
|
||||
countMiss: s.countMiss,
|
||||
pp: s.pp,
|
||||
mods: s.mods,
|
||||
passed: s.passed,
|
||||
beatmapId: s.game.slot?.beatmapId ?? null,
|
||||
mapCategory: s.game.slot?.category ?? '',
|
||||
mapOrder: s.game.slot?.orderInCategory ?? 0,
|
||||
matchName: s.game.match?.name ?? 'Match',
|
||||
matchId: s.game.matchId
|
||||
};
|
||||
})
|
||||
).then((rows) => rows.filter((x): x is NonNullable<typeof x> => Boolean(x)));
|
||||
mapCategory: s.mapCategory ?? '',
|
||||
mapOrder: s.mapOrder ?? 0,
|
||||
matchName: s.matchName ?? 'Match',
|
||||
matchId: s.matchId
|
||||
}));
|
||||
|
||||
return {
|
||||
topPlayers: playersWithUsers,
|
||||
topTeams: topTeams,
|
||||
highScores: highScoresWithNames
|
||||
tab: 'scores' as const,
|
||||
topPlayers: [],
|
||||
topTeams: [],
|
||||
highScores,
|
||||
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
<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();
|
||||
|
||||
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 = [
|
||||
{ id: 'players' as const, label: 'Top Players', icon: '👤' },
|
||||
{ id: 'teams' as const, label: 'Top Teams', icon: '⚑' },
|
||||
{ id: 'scores' as const, label: 'High Scores', icon: '🎯' }
|
||||
{ id: 'players', label: 'Top Players', icon: '👤' },
|
||||
{ id: 'teams', label: 'Top Teams', icon: '⚑' },
|
||||
{ id: 'scores', label: 'High Scores', icon: '🎯' }
|
||||
];
|
||||
|
||||
function eloColor(elo: number): string {
|
||||
@@ -32,6 +47,33 @@
|
||||
FM: 'text-green-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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -53,7 +95,7 @@
|
||||
<div class="mt-6 flex gap-1 rounded-lg border border-border bg-surface-800 p-1">
|
||||
{#each tabs as tab}
|
||||
<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 ===
|
||||
tab.id
|
||||
? 'bg-accent text-surface-900'
|
||||
@@ -67,28 +109,14 @@
|
||||
|
||||
<!-- Top Players -->
|
||||
{#if activeTab === 'players'}
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-border bg-surface-800">
|
||||
{#if data.topPlayers.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<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}
|
||||
<div class="mt-6">
|
||||
<DataTable data={data.topPlayers} meta={data.meta} columns={playerColumns}>
|
||||
{#snippet row(p, _i)}
|
||||
{@const medal = rankBadge(p.rank)}
|
||||
<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]'
|
||||
: ''}"
|
||||
>
|
||||
@@ -127,36 +155,21 @@
|
||||
{p.winRate}{p.winRate !== '—' ? '%' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Top Teams -->
|
||||
{#if activeTab === 'teams'}
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-border bg-surface-800">
|
||||
{#if data.topTeams.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<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}
|
||||
<div class="mt-6">
|
||||
<DataTable data={data.topTeams} meta={data.meta} columns={teamColumns}>
|
||||
{#snippet row(t, _i)}
|
||||
{@const medal = rankBadge(t.rank)}
|
||||
<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]'
|
||||
: ''}"
|
||||
>
|
||||
@@ -179,9 +192,6 @@
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<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">
|
||||
{t.memberCount} member{t.memberCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
@@ -193,43 +203,21 @@
|
||||
>{t.totalMatches}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- High Scores -->
|
||||
{#if activeTab === 'scores'}
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-border bg-surface-800">
|
||||
{#if data.highScores.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<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}
|
||||
<div class="mt-6">
|
||||
<DataTable data={data.highScores} meta={data.meta} columns={scoreColumns}>
|
||||
{#snippet row(s, _i)}
|
||||
{@const medal = rankBadge(s.rank)}
|
||||
<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]'
|
||||
: ''}"
|
||||
>
|
||||
@@ -252,9 +240,7 @@
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<span class="font-600 text-sm">{s.playerName}</span>
|
||||
<p class="truncate text-xs text-text-secondary">
|
||||
<a href="/matches/{s.matchId}" class="hover:text-accent">{s.matchName}</a>
|
||||
</p>
|
||||
<p class="truncate text-xs text-text-secondary">{s.matchName}</p>
|
||||
</td>
|
||||
<td
|
||||
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)}%` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
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 { requireAuth } from '$lib/server/permissions';
|
||||
import {
|
||||
parseTableParams,
|
||||
buildSearchFilter,
|
||||
buildOrderBy,
|
||||
buildTableMeta
|
||||
} from '$lib/server/table';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { mappool } from '$lib/server/db/schema';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) redirect(302, '/');
|
||||
const mapCount = sql<number>`(SELECT count(*) FROM mappool_slot WHERE mappool_id = ${mappool.id})`;
|
||||
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]) =>
|
||||
m.slots.length ? m.slots.reduce((s, sl) => s + (sl.starRating ?? 0), 0) / m.slots.length : 0;
|
||||
const [rows, countResult] = await Promise.all([
|
||||
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 };
|
||||
return {
|
||||
mappools: rows,
|
||||
meta: buildTableMeta(params, Number(countResult[0].count))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, locals }) => {
|
||||
if (!locals.user) redirect(302, '/');
|
||||
requireAuth(locals);
|
||||
|
||||
const form = await request.formData();
|
||||
const name = form.get('name')?.toString()?.trim();
|
||||
@@ -26,7 +58,7 @@ export const actions: Actions = {
|
||||
|
||||
const [created] = await db
|
||||
.insert(mappool)
|
||||
.values({ name, createdBy: locals.user.id })
|
||||
.values({ name, createdBy: locals.user!.id })
|
||||
.returning();
|
||||
|
||||
redirect(303, `/mappools/${created.id}`);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
|
||||
const { data } = $props();
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -18,10 +26,12 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<button
|
||||
onclick={() => (creating = !creating)}
|
||||
@@ -36,7 +46,7 @@
|
||||
method="post"
|
||||
action="?/create"
|
||||
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
|
||||
type="text"
|
||||
@@ -54,39 +64,35 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex flex-col gap-3">
|
||||
{#each data.mappools as pool}
|
||||
<DataTable data={data.mappools} meta={data.meta} {columns} searchPlaceholder="Search mappools...">
|
||||
{#snippet row(pool, _i)}
|
||||
{@const rated = pool.slots.filter((s: any) => s.starRating != null)}
|
||||
{@const avgSr =
|
||||
rated.length > 0
|
||||
? (rated.reduce((sum: number, s: any) => sum + s.starRating, 0) / rated.length).toFixed(2)
|
||||
: null}
|
||||
<a
|
||||
href="/mappools/{pool.id}"
|
||||
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"
|
||||
>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-600 text-sm">{pool.name}</h2>
|
||||
<tr class="transition-colors hover:bg-surface-800/50">
|
||||
<td class="px-4 py-3">
|
||||
<a href="/mappools/{pool.id}" class="flex items-center gap-2 hover:text-accent">
|
||||
<span class="font-600 text-sm">{pool.name}</span>
|
||||
{#if pool.verifiedAt}
|
||||
<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"
|
||||
>✓ Verified</span
|
||||
>Verified</span
|
||||
>
|
||||
{/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>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<p class="text-sm text-text-secondary">No mappools yet. Create one to get started.</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-text-secondary">
|
||||
{pool.slots.length}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-text-secondary">
|
||||
{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>
|
||||
|
||||
@@ -10,8 +10,6 @@ import { categoryToMods, isValidCategory, MOD_REGEX } from '$lib/mods';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
requireAuth(locals);
|
||||
|
||||
const pool = await db.query.mappool.findFirst({
|
||||
where: eq(mappool.id, params.id),
|
||||
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 {
|
||||
pool: { ...pool, slots: slotsWithBeatmaps },
|
||||
canEdit: isAdmin,
|
||||
isAdmin
|
||||
canEdit,
|
||||
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 = {
|
||||
rename: async ({ params, request, locals }) => {
|
||||
requireAuth(locals);
|
||||
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');
|
||||
await requireCanEdit(locals, params.id);
|
||||
|
||||
const form = await request.formData();
|
||||
const name = form.get('name')?.toString()?.trim();
|
||||
@@ -113,8 +122,7 @@ export const actions: Actions = {
|
||||
},
|
||||
|
||||
addSlot: async ({ params, request, locals }) => {
|
||||
requireAuth(locals);
|
||||
if (locals.user!.role !== 'admin') error(403, 'Admins only');
|
||||
await requireCanEdit(locals, params.id);
|
||||
|
||||
const form = await request.formData();
|
||||
let beatmapId = form.get('beatmapId')?.toString()?.trim();
|
||||
@@ -177,8 +185,7 @@ export const actions: Actions = {
|
||||
},
|
||||
|
||||
removeSlot: async ({ params, request, locals }) => {
|
||||
requireAuth(locals);
|
||||
if (locals.user!.role !== 'admin') error(403, 'Admins only');
|
||||
await requireCanEdit(locals, params.id);
|
||||
|
||||
const form = await request.formData();
|
||||
const slotId = form.get('slotId')?.toString();
|
||||
@@ -207,8 +214,7 @@ export const actions: Actions = {
|
||||
},
|
||||
|
||||
moveSlot: async ({ params, request, locals }) => {
|
||||
requireAuth(locals);
|
||||
if (locals.user!.role !== 'admin') error(403, 'Admins only');
|
||||
await requireCanEdit(locals, params.id);
|
||||
|
||||
const form = await request.formData();
|
||||
const slotId = form.get('slotId')?.toString();
|
||||
@@ -275,8 +281,7 @@ export const actions: Actions = {
|
||||
},
|
||||
|
||||
bulkImport: async ({ params, request, locals }) => {
|
||||
requireAuth(locals);
|
||||
if (locals.user!.role !== 'admin') error(403, 'Admins only');
|
||||
await requireCanEdit(locals, params.id);
|
||||
|
||||
const form = await request.formData();
|
||||
const raw = form.get('data')?.toString()?.trim();
|
||||
|
||||
@@ -2,17 +2,28 @@ import { log } from '$lib/server/logger';
|
||||
import { db } from '$lib/server/db';
|
||||
import { match, teamMember } from '$lib/server/db/schema';
|
||||
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 { initMatchLobby } from '$lib/server/match/orchestrator';
|
||||
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';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
requireAuth(locals);
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
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: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -24,36 +35,51 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
},
|
||||
with: {
|
||||
participants: {
|
||||
columns: {
|
||||
teamId: true,
|
||||
score: true,
|
||||
slot: true
|
||||
},
|
||||
columns: { teamId: true, score: true, slot: true },
|
||||
orderBy: (p, { asc }) => [asc(p.slot)],
|
||||
with: {
|
||||
team: {
|
||||
with: { team: { columns: { id: true, name: true, avatarUrl: true } } }
|
||||
},
|
||||
mappool: { columns: { id: true, name: true } }
|
||||
},
|
||||
where: inArray(match.state, liveStates),
|
||||
orderBy: desc(match.createdAt)
|
||||
}),
|
||||
db.query.match.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
avatarUrl: true
|
||||
}
|
||||
}
|
||||
}
|
||||
state: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
finishedAt: true,
|
||||
winnerId: true
|
||||
},
|
||||
mappool: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
with: {
|
||||
participants: {
|
||||
columns: { teamId: true, score: true, slot: 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),
|
||||
limit: 50
|
||||
});
|
||||
limit: params.limit,
|
||||
offset: (params.page - 1) * params.limit
|
||||
}),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(match)
|
||||
.where(searchFilter)
|
||||
]);
|
||||
|
||||
return {
|
||||
matches,
|
||||
canCreateMatch: hasRole(locals.user!.role, 'referee')
|
||||
liveMatches,
|
||||
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 }) => {
|
||||
// Only referees and admins can create matches manually
|
||||
requireRole(locals, 'referee', 'Only referees and admins can create matches');
|
||||
requirePermission(
|
||||
locals,
|
||||
GlobalPermission.MATCH_CREATE,
|
||||
'Only referees and admins can create matches'
|
||||
);
|
||||
|
||||
const form = await request.formData();
|
||||
const name = form.get('name')?.toString()?.trim() || 'Custom Match';
|
||||
@@ -115,7 +144,6 @@ export const actions: Actions = {
|
||||
return { error: e.message };
|
||||
}
|
||||
|
||||
// Create IRC lobby in background
|
||||
initMatchLobby(result.id).catch((err) => {
|
||||
log.match.error({ err, matchId: result.id }, 'IRC lobby failed');
|
||||
});
|
||||
@@ -146,7 +174,6 @@ export const actions: Actions = {
|
||||
return { error: 'All fields are required' };
|
||||
}
|
||||
|
||||
// Validate bestOf is odd and >= 1
|
||||
if (bestOf < 1 || bestOf % 2 === 0) {
|
||||
return { error: 'Best of must be an odd number >= 1' };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -45,7 +46,6 @@
|
||||
);
|
||||
const hasMultipleTeams = $derived(nonPersonalUserTeams.length > 0);
|
||||
|
||||
// Auto-select personal team by default
|
||||
$effect(() => {
|
||||
if (!selectedCreatorTeamId && personalTeam) {
|
||||
selectedCreatorTeamId = personalTeam.id;
|
||||
@@ -53,10 +53,10 @@
|
||||
});
|
||||
|
||||
const selectedCreatorTeam = $derived(
|
||||
(formData ?? data).teams.find((t: any) => t.id === selectedCreatorTeamId)
|
||||
(formData ?? data).teams?.find((t: any) => t.id === selectedCreatorTeamId)
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -65,13 +65,12 @@
|
||||
|
||||
const effectiveBestOf = $derived(useCustomBo ? parseInt(customBestOf) || 5 : bestOf);
|
||||
|
||||
// Filter opponent teams for search
|
||||
const filteredOpponentTeams = $derived(
|
||||
(formData ?? data).teams.filter((t: any) => {
|
||||
(formData ?? data).teams?.filter((t: any) => {
|
||||
if (t.id === selectedCreatorTeamId) return false;
|
||||
if (!opponentSearch) return true;
|
||||
return t.name.toLowerCase().includes(opponentSearch.toLowerCase());
|
||||
})
|
||||
}) ?? []
|
||||
);
|
||||
|
||||
function teamDisplayName(t: any): string {
|
||||
@@ -82,7 +81,6 @@
|
||||
const slots = p.slots ?? [];
|
||||
if (!slots.length) return '0 maps';
|
||||
const avg = slots.reduce((s: number, sl: any) => s + (sl.starRating ?? 0), 0) / slots.length;
|
||||
// Count per category
|
||||
const counts: Record<string, number> = {};
|
||||
for (const sl of slots) {
|
||||
counts[sl.category] = (counts[sl.category] ?? 0) + 1;
|
||||
@@ -113,24 +111,12 @@
|
||||
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) {
|
||||
const p1 = m.participants[0];
|
||||
const p2 = m.participants[1];
|
||||
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) {
|
||||
const d = new Date(date);
|
||||
const diff = Date.now() - d.getTime();
|
||||
@@ -142,6 +128,13 @@
|
||||
const days = Math.floor(hours / 24);
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -155,13 +148,13 @@
|
||||
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="font-700 text-2xl tracking-tight">Matches</h1>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
{data.matches.length} match{data.matches.length !== 1 ? 'es' : ''}
|
||||
{#if liveMatches.length > 0}
|
||||
· <span class="text-green-400">{liveMatches.length} live</span>
|
||||
{data.meta.total} match{data.meta.total !== 1 ? 'es' : ''}
|
||||
{#if data.liveMatches.length > 0}
|
||||
· <span class="text-green-400">{data.liveMatches.length} live</span>
|
||||
{/if}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<option value="">Select team...</option>
|
||||
{#each formData?.teams as t}
|
||||
{#each formData?.teams ?? [] as t}
|
||||
<option value={t.id}>{t.name}</option>
|
||||
{/each}
|
||||
</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"
|
||||
>
|
||||
<option value="">Select team...</option>
|
||||
{#each formData?.teams as t}
|
||||
{#each formData?.teams ?? [] as t}
|
||||
<option value={t.id}>{t.name}</option>
|
||||
{/each}
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
@@ -343,7 +336,6 @@
|
||||
<input type="hidden" name="invitedTeamId" value={selectedInvitedTeamId} />
|
||||
|
||||
<div class="mt-4 flex flex-col gap-4">
|
||||
<!-- Your Team (only shown if you have non-personal teams) -->
|
||||
{#if hasMultipleTeams}
|
||||
<div>
|
||||
<label for="inv-myTeam" class="font-500 text-xs text-text-secondary">Your Team</label>
|
||||
@@ -362,7 +354,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Opponent (searchable) -->
|
||||
<div>
|
||||
<label for="inv-opponent-search" class="font-500 text-xs text-text-secondary"
|
||||
>Opponent</label
|
||||
@@ -422,7 +413,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Best Of -->
|
||||
<div>
|
||||
<span class="font-500 text-xs text-text-secondary">Best Of</span>
|
||||
<div class="mt-1.5 flex flex-wrap gap-1.5">
|
||||
@@ -457,7 +447,6 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Team sizes (only if creator is not personal team) -->
|
||||
{#if !creatorIsPersonal && creatorMaxSize > 1}
|
||||
<div>
|
||||
<label for="inv-ts1" class="font-500 text-xs text-text-secondary"
|
||||
@@ -496,7 +485,6 @@
|
||||
<input type="hidden" name="teamSize2" value="1" />
|
||||
{/if}
|
||||
|
||||
<!-- Scoring -->
|
||||
<div>
|
||||
<label for="inv-scoring" class="font-500 text-xs text-text-secondary">Scoring</label>
|
||||
<select
|
||||
@@ -511,7 +499,6 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Mappool -->
|
||||
<div>
|
||||
<label for="inv-mappool" class="font-500 text-xs text-text-secondary">Mappool</label>
|
||||
<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"
|
||||
>
|
||||
<option value="">Select mappool...</option>
|
||||
{#each formData?.mappools as p}
|
||||
{#each formData?.mappools ?? [] as p}
|
||||
<option value={p.id}>{p.name} — {mappoolSummary(p)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ELO toggle (default on) -->
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<input
|
||||
@@ -541,7 +527,6 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Message + Schedule -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="inv-message" class="font-500 text-xs text-text-secondary"
|
||||
@@ -582,17 +567,17 @@
|
||||
{/if}
|
||||
|
||||
<!-- Live Matches -->
|
||||
{#if liveMatches.length > 0}
|
||||
<div class="mt-6">
|
||||
{#if data.liveMatches.length > 0}
|
||||
<div class="mb-6">
|
||||
<h2 class="font-600 flex items-center gap-2 text-sm">
|
||||
<div class="relative h-2 w-2">
|
||||
<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>
|
||||
Live Now (max 4 until osu! gives me bot account lol)
|
||||
Live Now
|
||||
</h2>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
{#each liveMatches as m}
|
||||
{#each data.liveMatches as m}
|
||||
{@const { p1, p2 } = teamDisplay(m)}
|
||||
{@const sc = stateConfig[m.state]}
|
||||
<a
|
||||
@@ -634,22 +619,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- All Matches -->
|
||||
<div class="mt-6">
|
||||
{#if liveMatches.length > 0}
|
||||
<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}
|
||||
<!-- All Matches Table -->
|
||||
<DataTable data={data.matches} meta={data.meta} {columns} searchPlaceholder="Search matches...">
|
||||
{#snippet row(m, _i)}
|
||||
{@const { p1, p2 } = teamDisplay(m)}
|
||||
{@const config = m.config as { bestOf: number }}
|
||||
{@const sc = stateConfig[m.state]}
|
||||
<a
|
||||
href="/matches/{m.id}"
|
||||
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"
|
||||
>
|
||||
<!-- Match info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<tr class="transition-colors hover:bg-surface-800/50">
|
||||
<td class="px-4 py-3">
|
||||
<a href="/matches/{m.id}" class="block hover:text-accent">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
|
||||
{#if m.state === 'FINISHED'}
|
||||
@@ -668,45 +646,28 @@
|
||||
<span class="text-xs text-text-secondary">vs</span>
|
||||
{/if}
|
||||
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-text-secondary">
|
||||
{m.name ? `${m.name} · ` : ''}BO{config.bestOf}{#if m.finishedAt}
|
||||
· {timeAgo(m.finishedAt)}{:else if m.createdAt}
|
||||
· {timeAgo(m.createdAt)}{/if}
|
||||
</p>
|
||||
{#if m.mappool}
|
||||
<p class="text-xs text-text-secondary">
|
||||
{m.mappool.name}{#if avgSR(m.mappool)}
|
||||
({avgSR(m.mappool)}★){/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Winner indicator -->
|
||||
{#if m.state === 'FINISHED' && m.winnerId}
|
||||
{@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}
|
||||
|
||||
<!-- State -->
|
||||
</div>
|
||||
{#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'}">
|
||||
{sc?.label ?? m.state}
|
||||
</span>
|
||||
</a>
|
||||
{:else}
|
||||
{#if liveMatches.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<p class="text-sm text-text-secondary">
|
||||
No matches yet.
|
||||
{#if data.canCreateMatch}
|
||||
Create one to get started.
|
||||
{:else}
|
||||
Join the ranked queue from the dashboard!
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 text-sm text-text-secondary sm:table-cell">
|
||||
{timeAgo(m.finishedAt ?? m.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,13 @@ import { getLobby, getLobbyStatus } from '$lib/server/bancho/client';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { getBeatmap } from '$lib/server/osu/api';
|
||||
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 { eq } from 'drizzle-orm';
|
||||
import type { MatchConfig } from '$lib/server/match/types';
|
||||
import { playerRating } from '$lib/server/db/schema';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
if (!locals.user) redirect(302, '/');
|
||||
|
||||
let m;
|
||||
try {
|
||||
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
|
||||
const playerNames: Record<string, string> = {};
|
||||
@@ -127,7 +127,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
return {
|
||||
match: m,
|
||||
beatmapCache,
|
||||
userId: locals.user.id,
|
||||
userId: locals.user?.id ?? null,
|
||||
isStaff,
|
||||
lobbyStatus,
|
||||
playerNames,
|
||||
@@ -271,7 +271,7 @@ export const actions: Actions = {
|
||||
forceStart: async ({ params, locals }) => {
|
||||
if (!locals.user) redirect(302, '/');
|
||||
// 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' };
|
||||
}
|
||||
await forceStartGame(params.id);
|
||||
@@ -281,7 +281,7 @@ export const actions: Actions = {
|
||||
cancel: async ({ params, locals }) => {
|
||||
if (!locals.user) redirect(302, '/');
|
||||
// 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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,56 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { team, teamMember, user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { team, teamMember } from '$lib/server/db/schema';
|
||||
import { eq, sql, and } from 'drizzle-orm';
|
||||
import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions';
|
||||
import {
|
||||
parseTableParams,
|
||||
buildSearchFilter,
|
||||
buildOrderBy,
|
||||
buildTableMeta
|
||||
} from '$lib/server/table';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const u = requireAuth(locals);
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
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({
|
||||
with: {
|
||||
members: true
|
||||
},
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)]
|
||||
});
|
||||
|
||||
// Fetch member user details
|
||||
const teamsWithUsers = await Promise.all(
|
||||
teams.map(async (t) => {
|
||||
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 };
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
isPersonal: team.isPersonal,
|
||||
ownerId: team.ownerId,
|
||||
avatarUrl: team.avatarUrl,
|
||||
createdAt: team.createdAt,
|
||||
memberCount: sql<number>`(SELECT count(*) FROM team_member WHERE team_id = ${team.id})`
|
||||
})
|
||||
);
|
||||
return { ...t, members: membersWithUsers };
|
||||
})
|
||||
);
|
||||
.from(team)
|
||||
.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 = {
|
||||
@@ -51,7 +71,6 @@ export const actions: Actions = {
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Add creator as captain
|
||||
await db.insert(teamMember).values({
|
||||
teamId: created.id,
|
||||
userId: u.id,
|
||||
@@ -80,6 +99,7 @@ export const actions: Actions = {
|
||||
|
||||
addMember: async ({ request, locals }) => {
|
||||
requireAuth(locals);
|
||||
const { user } = await import('$lib/server/db/schema');
|
||||
|
||||
const form = await request.formData();
|
||||
const teamId = form.get('teamId')?.toString();
|
||||
@@ -87,19 +107,16 @@ export const actions: Actions = {
|
||||
|
||||
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) });
|
||||
if (!t) return { error: 'Team not found' };
|
||||
requireOwnerOrAdmin(locals, t.ownerId);
|
||||
|
||||
// Find user by osu! username
|
||||
const u = await db.query.user.findFirst({
|
||||
where: eq(user.name, username)
|
||||
});
|
||||
|
||||
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({
|
||||
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();
|
||||
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({
|
||||
where: eq(teamMember.id, memberId),
|
||||
with: { team: true }
|
||||
|
||||
@@ -1,54 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
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(
|
||||
data.teams.filter((t: any) => t.members.some((m: any) => m.userId === data.userId))
|
||||
);
|
||||
const otherTeams = $derived(
|
||||
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;
|
||||
}
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Team', sortable: true },
|
||||
{ key: 'memberCount', label: 'Members' },
|
||||
{ key: 'createdAt', label: 'Created', sortable: true, class: 'hidden sm:table-cell' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Teams — Vash Esports</title>
|
||||
<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>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<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
|
||||
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"
|
||||
@@ -57,7 +29,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Team -->
|
||||
{#if creating}
|
||||
<form
|
||||
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
|
||||
type="text"
|
||||
@@ -88,256 +59,43 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if actionError}
|
||||
<p class="mt-3 text-sm text-red-400">{actionError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- My Teams -->
|
||||
{#if myTeams.length > 0}
|
||||
<div class="mt-6">
|
||||
<h2 class="font-600 text-sm text-text-secondary">My Teams</h2>
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
{#each myTeams as t}
|
||||
<div class="rounded-lg border border-border bg-surface-800 transition-colors">
|
||||
<!-- Team header -->
|
||||
<button
|
||||
onclick={() => (expandedTeam = expandedTeam === t.id ? null : t.id)}
|
||||
class="flex w-full items-center gap-4 p-4 text-left transition-colors hover:bg-surface-700"
|
||||
>
|
||||
{#if t.avatarUrl}
|
||||
<img src={t.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
|
||||
{:else}
|
||||
<div
|
||||
class="font-700 flex h-10 w-10 items-center justify-center rounded-full bg-surface-600 text-sm text-text-secondary"
|
||||
>
|
||||
{t.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href="/teams/{t.id}"
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
class="font-600 text-sm hover:text-accent hover:underline">{t.name}</a
|
||||
>
|
||||
{#if t.isPersonal}
|
||||
<span
|
||||
class="font-500 rounded bg-surface-600 px-1.5 py-0.5 text-[10px] text-text-secondary"
|
||||
>solo</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-text-secondary">
|
||||
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="text-sm text-text-secondary transition-transform {expandedTeam === t.id
|
||||
? 'rotate-90'
|
||||
: ''}">›</span
|
||||
>
|
||||
</button>
|
||||
|
||||
<!-- Expanded: members -->
|
||||
{#if expandedTeam === t.id}
|
||||
<div class="border-t border-border px-4 pt-3 pb-4">
|
||||
<!-- Rename team -->
|
||||
{#if isOwner(t) && !t.isPersonal}
|
||||
{#if renamingTeam === t.id}
|
||||
<form
|
||||
method="post"
|
||||
action="?/rename"
|
||||
use:enhance={() => {
|
||||
actionError = '';
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && (result.data as any)?.success) {
|
||||
renamingTeam = null;
|
||||
await update();
|
||||
} else if (result.type === 'success' && (result.data as any)?.error) {
|
||||
actionError = (result.data as any).error;
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="mb-3 flex items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="teamId" value={t.id} />
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={renameValue}
|
||||
class="flex-1 rounded-md border border-accent bg-surface-700 px-3 py-1.5 text-sm text-text-primary focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="font-600 rounded-md bg-accent px-3 py-1.5 text-xs text-surface-900 hover:bg-accent-hover"
|
||||
>Save</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (renamingTeam = null)}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-600"
|
||||
>Cancel</button
|
||||
>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => {
|
||||
renamingTeam = t.id;
|
||||
renameValue = t.name;
|
||||
}}
|
||||
class="mb-3 text-xs text-text-secondary transition-colors hover:text-accent"
|
||||
>
|
||||
✎ Rename team
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Member list -->
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each t.members as member}
|
||||
<div class="flex items-center gap-3 rounded-md bg-surface-700 px-3 py-2">
|
||||
{#if member.user?.image}
|
||||
<img src={member.user.image} alt="" class="h-6 w-6 rounded-full" />
|
||||
{:else}
|
||||
<div class="h-6 w-6 rounded-full bg-surface-600"></div>
|
||||
{/if}
|
||||
<span class="flex-1 text-sm">{member.user?.name ?? 'Unknown'}</span>
|
||||
<span class="text-xs text-text-secondary">{member.role}</span>
|
||||
{#if isOwner(t) && member.userId !== data.userId}
|
||||
<form
|
||||
method="post"
|
||||
action="?/removeMember"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="memberId" value={member.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100"
|
||||
>×</button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Add member -->
|
||||
{#if isOwner(t) && !t.isPersonal}
|
||||
{#if addingMemberTo === t.id}
|
||||
<form
|
||||
method="post"
|
||||
action="?/addMember"
|
||||
use:enhance={() => {
|
||||
actionError = '';
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && (result.data as any)?.success) {
|
||||
memberUsername = '';
|
||||
addingMemberTo = null;
|
||||
await update();
|
||||
} else if (result.type === 'success' && (result.data as any)?.error) {
|
||||
actionError = (result.data as any).error;
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="mt-3 flex gap-2"
|
||||
>
|
||||
<input type="hidden" name="teamId" value={t.id} />
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
bind:value={memberUsername}
|
||||
placeholder="osu! username"
|
||||
required
|
||||
class="flex-1 rounded-md border border-border bg-surface-600 px-3 py-1.5 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="font-600 rounded-md bg-accent px-3 py-1.5 text-xs text-surface-900 hover:bg-accent-hover"
|
||||
>Add</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (addingMemberTo = null)}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-600"
|
||||
>Cancel</button
|
||||
>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (addingMemberTo = t.id)}
|
||||
class="mt-3 w-full rounded-md border border-dashed border-border py-2 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-accent"
|
||||
>
|
||||
+ Add Member
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Delete team -->
|
||||
{#if isOwner(t) && !t.isPersonal}
|
||||
<form method="post" action="?/deleteTeam" use:enhance>
|
||||
<input type="hidden" name="teamId" value={t.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onclick={(e) => {
|
||||
if (!confirm(`Delete ${t.name}?`)) e.preventDefault();
|
||||
}}
|
||||
class="mt-3 text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100"
|
||||
>
|
||||
Delete team
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Other Teams -->
|
||||
{#if otherTeams.length > 0}
|
||||
<div class="mt-6">
|
||||
<h2 class="font-600 text-sm text-text-secondary">Other Teams</h2>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
{#each otherTeams as t}
|
||||
<a
|
||||
href="/teams/{t.id}"
|
||||
class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
|
||||
>
|
||||
{#if t.avatarUrl}
|
||||
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||
<DataTable data={data.teams} meta={data.meta} {columns} searchPlaceholder="Search teams...">
|
||||
{#snippet row(team, _i)}
|
||||
<tr class="transition-colors hover:bg-surface-800/50">
|
||||
<td class="px-4 py-3">
|
||||
<a href="/teams/{team.id}" class="flex items-center gap-3 hover:text-accent">
|
||||
{#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"
|
||||
>
|
||||
{t.name.charAt(0).toUpperCase()}
|
||||
{team.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-1">
|
||||
<span class="font-600 text-sm">{t.name}</span>
|
||||
<p class="text-xs text-text-secondary">
|
||||
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div>
|
||||
<span class="font-600 text-sm">{team.name}</span>
|
||||
{#if team.isPersonal}
|
||||
<span
|
||||
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>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if data.teams.length === 0}
|
||||
<div class="mt-6 rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<p class="text-sm text-text-secondary">
|
||||
No teams yet. Your personal team is created when you sign in.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-text-secondary">
|
||||
{team.memberCount}
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 text-sm text-text-secondary sm:table-cell">
|
||||
{new Date(team.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 { eq, and, desc } from 'drizzle-orm';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions';
|
||||
import { uploadImage } from '$lib/server/storage/r2';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
requireAuth(locals);
|
||||
|
||||
const t = await db.query.team.findFirst({
|
||||
where: eq(team.id, params.id),
|
||||
with: { members: true }
|
||||
@@ -49,10 +48,71 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
(m) => m.state === 'FINISHED' && m.winnerId !== params.id
|
||||
).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 {
|
||||
team: { ...t, members: membersWithUsers },
|
||||
recentMatches,
|
||||
wins,
|
||||
losses
|
||||
losses,
|
||||
canManage: isOwner || isAdmin,
|
||||
comments
|
||||
};
|
||||
};
|
||||
|
||||
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
|
||||
export const actions: Actions = {
|
||||
comment: async ({ params, request, locals }) => {
|
||||
const u = requireAuth(locals);
|
||||
const form = await request.formData();
|
||||
const content = form.get('content')?.toString()?.trim();
|
||||
if (!content) return { error: 'Comment cannot be empty' };
|
||||
if (content.length > 1000) return { error: 'Comment too long (max 1000 characters)' };
|
||||
|
||||
await db.insert(profileComment).values({
|
||||
targetType: 'team',
|
||||
targetId: params.id,
|
||||
authorId: u.id,
|
||||
content
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
uploadAvatar: async ({ params, request, locals }) => {
|
||||
const t = await db.query.team.findFirst({ where: eq(team.id, params.id) });
|
||||
if (!t) error(404, 'Team not found');
|
||||
requireOwnerOrAdmin(locals, t.ownerId);
|
||||
|
||||
const form = await request.formData();
|
||||
const file = form.get('avatar');
|
||||
if (!(file instanceof File) || file.size === 0) return { error: 'No file provided' };
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return { error: 'Invalid file type. Use JPEG, PNG, WebP, or GIF.' };
|
||||
}
|
||||
if (file.size > MAX_AVATAR_SIZE) {
|
||||
return { error: 'File too large. Max 2 MB.' };
|
||||
}
|
||||
|
||||
const ext = file.type.split('/')[1].replace('jpeg', 'jpg');
|
||||
const key = `teams/${params.id}/avatar.${ext}`;
|
||||
const buf = await file.arrayBuffer();
|
||||
const cdnUrl = await uploadImage(key, buf, file.type);
|
||||
|
||||
if (!cdnUrl) return { error: 'Upload failed. Try again later.' };
|
||||
|
||||
await db.update(team).set({ avatarUrl: cdnUrl }).where(eq(team.id, params.id));
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import ProfileComments from '$lib/components/ProfileComments.svelte';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const t = $derived(data.team);
|
||||
let uploading = $state(false);
|
||||
let avatarError = $state('');
|
||||
|
||||
function timeAgo(date: string | Date) {
|
||||
const d = new Date(date);
|
||||
@@ -23,8 +28,9 @@
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
{#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}
|
||||
<div
|
||||
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()}
|
||||
</div>
|
||||
{/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 class="flex items-center gap-2">
|
||||
<h1 class="font-700 text-2xl tracking-tight">{t.name}</h1>
|
||||
@@ -49,13 +106,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if avatarError}
|
||||
<p class="mt-2 text-sm text-red-400">{avatarError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Players -->
|
||||
<div class="mt-6">
|
||||
<h2 class="font-600 text-sm text-text-secondary">Players</h2>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
{#each t.members as member}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3"
|
||||
<a
|
||||
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}
|
||||
<img src={member.user.image} alt="" class="h-8 w-8 rounded-full" />
|
||||
@@ -64,7 +126,7 @@
|
||||
{/if}
|
||||
<span class="font-600 flex-1 text-sm">{member.user?.name ?? 'Unknown'}</span>
|
||||
<span class="text-xs text-text-secondary">{member.role}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,4 +170,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ProfileComments comments={data.comments} actionUrl="?/comment" />
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,60 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournament } from '$lib/server/db/schema';
|
||||
import { desc } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { sql, and, inArray } from 'drizzle-orm';
|
||||
import {
|
||||
parseTableParams,
|
||||
buildSearchFilter,
|
||||
buildOrderBy,
|
||||
buildTableMeta
|
||||
} from '$lib/server/table';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
requireAuth(locals);
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
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: {
|
||||
registrations: {
|
||||
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))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const formatLabels: Record<string, string> = {
|
||||
@@ -17,31 +21,26 @@
|
||||
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(
|
||||
filter === 'all'
|
||||
? data.tournaments
|
||||
: data.tournaments.filter((t: any) => {
|
||||
if (filter === 'upcoming')
|
||||
return ['DRAFT', 'REGISTRATION', 'QUALIFIERS', 'SEEDING'].includes(t.state);
|
||||
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`;
|
||||
function setFilter(value: string) {
|
||||
const url = new URL($page.url);
|
||||
if (value === 'all') {
|
||||
url.searchParams.delete('filter');
|
||||
} else {
|
||||
url.searchParams.set('filter', value);
|
||||
}
|
||||
url.searchParams.set('page', '1');
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Tournament', sortable: true },
|
||||
{ key: 'format', label: 'Format' },
|
||||
{ key: 'state', label: 'State' },
|
||||
{ key: 'players', label: 'Players' },
|
||||
{ key: 'createdAt', label: 'Created', sortable: true, class: 'hidden sm:table-cell' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -49,11 +48,11 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="font-700 text-2xl tracking-tight">Tournaments</h1>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
{data.tournaments.length} tournament{data.tournaments.length !== 1 ? 's' : ''}
|
||||
{data.meta.total} tournament{data.meta.total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
@@ -65,67 +64,57 @@
|
||||
</div>
|
||||
|
||||
<!-- 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]}
|
||||
<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'
|
||||
: 'border border-border bg-surface-700 text-text-secondary hover:border-accent/40 hover:text-text-primary'}"
|
||||
onclick={() => (filter = value)}
|
||||
onclick={() => setFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Tournament List -->
|
||||
{#if filtered.length === 0}
|
||||
<div class="mt-6 rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<p class="text-sm text-text-secondary">No tournaments found</p>
|
||||
</div>
|
||||
{: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"
|
||||
<DataTable
|
||||
data={data.tournaments}
|
||||
meta={data.meta}
|
||||
{columns}
|
||||
searchPlaceholder="Search tournaments..."
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
{#snippet row(t, _i)}
|
||||
{@const activeRegs = t.registrations.filter(
|
||||
(r: any) => r.status !== 'withdrawn' && r.status !== 'eliminated'
|
||||
).length}
|
||||
<tr
|
||||
onclick={() => goto(`/tournaments/${t.id}`)}
|
||||
class="cursor-pointer transition-colors hover:bg-surface-800/50"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-600 text-sm">{t.name}</span>
|
||||
{#if t.description}
|
||||
<p class="mt-0.5 truncate text-xs text-text-secondary">{t.description}</p>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-text-secondary">
|
||||
{formatLabels[t.format] ?? t.format}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="font-500 rounded px-2 py-0.5 text-xs {stateColors[t.state] ??
|
||||
'bg-surface-600 text-text-secondary'}"
|
||||
>
|
||||
{t.state}
|
||||
</span>
|
||||
</div>
|
||||
{#if t.description}
|
||||
<p class="mt-0.5 truncate text-xs text-text-secondary">{t.description}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-text-secondary">
|
||||
{formatLabels[t.format] ?? t.format}
|
||||
· {t.registrations.filter(
|
||||
(r: any) => r.status !== 'withdrawn' && r.status !== 'eliminated'
|
||||
).length}/{t.maxSlots} players
|
||||
{#if t.startAt}
|
||||
· starts {new Date(t.startAt).toLocaleDateString()}
|
||||
{/if}
|
||||
· {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}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-text-secondary">
|
||||
{activeRegs}/{t.maxSlots}
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 text-sm text-text-secondary sm:table-cell">
|
||||
{new Date(t.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,6 @@ import { requireAuth } from '$lib/server/permissions';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
requireAuth(locals);
|
||||
|
||||
const t = await db.query.tournament.findFirst({
|
||||
where: eq(tournament.id, params.id),
|
||||
with: {
|
||||
@@ -59,17 +57,18 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
});
|
||||
|
||||
// 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
|
||||
const userTeams = await db.query.teamMember.findMany({
|
||||
where: eq(teamMember.userId, locals.user!.id),
|
||||
const userTeams = locals.user
|
||||
? await db.query.teamMember.findMany({
|
||||
where: eq(teamMember.userId, locals.user.id),
|
||||
with: { team: true }
|
||||
});
|
||||
|
||||
const userRegistration = t.registrations.find((r) =>
|
||||
userTeams.some((ut) => ut.teamId === r.teamId)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
const userRegistration = locals.user
|
||||
? t.registrations.find((r) => userTeams.some((ut) => ut.teamId === r.teamId))
|
||||
: undefined;
|
||||
|
||||
// load mappools for round config (staff only in DRAFT)
|
||||
let mappools: { id: string; name: string; slotCount: number }[] = [];
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createTournament } from '$lib/server/tournament/lifecycle';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
requireAuth(locals);
|
||||
if (!locals.user) redirect(302, '/login');
|
||||
return {};
|
||||
};
|
||||
|
||||
|
||||
101
src/routes/(app)/users/[id]/+page.server.ts
Normal file
101
src/routes/(app)/users/[id]/+page.server.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/auth.schema';
|
||||
import {
|
||||
playerRating,
|
||||
teamMember,
|
||||
matchParticipantPlayer,
|
||||
profileComment
|
||||
} from '$lib/server/db/schema';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const u = await db.query.user.findFirst({
|
||||
where: eq(user.id, params.id)
|
||||
});
|
||||
if (!u) error(404, 'User not found');
|
||||
|
||||
// Player rating
|
||||
const rating = await db.query.playerRating.findFirst({
|
||||
where: eq(playerRating.userId, params.id)
|
||||
});
|
||||
|
||||
// Teams (non-personal)
|
||||
const memberships = await db.query.teamMember.findMany({
|
||||
where: eq(teamMember.userId, params.id),
|
||||
with: { team: true }
|
||||
});
|
||||
const teams = memberships
|
||||
.filter((m) => !m.team.isPersonal)
|
||||
.map((m) => ({ id: m.team.id, name: m.team.name, avatarUrl: m.team.avatarUrl, role: m.role }));
|
||||
|
||||
// Recent matches via matchParticipantPlayer
|
||||
const playerEntries = await db.query.matchParticipantPlayer.findMany({
|
||||
where: eq(matchParticipantPlayer.userId, params.id),
|
||||
with: {
|
||||
participant: {
|
||||
with: {
|
||||
match: {
|
||||
with: {
|
||||
participants: {
|
||||
with: { team: true },
|
||||
orderBy: (p: any, { asc }: any) => [asc(p.slot)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const recentMatches = playerEntries
|
||||
.map((pe) => pe.participant.match)
|
||||
.filter((m) => m.state === 'FINISHED' || m.state === 'CANCELLED')
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 20);
|
||||
|
||||
const personalTeam = memberships.find((m) => m.team.isPersonal)?.team;
|
||||
|
||||
// Comments
|
||||
const comments = await db.query.profileComment.findMany({
|
||||
where: and(eq(profileComment.targetType, 'user'), eq(profileComment.targetId, params.id)),
|
||||
with: { author: true },
|
||||
orderBy: desc(profileComment.createdAt)
|
||||
});
|
||||
|
||||
return {
|
||||
profile: {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
image: u.image,
|
||||
role: u.role,
|
||||
createdAt: u.createdAt
|
||||
},
|
||||
rating: rating ? { elo: rating.elo, wins: rating.wins, losses: rating.losses } : null,
|
||||
teams,
|
||||
recentMatches,
|
||||
personalTeamId: personalTeam?.id ?? null,
|
||||
comments
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
comment: async ({ params, request, locals }) => {
|
||||
const u = requireAuth(locals);
|
||||
const form = await request.formData();
|
||||
const content = form.get('content')?.toString()?.trim();
|
||||
if (!content) return { error: 'Comment cannot be empty' };
|
||||
if (content.length > 1000) return { error: 'Comment too long (max 1000 characters)' };
|
||||
|
||||
await db.insert(profileComment).values({
|
||||
targetType: 'user',
|
||||
targetId: params.id,
|
||||
authorId: u.id,
|
||||
content
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
154
src/routes/(app)/users/[id]/+page.svelte
Normal file
154
src/routes/(app)/users/[id]/+page.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import ProfileComments from '$lib/components/ProfileComments.svelte';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const p = $derived(data.profile);
|
||||
|
||||
const roleBadge: Record<string, { label: string; class: string }> = {
|
||||
admin: { label: 'Admin', class: 'bg-red-500/20 text-red-400' },
|
||||
referee: { label: 'Referee', class: 'bg-blue-500/20 text-blue-400' },
|
||||
player: { label: 'Player', class: 'bg-surface-600 text-text-secondary' }
|
||||
};
|
||||
|
||||
const badge = $derived(roleBadge[p.role] ?? roleBadge.player);
|
||||
|
||||
function timeAgo(date: string | Date) {
|
||||
const d = new Date(date);
|
||||
const diff = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function formatDate(date: string | Date) {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{p.name} — Vash Esports</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
{#if p.image}
|
||||
<img src={p.image} alt="" class="h-16 w-16 rounded-full" />
|
||||
{:else}
|
||||
<div
|
||||
class="font-700 flex h-16 w-16 items-center justify-center rounded-full bg-surface-600 text-xl text-text-secondary"
|
||||
>
|
||||
{p.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="font-700 text-2xl tracking-tight">{p.name}</h1>
|
||||
<span class="font-600 rounded px-1.5 py-0.5 text-[10px] {badge.class}">{badge.label}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-sm text-text-secondary">
|
||||
Joined {formatDate(p.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
{#if data.rating}
|
||||
<div class="mt-6 grid grid-cols-3 gap-3">
|
||||
<div class="rounded-lg border border-border bg-surface-800 px-4 py-3 text-center">
|
||||
<p class="font-700 text-xl text-accent">{data.rating.elo}</p>
|
||||
<p class="mt-0.5 text-[11px] text-text-secondary">ELO</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-surface-800 px-4 py-3 text-center">
|
||||
<p class="font-700 text-xl text-green-400">{data.rating.wins}</p>
|
||||
<p class="mt-0.5 text-[11px] text-text-secondary">Wins</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-surface-800 px-4 py-3 text-center">
|
||||
<p class="font-700 text-xl text-red-400">{data.rating.losses}</p>
|
||||
<p class="mt-0.5 text-[11px] text-text-secondary">Losses</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Teams -->
|
||||
{#if data.teams.length > 0}
|
||||
<div class="mt-6">
|
||||
<h2 class="font-600 text-sm text-text-secondary">Teams</h2>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
{#each data.teams as team}
|
||||
<a
|
||||
href="/teams/{team.id}"
|
||||
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
|
||||
>
|
||||
{#if team.avatarUrl}
|
||||
<img src={team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||
{:else}
|
||||
<div
|
||||
class="font-700 flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs text-text-secondary"
|
||||
>
|
||||
{team.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="font-600 flex-1 text-sm">{team.name}</span>
|
||||
<span class="text-xs text-text-secondary">{team.role}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Recent Matches -->
|
||||
{#if data.recentMatches.length > 0}
|
||||
<div class="mt-6">
|
||||
<h2 class="font-600 text-sm text-text-secondary">Recent Matches</h2>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
{#each data.recentMatches as m}
|
||||
{@const p1 = m.participants[0]}
|
||||
{@const p2 = m.participants[1]}
|
||||
{@const won = data.personalTeamId ? m.winnerId === data.personalTeamId : false}
|
||||
<a
|
||||
href="/matches/{m.id}"
|
||||
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 px-4 py-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
|
||||
>
|
||||
<span class="font-600 text-sm">{p1?.team.name ?? '?'}</span>
|
||||
{#if m.state === 'FINISHED'}
|
||||
<span
|
||||
class="font-700 text-xs tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
|
||||
? 'text-green-400'
|
||||
: 'text-text-secondary'}">{p1?.score ?? 0}</span
|
||||
>
|
||||
<span class="text-xs text-text-secondary">-</span>
|
||||
<span
|
||||
class="font-700 text-xs tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0)
|
||||
? 'text-green-400'
|
||||
: 'text-text-secondary'}">{p2?.score ?? 0}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-text-secondary">vs</span>
|
||||
{/if}
|
||||
<span class="font-600 text-sm">{p2?.team.name ?? '?'}</span>
|
||||
{#if m.state === 'FINISHED'}
|
||||
<span class="ml-auto text-xs {won ? 'font-600 text-green-400' : 'text-red-400'}"
|
||||
>{won ? 'W' : 'L'}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="ml-auto text-xs text-text-secondary">{m.state}</span>
|
||||
{/if}
|
||||
<span class="text-xs text-text-secondary/50">{timeAgo(m.createdAt)}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ProfileComments comments={data.comments} actionUrl="?/comment" />
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
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 type { RequestHandler } from './$types';
|
||||
|
||||
@@ -18,7 +18,11 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
|
||||
// Create a mappool (admin only)
|
||||
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 { name } = parseBody(createMappoolSchema, body);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
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 type { RequestHandler } from './$types';
|
||||
|
||||
@@ -20,7 +20,11 @@ export const GET: RequestHandler = async ({ 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 { name, config, mappoolId, teams } = parseBody(createMatchSchema, body);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// src/routes/api/matches/[id]/lobby/+server.ts
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
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(() => ({}));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/routes/api/matches/[id]/pick/+server.ts
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
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 { pickMapSchema, parseBody } from '$lib/server/validation';
|
||||
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
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/routes/api/matches/[id]/roll/+server.ts
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
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 { submitRollSchema, parseBody } from '$lib/server/validation';
|
||||
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
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// src/routes/api/matches/[id]/rolling/+server.ts
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
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 {
|
||||
const m = await moveToRolling(params.id);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// src/routes/api/matches/[id]/score/+server.ts
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
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 type { RequestHandler } from './$types';
|
||||
|
||||
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 { matchGameId, scores } = parseBody(submitScoreSchema, body);
|
||||
|
||||
56
src/routes/api/search/+server.ts
Normal file
56
src/routes/api/search/+server.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/auth.schema';
|
||||
import { team, mappool, tournament, match } from '$lib/server/db/schema';
|
||||
import { ilike, and, eq } from 'drizzle-orm';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const q = url.searchParams.get('q')?.trim();
|
||||
if (!q || q.length < 2) return json({ results: {} });
|
||||
|
||||
const pattern = `%${q}%`;
|
||||
const limit = 5;
|
||||
|
||||
const [users, teams, mappools, tournaments, matches] = await Promise.all([
|
||||
db
|
||||
.select({ id: user.id, name: user.name, image: user.image })
|
||||
.from(user)
|
||||
.where(ilike(user.name, pattern))
|
||||
.limit(limit),
|
||||
db
|
||||
.select({ id: team.id, name: team.name, avatarUrl: team.avatarUrl })
|
||||
.from(team)
|
||||
.where(and(ilike(team.name, pattern), eq(team.isPersonal, false)))
|
||||
.limit(limit),
|
||||
db
|
||||
.select({ id: mappool.id, name: mappool.name })
|
||||
.from(mappool)
|
||||
.where(ilike(mappool.name, pattern))
|
||||
.limit(limit),
|
||||
db
|
||||
.select({ id: tournament.id, name: tournament.name, state: tournament.state })
|
||||
.from(tournament)
|
||||
.where(ilike(tournament.name, pattern))
|
||||
.limit(limit),
|
||||
db
|
||||
.select({ id: match.id, name: match.name, state: match.state })
|
||||
.from(match)
|
||||
.where(ilike(match.name, pattern))
|
||||
.limit(limit)
|
||||
]);
|
||||
|
||||
return json({
|
||||
results: {
|
||||
users: users.map((u) => ({ ...u, type: 'user', href: `/users/${u.id}` })),
|
||||
teams: teams.map((t) => ({ ...t, type: 'team', href: `/teams/${t.id}` })),
|
||||
mappools: mappools.map((m) => ({ ...m, type: 'mappool', href: `/mappools/${m.id}` })),
|
||||
tournaments: tournaments.map((t) => ({
|
||||
...t,
|
||||
type: 'tournament',
|
||||
href: `/tournaments/${t.id}`
|
||||
})),
|
||||
matches: matches.map((m) => ({ ...m, type: 'match', href: `/matches/${m.id}` }))
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournament, tournamentStaff } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { tournament } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||
import { getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
@@ -13,8 +13,7 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
await requireTournamentStaff(params.id, user.id);
|
||||
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_SETTINGS);
|
||||
|
||||
const t = await db.query.tournament.findFirst({
|
||||
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 }) => {
|
||||
const user = requireAuth(locals);
|
||||
await requireTournamentStaff(params.id, user.id);
|
||||
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_SETTINGS);
|
||||
|
||||
const t = await db.query.tournament.findFirst({
|
||||
where: eq(tournament.id, params.id)
|
||||
@@ -73,11 +71,3 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
await cancelTournament(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;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournamentStaff } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||
import { cancelTournament, getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
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 requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_SETTINGS);
|
||||
|
||||
try {
|
||||
await cancelTournament(params.id);
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournamentStaff } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||
import { closeRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
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 requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_REGISTRATIONS);
|
||||
|
||||
try {
|
||||
await closeRegistration(params.id);
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournamentStaff } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||
import { finalizeSeeding, getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
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 requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_BRACKET);
|
||||
|
||||
try {
|
||||
await finalizeSeeding(params.id);
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournamentStaff } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||
import { openRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
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 requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_REGISTRATIONS);
|
||||
|
||||
try {
|
||||
await openRegistration(params.id);
|
||||
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
getQualifierMappool,
|
||||
submitQualifierScore
|
||||
} from '$lib/server/tournament/qualifiers';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournamentStaff } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
@@ -20,13 +17,7 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
// 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');
|
||||
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.REFEREE_MATCHES);
|
||||
|
||||
const body = await request.json();
|
||||
const { teamId, mappoolSlotId, totalScore, accuracy } = body;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournamentRound, tournamentStaff } from '$lib/server/db/schema';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { tournamentRound } from '$lib/server/db/schema';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
@@ -15,19 +15,13 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
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 requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_ROUNDS);
|
||||
|
||||
const body = await request.json();
|
||||
const { name, abbreviation, bestOf, mappoolId, bracketType, scheduledAt } = body;
|
||||
|
||||
if (!name || !bestOf) error(400, 'Missing required fields: name, bestOf');
|
||||
|
||||
// get next round order
|
||||
const existing = await db.query.tournamentRound.findMany({
|
||||
where: eq(tournamentRound.tournamentId, params.id)
|
||||
});
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournamentRound, tournamentStaff } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { tournamentRound } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
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 requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_ROUNDS);
|
||||
|
||||
const body = await request.json();
|
||||
const updates: Record<string, unknown> = {};
|
||||
@@ -38,12 +33,7 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
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 requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_ROUNDS);
|
||||
|
||||
await db.delete(tournamentRound).where(eq(tournamentRound.id, params.roundId));
|
||||
return json({ deleted: true });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournamentStaff } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
@@ -13,15 +13,7 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
// 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');
|
||||
}
|
||||
await requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_STAFF);
|
||||
|
||||
const body = await request.json();
|
||||
if (!body.userId || !body.role) error(400, 'Missing userId or role');
|
||||
@@ -41,7 +33,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
})
|
||||
.returning();
|
||||
return json(created, { status: 201 });
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
error(400, 'User is already a staff member');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournamentStaff } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
|
||||
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 requireTournamentStaffOrAdmin(locals, params.id, TournamentPermission.MANAGE_STAFF);
|
||||
await db.delete(tournamentStaff).where(eq(tournamentStaff.id, params.staffId));
|
||||
return json({ deleted: true });
|
||||
};
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { tournamentStaff } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/permissions';
|
||||
import { requireTournamentStaffOrAdmin, TournamentPermission } from '$lib/server/permissions';
|
||||
import { getTournamentFull } from '$lib/server/tournament/lifecycle';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
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');
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user