switch to sveltekit and make big progress
This commit is contained in:
parent
73c32b4fb6
commit
a8d502f2ee
531 changed files with 3468 additions and 27682 deletions
9
src/routes/+layout.server.ts
Normal file
9
src/routes/+layout.server.ts
Normal 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
23
src/routes/+layout.svelte
Normal 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()}
|
38
src/routes/+page.server.ts
Normal file
38
src/routes/+page.server.ts
Normal 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
204
src/routes/+page.svelte
Normal 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>
|
108
src/routes/auth/callback/+server.ts
Normal file
108
src/routes/auth/callback/+server.ts
Normal 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;
|
||||
}
|
29
src/routes/auth/login/+server.ts
Normal file
29
src/routes/auth/login/+server.ts
Normal 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()
|
||||
}
|
||||
});
|
||||
}
|
12
src/routes/auth/logout/+server.ts
Normal file
12
src/routes/auth/logout/+server.ts
Normal 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, '/');
|
||||
};
|
168
src/routes/competitions/[id]/+page.server.ts
Normal file
168
src/routes/competitions/[id]/+page.server.ts
Normal 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;
|
149
src/routes/competitions/[id]/+page.svelte
Normal file
149
src/routes/competitions/[id]/+page.svelte
Normal 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>
|
56
src/routes/competitions/new/+page.server.ts
Normal file
56
src/routes/competitions/new/+page.server.ts
Normal 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;
|
167
src/routes/competitions/new/+page.svelte
Normal file
167
src/routes/competitions/new/+page.svelte
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue