switch to sveltekit and make big progress

This commit is contained in:
unurled 2025-07-05 23:21:18 +02:00
parent 73c32b4fb6
commit a8d502f2ee
531 changed files with 3468 additions and 27682 deletions

View file

@ -0,0 +1,9 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => {
return {
user: locals.user,
roles: locals.roles,
permissions: locals.permissions
};
};

23
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,23 @@
<script lang="ts">
import { ModeWatcher } from 'mode-watcher';
import '../app.css';
import { setContext } from 'svelte';
import type { PageProps } from './$types';
import { Toaster } from '@/components/ui/sonner';
let {
children,
data
}: {
children: any;
data: PageProps['data'];
} = $props();
setContext('user', data.user);
setContext('roles', data.roles);
setContext('permissions', data.permissions);
</script>
<Toaster />
<ModeWatcher />
{@render children()}

View file

@ -0,0 +1,38 @@
import { db } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
// Check if user is authenticated
const user = locals.user;
// If user is not authenticated, return an empty array for competitions
if (!user) {
return {
competitions: []
};
}
try {
// Fetch competitions from the database
const userCompetitions = db.query.competitions.findMany({
orderBy: (competitions, { desc }) => [desc(competitions.start_date)],
limit: 10,
with: {
breakperiods: true,
fields: true,
matches: true,
scheduling_modes: true,
teams: true
}
});
return {
competitions: await userCompetitions
};
} catch (error) {
console.error('Error fetching competitions:', error);
return {
competitions: []
};
}
};

204
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,204 @@
<script lang="ts">
import { getContext } from 'svelte';
import AppLayout from '$lib/components/app-layout.svelte';
import { Button } from '$lib/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Calendar, Users, MapPin } from 'lucide-svelte';
import type { BreadcrumbItem } from '../app';
import { Skeleton } from '$lib/components/ui/skeleton';
import type { PageProps } from './$types.js';
import { toast } from 'svelte-sonner';
import { formatDate } from '@/utils';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Home',
href: '/'
}
];
// Get user context
const user = getContext('user');
// Get competitions data from server
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>Home - FlbxCup</title>
<meta name="description" content="FlbxCup - Competition Management Platform" />
</svelte:head>
<AppLayout {breadcrumbs}>
<div class="space-y-8">
<!-- Hero Section -->
<section
class="from-primary/10 via-primary/5 to-background rounded-xl bg-gradient-to-r p-8 text-center"
>
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl">
Welcome to <span class="text-primary">FlbxCup</span>
</h1>
<p class="text-muted-foreground mx-auto mt-6 max-w-xl text-lg">
Your platform for managing competitions, teams, and scheduling all in one place.
</p>
{#if !user}
<div class="mt-8 flex flex-wrap justify-center gap-4">
<Button onclick={() => (window.location.href = '/auth/login')}>Sign In</Button>
<Button variant="outline">Learn More</Button>
</div>
{/if}
</section>
<!-- Competitions Section -->
<section>
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold tracking-tight">Competitions</h2>
{#if user}
<Button size="sm" variant="outline">
<span>View All</span>
</Button>
{/if}
</div>
{#if !user}
<div class="bg-card/50 mt-6 rounded-xl border p-12 text-center">
<h3 class="text-xl font-medium">Sign in to view available competitions</h3>
<p class="text-muted-foreground mt-2">
Access all competitions, teams, and match schedules by signing in to your account.
</p>
<div class="mt-6">
<Button onclick={() => (window.location.href = '/auth/login')}>Sign In</Button>
</div>
</div>
{:else}
{#await data.competitions}
<div class="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(3) as _}
<Card>
<CardHeader>
<Skeleton class="h-6 w-3/4" />
<Skeleton class="h-4 w-1/2" />
</CardHeader>
<CardContent>
<div class="space-y-4">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-3/4" />
</div>
</CardContent>
<CardFooter>
<Skeleton class="h-9 w-full" />
</CardFooter>
</Card>
{/each}
</div>
{:then competitions}
{#if data.competitions.length === 0}
<Card class="mt-6">
<CardHeader>
<p>No competitions found</p>
</CardHeader>
<CardFooter>
<Button variant="default" href="/competitions/new">Create Competition</Button>
</CardFooter>
</Card>
{:else}
<div class="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each competitions as competition}
<Card>
<CardHeader>
<div class="flex items-start justify-between">
<div>
<CardTitle>{competition.name}</CardTitle>
<CardDescription class="mt-1">ID: {competition.id}</CardDescription>
</div>
<Badge variant="outline">
{competition.teams?.length || 0} Teams
</Badge>
</div>
</CardHeader>
<CardContent>
<p class="text-muted-foreground text-sm">{competition.description}</p>
<div class="mt-4 space-y-2">
<div class="flex items-center text-sm">
<Calendar class="text-muted-foreground mr-2 h-4 w-4" />
<span>{formatDate(competition.start_date)}</span>
</div>
<div class="flex items-center text-sm">
<MapPin class="text-muted-foreground mr-2 h-4 w-4" />
<span>{competition.location}</span>
</div>
<div class="flex items-center text-sm">
<Users class="text-muted-foreground mr-2 h-4 w-4" />
<span> {competition.teams?.length || 0} teams registered max</span>
</div>
</div>
</CardContent>
<CardFooter>
<Button
class="w-full"
variant="default"
href={`/competitions/${competition.id}`}
>
View Details
</Button>
</CardFooter>
</Card>
{/each}
</div>
{/if}
{:catch error}
{toast(error)}
{/await}
{/if}
</section>
<!-- Features Section -->
<section class="py-8">
<h2 class="mb-6 text-2xl font-semibold tracking-tight">Platform Features</h2>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Competition Management</CardTitle>
</CardHeader>
<CardContent>
<p class="text-muted-foreground text-sm">
Create and manage competitions with customizable settings, team limits, and scheduling
options.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Team Registration</CardTitle>
</CardHeader>
<CardContent>
<p class="text-muted-foreground text-sm">
Streamline team registration with custom forms, roster management, and team
communication tools.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Smart Scheduling</CardTitle>
</CardHeader>
<CardContent>
<p class="text-muted-foreground text-sm">
Automatically generate fair match schedules, manage fields, and handle complex
tournament formats.
</p>
</CardContent>
</Card>
</div>
</section>
</div>
</AppLayout>

View file

@ -0,0 +1,108 @@
import { createSession, keycloak, setSessionTokenCookie } from '$lib/server/auth';
import { db } from '$lib/server/db';
import { users, type User } from '$lib/server/db/schema/users';
import type { RequestEvent } from '@sveltejs/kit';
import { decodeIdToken, type OAuth2Tokens } from 'arctic';
import { eq } from 'drizzle-orm';
import { v7 as uuid } from 'uuid';
export async function GET(event: RequestEvent): Promise<Response> {
const code = event.url.searchParams.get('code');
const state = event.url.searchParams.get('state');
const storedState = event.cookies.get('keycloak_oauth_state') ?? null;
const codeVerifier = event.cookies.get('keycloak_code_verifier') ?? null;
if (code === null || state === null || storedState === null || codeVerifier === null) {
return new Response(null, {
status: 400
});
}
if (state !== storedState) {
return new Response(null, {
status: 400
});
}
let tokens: OAuth2Tokens;
try {
tokens = await keycloak.validateAuthorizationCode(code, codeVerifier);
} catch {
// Invalid code or client credentials
return new Response(null, {
status: 400
});
}
const claims = decodeIdToken(tokens.idToken());
if (
!(
'sub' in claims &&
typeof claims.sub === 'string' &&
'name' in claims &&
typeof claims.name === 'string' &&
'email' in claims &&
typeof claims.email === 'string'
)
) {
// Invalid token
return new Response(null, {
status: 400
});
}
const keycloakUserId = claims.sub;
const username = claims.name;
const email = claims.email;
// TODO: Replace this with your own DB query.
const existingUser = await getUserFromKeycloakId(keycloakUserId);
if (existingUser !== null) {
const session = await createSession(existingUser.id);
setSessionTokenCookie(event, session.token, session.session.expires_at);
return new Response(null, {
status: 302,
headers: {
Location: '/'
}
});
}
const user = await createUser(keycloakUserId, username, email);
const session = await createSession(user.id);
setSessionTokenCookie(event, session.token, session.session.expires_at);
return new Response(null, {
status: 302,
headers: {
Location: '/'
}
});
}
async function getUserFromKeycloakId(keycloakUserId: string): Promise<User | null> {
const [result] = await db
.select({
user: users
})
.from(users)
.where(eq(users.oauth_id, keycloakUserId));
return result ? result.user : null;
}
async function createUser(keycloakUserId: string, username: string, email: string): Promise<User> {
const [result] = await db
.insert(users)
.values({
id: uuid(),
oauth_id: keycloakUserId,
username,
email,
created_at: new Date(Date.now()),
deleted_at: new Date(),
updated_at: new Date(Date.now())
})
.returning();
// add user to default role
console.log(JSON.stringify(result));
return result;
}

View file

@ -0,0 +1,29 @@
import { keycloak } from '$lib/server/auth';
import type { RequestEvent } from '@sveltejs/kit';
import { generateCodeVerifier, generateState } from 'arctic';
export async function GET(event: RequestEvent): Promise<Response> {
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = keycloak.createAuthorizationURL(state, codeVerifier, ['openid', 'profile']);
event.cookies.set('keycloak_oauth_state', state, {
path: '/',
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax'
});
event.cookies.set('keycloak_code_verifier', codeVerifier, {
path: '/',
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax'
});
return new Response(null, {
status: 302,
headers: {
Location: url.toString()
}
});
}

View file

@ -0,0 +1,12 @@
import * as auth from '$lib/server/auth';
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, locals, cookies }) => {
if (!locals.session) {
return new Response(null, { status: 302, headers: { Location: url.origin } });
}
await auth.invalidateSession(locals.session.id);
auth.deleteSessionTokenCookie(cookies);
return redirect(302, '/');
};

View file

@ -0,0 +1,168 @@
import { db } from '@/server/db';
import type { Actions, PageServerLoad } from './$types';
import { competitions } from '@/server/db/schema/competitions';
import { error, redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { and } from 'drizzle-orm';
import { teams } from '@/server/db/schema/teams';
export const load: PageServerLoad = async ({ params, locals }) => {
try {
const id = Number.parseInt(params.id);
const competition = await db.query.competitions.findFirst({
where: and(eq(competitions.id, id), eq(competitions.owner, locals.user.id)),
with: {
breakperiods: true,
fields: true,
matches: true,
scheduling_modes: true,
teams: true
}
});
if (!competition) {
throw error(404, 'Competition not found');
}
return {
competition: competition
};
} catch (e) {
return redirect(302, '/');
}
};
export const actions = {
addTeam: async (event) => {
const data = await event.request.formData();
const teamName = data.get('team_name')?.toString();
if (!teamName) {
throw error(400, 'Team name is required');
}
// get competition id from url
const id = Number.parseInt(event.params.id, 10);
if (id === -1) {
throw error(400, 'Invalid competition id');
}
let [competition] = await db
.select()
.from(competitions)
.leftJoin(teams, eq(competitions.id, teams.competition_id))
.where(and(eq(competitions.id, id), eq(competitions.owner, event.locals.user.id)));
if (!competition) {
throw error(404, 'Competition not found');
}
// add team to competition's teams array
await db.insert(teams).values({
competition_id: id,
name: teamName
});
const comp = await db.query.competitions.findFirst({
where: and(eq(competitions.id, id), eq(competitions.owner, event.locals.user.id)),
with: {
breakperiods: true,
fields: true,
matches: true,
scheduling_modes: true,
teams: true
}
});
return {
comp
};
},
deleteTeam: async (event) => {
const data = await event.request.formData();
const team = data.get('id')?.toString();
if (!team) {
throw error(400, 'Team name is required');
}
const teamId = Number.parseInt(team, 10);
// get competition id from url
const id = Number.parseInt(event.params.id, 10);
if (id === -1) {
throw error(400, 'Invalid competition id');
}
let [competition] = await db
.select()
.from(competitions)
.leftJoin(teams, eq(competitions.id, teams.competition_id))
.where(and(eq(competitions.id, id), eq(competitions.owner, event.locals.user.id)));
if (!competition) {
throw error(404, 'Competition not found');
}
// remove team to competition's teams array
await db.delete(teams).where(and(eq(teams.id, teamId), eq(teams.competition_id, id)));
const comp = await db.query.competitions.findFirst({
where: and(eq(competitions.id, id), eq(competitions.owner, event.locals.user.id)),
with: {
breakperiods: true,
fields: true,
matches: true,
scheduling_modes: true,
teams: true
}
});
return {
comp
};
},
updateDescription: async (event) => {
const data = await event.request.formData();
const description = data.get('description')?.toString();
// get competition id from url
const id = Number.parseInt(event.params.id, 10);
if (id === -1) {
console.log(id);
throw error(400, 'Invalid competition id');
}
let [competition] = await db
.select()
.from(competitions)
.leftJoin(teams, eq(competitions.id, teams.competition_id))
.where(and(eq(competitions.id, id), eq(competitions.owner, event.locals.user.id)));
if (!competition) {
console.log(competition);
throw error(404, 'Competition not found');
}
// update competition's description
await db.update(competitions).set({ description }).where(eq(competitions.id, id));
const comp = await db.query.competitions.findFirst({
where: and(eq(competitions.id, id), eq(competitions.owner, event.locals.user.id)),
with: {
breakperiods: true,
fields: true,
matches: true,
scheduling_modes: true,
teams: true
}
});
return {
comp
};
}
} satisfies Actions;

View file

@ -0,0 +1,149 @@
<script lang="ts">
import Badge from '@/components/ui/badge/badge.svelte';
import type { PageProps } from './$types';
import { CalendarDays, MapPin, Users, Trophy, Clock, Trash2Icon } from 'lucide-svelte';
import { formatDate } from '@/utils';
import Button from '@/components/ui/button/button.svelte';
import Card from '@/components/ui/card/card.svelte';
import CardHeader from '@/components/ui/card/card-header.svelte';
import CardContent from '@/components/ui/card/card-content.svelte';
import CardFooter from '@/components/ui/card/card-footer.svelte';
import Input from '@/components/ui/input/input.svelte';
import { enhance } from '$app/forms';
import Textarea from '@/components/ui/textarea/textarea.svelte';
let { data }: PageProps = $props();
let showInputTeam = $state(false);
let showInputDescription = $state(false);
let description = $state(data.competition.description);
function clearAndCloseDescription() {
showInputDescription = false;
}
</script>
<svelte:head>
<title>{data.competition.name} | Competition Details</title>
</svelte:head>
<div class="container mx-auto max-w-4xl px-4 py-8">
<div class="mb-8 flex items-start justify-between">
<div>
<h1 class="text-3xl font-bold">{data.competition.name}</h1>
<div class="mt-2 flex items-center gap-2">
<Badge variant="outline" class="flex items-center gap-1">
<CalendarDays class="h-4 w-4" />
{formatDate(new Date(data.competition.start_date))}
</Badge>
{#if data.competition.location}
<Badge variant="outline" class="flex items-center gap-1">
<MapPin class="h-4 w-4" />
{data.competition.location}
</Badge>
{/if}
<Badge variant="outline" class="flex items-center gap-1">
<Users class="h-4 w-4" />
Teams: {data.competition.teams?.length || 0}
</Badge>
</div>
</div>
<div class="flex gap-2">
<Button variant="destructive">Delete</Button>
</div>
</div>
<div class="grid gap-6 md:grid-cols-3">
<Card class=" md:col-span-2">
<CardHeader>
<h2 class=" text-xl font-semibold">Description</h2>
</CardHeader>
<CardContent>
{#if showInputDescription}
<form method="post" action="?/updateDescription" class="flex flex-col gap-4" use:enhance>
<Textarea name="description" placeholder="Description" bind:value={description} />
<div class="flex gap-2">
<Button formaction="?/updateDescription" type="submit">Save</Button>
<Button onclick={() => clearAndCloseDescription()}>Cancel</Button>
</div>
</form>
{:else if data.competition.description}
<p class="text-gray-700 dark:text-gray-300">{data.competition.description}</p>
{:else}
<p class="text-gray-500 italic">No description available</p>
{/if}
</CardContent>
{#if !showInputDescription}
<CardFooter class="flex gap-2">
<Button onclick={() => (showInputDescription = true)}>Edit</Button>
</CardFooter>
{/if}
</Card>
<Card>
<CardHeader>
<h2 class="text-xl font-semibold">Details</h2>
</CardHeader>
<CardContent>
<div class="flex items-center gap-2">
<Clock class="h-5 w-5 text-blue-500" />
<span>Created: {formatDate(new Date(data.competition.created_at))}</span>
</div>
{#if data.competition.updated_at}
<div class="flex items-center gap-2">
<Clock class="h-5 w-5 text-green-500" />
<span>Updated: {formatDate(new Date(data.competition.updated_at))}</span>
</div>
{/if}
</CardContent>
<CardFooter></CardFooter>
</Card>
</div>
<Card class="mt-6">
<CardHeader>
<h2 class="text-xl font-semibold">Teams</h2>
</CardHeader>
<CardContent>
{#if !data.competition.teams || data.competition.teams.length === 0}
<p class="text-gray-500 italic">No teams have been added yet</p>
{:else}
<div class="flex flex-col gap-4">
{#each data.competition.teams as team}
<div class="">
<form
method="post"
action="?/deleteTeam"
class="flex items-center justify-between gap-2"
use:enhance
>
<div class="flex items-center gap-4">
<Users class="h-5 w-5 text-indigo-500" />
<input type="hidden" name="id" value={team.id} />
<span>{team.name}</span>
</div>
<Button formaction="?/deleteTeam" variant="destructive" type="submit"
>Delete<Trash2Icon /></Button
>
</form>
</div>
{/each}
</div>
{/if}
{#if showInputTeam}
<form method="post" action="?/addTeam" class="mt-4 flex gap-4" use:enhance>
<Input name="team_name" placeholder="Team Name" />
<Button formaction="?/addTeam" type="submit">Save</Button>
</form>
{/if}
</CardContent>
<CardFooter>
<Button
disabled={showInputTeam}
onclick={() => {
showInputTeam = true;
}}>Add</Button
>
</CardFooter>
</Card>
</div>

View file

@ -0,0 +1,56 @@
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { competitions } from '$lib/server/db/schema/competitions';
import type { Actions } from './$types';
export const actions = {
default: async (event) => {
const data = await event.request.formData();
console.log('data', data);
const name = data.get('name')?.toString();
const description = data.get('description')?.toString() || null;
const startDateStr = data.get('start_date')?.toString();
const location = data.get('location')?.toString() || null;
// Validate required fields
if (!name) {
console.log('name', name);
return fail(400, { error: 'Competition name is required' });
}
if (!startDateStr) {
console.log('startDateStr', startDateStr);
return fail(400, { error: 'Start date is required' });
}
let startDate: Date;
try {
startDate = new Date(startDateStr);
if (isNaN(startDate.getTime())) {
console.log('startDate', startDate);
throw new Error('Invalid date');
}
} catch {
console.log('invalid start date format');
return fail(400, { error: 'Invalid start date format' });
}
// Create the competition
const [competition] = await db
.insert(competitions)
.values({
name,
description,
start_date: startDate,
location,
owner: event.locals.user.id,
current_scheduling_mode_id: -1, // This will be updated after creating the scheduling mode
created_at: new Date(),
updated_at: new Date()
})
.returning();
throw redirect(303, `/competitions/${competition.id}`);
}
} satisfies Actions;

View file

@ -0,0 +1,167 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { CalendarIcon, MapPin, Users, Trophy } from 'lucide-svelte';
let formData = $state({
name: '',
description: '',
start_date: '',
location: '',
max_number_teams: 10,
current_scheduling_mode_id: ''
});
let errors: any = $state({});
let isSubmitting = $state(false);
</script>
<div class="from-primary/5 to-primary/10 min-h-screen bg-gradient-to-br p-4">
<div class="mx-auto max-w-2xl">
<div class="mb-8 text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-600 text-white"
>
<Trophy class="h-8 w-8" />
</div>
<h1 class="text-3xl font-bold">Create New Competition</h1>
<p class="mt-2 text-gray-400">Set up your tournament and start competing</p>
</div>
<Card class="shadow-lg">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Trophy class="h-5 w-5" />
Competition Details
</CardTitle>
<CardDescription>Fill in the information below to create your competition</CardDescription>
</CardHeader>
<CardContent>
<form method="POST" class="space-y-6">
<div class="grid gap-6 md:grid-cols-2">
<!-- Competition Name -->
<div class="space-y-2 md:col-span-2">
<Label for="name">Competition Name *</Label>
<Input
id="name"
name="name"
bind:value={formData.name}
placeholder="Enter competition name"
class={errors.name ? 'border-red-500' : ''}
/>
{#if errors.name}
<p class="text-sm text-red-500">{errors.name}</p>
{/if}
</div>
<!-- Start Date -->
<div class="space-y-2">
<Label for="start_date">Start Date *</Label>
<div class="relative">
<Input
name="start_date"
id="start_date"
type="datetime-local"
bind:value={formData.start_date}
class={errors.start_date ? 'border-red-500' : ''}
/>
<CalendarIcon
class="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 text-gray-400"
/>
</div>
{#if errors.start_date}
<p class="text-sm text-red-500">{errors.start_date}</p>
{/if}
</div>
<!-- Location -->
<div class="space-y-2">
<Label for="location">Location</Label>
<div class="relative">
<Input
name="location"
id="location"
bind:value={formData.location}
placeholder="Competition venue"
/>
<MapPin class="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
</div>
</div>
<!-- Max Number of Teams -->
<div class="space-y-2">
<Label for="max_teams">Maximum Teams *</Label>
<div class="relative">
<Input
id="max_teams"
name="max_teams"
type="number"
min="2"
bind:value={formData.max_number_teams}
placeholder="e.g., 16"
class={errors.max_number_teams ? 'border-red-500' : ''}
/>
<Users class="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
</div>
{#if errors.max_number_teams}
<p class="text-sm text-red-500">{errors.max_number_teams}</p>
{/if}
</div>
</div>
<!-- Description -->
<div class="space-y-2">
<Label for="description">Description</Label>
<Textarea
name="description"
id="description"
bind:value={formData.description}
placeholder="Describe your competition, rules, prizes, etc."
/>
</div>
<!-- Submit Button -->
<div class="flex justify-end space-x-4">
<Button type="button" variant="outline">Cancel</Button>
<Button type="submit" class="min-w-32">Create Competition</Button>
</div>
</form>
</CardContent>
</Card>
<!-- Info Cards -->
<div class="mt-8 grid gap-4 md:grid-cols-3">
<Card class="text-center">
<CardContent class="pt-6">
<Trophy class="mx-auto mb-2 h-8 w-8 text-blue-600" />
<h3 class="font-semibold">Professional Setup</h3>
<p class="text-sm text-gray-600">Create tournaments with multiple scheduling modes</p>
</CardContent>
</Card>
<Card class="text-center">
<CardContent class="pt-6">
<Users class="mx-auto mb-2 h-8 w-8 text-green-600" />
<h3 class="font-semibold">Team Management</h3>
<p class="text-sm text-gray-600">Set team limits and manage participants</p>
</CardContent>
</Card>
<Card class="text-center">
<CardContent class="pt-6">
<CalendarIcon class="mx-auto mb-2 h-8 w-8 text-purple-600" />
<h3 class="font-semibold">Schedule Control</h3>
<p class="text-sm text-gray-600">Plan your competition timeline</p>
</CardContent>
</Card>
</div>
</div>
</div>