feat: implement team management features including team creation, member addition, and deletion

- Added a new page for managing teams with functionalities to create teams, add members, and delete teams.
- Enhanced the matches page with improved match creation form and live match display.
- Refactored state handling for match states and improved UI for displaying match information.
This commit is contained in:
2026-02-28 05:03:11 +01:00
parent e147ba4432
commit f4b94597c7
6 changed files with 777 additions and 360 deletions

View File

@@ -0,0 +1,120 @@
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 type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(302, '/');
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 u = await db.query.user.findFirst({
where: eq(user.id, m.userId)
});
return { ...m, user: u ? { id: u.id, name: u.name, image: u.image } : null };
})
);
return { ...t, members: membersWithUsers };
})
);
return { teams: teamsWithUsers, userId: locals.user.id };
};
export const actions: Actions = {
create: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/');
const form = await request.formData();
const name = form.get('name')?.toString()?.trim();
if (!name) return { error: 'Team name is required' };
const [created] = await db
.insert(team)
.values({
name,
ownerId: locals.user.id,
isPersonal: false
})
.returning();
// Add creator as captain
await db.insert(teamMember).values({
teamId: created.id,
userId: locals.user.id,
role: 'captain'
});
return { success: true };
},
addMember: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/');
const form = await request.formData();
const teamId = form.get('teamId')?.toString();
const username = form.get('username')?.toString()?.trim();
if (!teamId || !username) return { error: 'Team and username are required' };
// 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))
});
if (existing) return { error: `${username} is already on this team` };
await db.insert(teamMember).values({
teamId,
userId: u.id,
role: 'member'
});
return { success: true };
},
removeMember: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/');
const form = await request.formData();
const memberId = form.get('memberId')?.toString();
if (!memberId) return { error: 'Missing member ID' };
await db.delete(teamMember).where(eq(teamMember.id, memberId));
return { success: true };
},
deleteTeam: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/');
const form = await request.formData();
const teamId = form.get('teamId')?.toString();
if (!teamId) return { error: 'Missing team ID' };
// Only allow owner to delete
const t = await db.query.team.findFirst({ where: eq(team.id, teamId) });
if (!t || t.ownerId !== locals.user.id) return { error: 'Not authorized' };
await db.delete(team).where(eq(team.id, teamId));
return { success: true };
}
};

View File

@@ -0,0 +1,229 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
let { data } = $props();
let creating = $state(false);
let expandedTeam = $state<string | null>(null);
let addingMemberTo = $state<string | null>(null);
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.members.some((m: any) => m.userId === data.userId)
));
function isOwner(t: any) {
return t.ownerId === data.userId;
}
</script>
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-700 tracking-tight">Teams</h1>
<p class="mt-1 text-sm text-text-secondary">
{data.teams.length} team{data.teams.length !== 1 ? 's' : ''}
</p>
</div>
<button
onclick={() => (creating = !creating)}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
>
{creating ? 'Cancel' : 'New Team'}
</button>
</div>
<!-- Create Team -->
{#if creating}
<form
method="post"
action="?/create"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success' && (result.data as any)?.success) {
creating = false;
await update();
}
};
}}
class="mt-4 flex gap-3 rounded-lg border border-border bg-surface-800 p-4"
>
<input
type="text"
name="name"
placeholder="Team name"
required
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
<button
type="submit"
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
>
Create
</button>
</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="text-sm font-600 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="flex h-10 w-10 items-center justify-center rounded-full bg-surface-600 text-sm font-700 text-text-secondary">
{t.name.charAt(0).toUpperCase()}
</div>
{/if}
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-600">{t.name}</span>
{#if t.isPersonal}
<span class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-500 text-text-secondary">solo</span>
{/if}
</div>
<p class="mt-0.5 text-xs text-text-secondary">
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
</p>
</div>
<span class="text-sm text-text-secondary transition-transform {expandedTeam === t.id ? 'rotate-90' : ''}">&rsaquo;</span>
</button>
<!-- Expanded: members -->
{#if expandedTeam === t.id}
<div class="border-t border-border px-4 pb-4 pt-3">
<!-- Member list -->
<div class="flex flex-col gap-2">
{#each t.members as member}
<div class="flex items-center gap-3 rounded-md bg-surface-700 px-3 py-2">
{#if member.user?.image}
<img src={member.user.image} alt="" class="h-6 w-6 rounded-full" />
{:else}
<div class="h-6 w-6 rounded-full bg-surface-600"></div>
{/if}
<span class="flex-1 text-sm">{member.user?.name ?? 'Unknown'}</span>
<span class="text-xs text-text-secondary">{member.role}</span>
{#if isOwner(t) && member.userId !== data.userId}
<form method="post" action="?/removeMember" use:enhance={() => {
return async ({ update }) => { await update(); };
}}>
<input type="hidden" name="memberId" value={member.id} />
<button type="submit" class="text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100">&times;</button>
</form>
{/if}
</div>
{/each}
</div>
<!-- Add member -->
{#if isOwner(t) && !t.isPersonal}
{#if addingMemberTo === t.id}
<form
method="post"
action="?/addMember"
use:enhance={() => {
actionError = '';
return async ({ result, update }) => {
if (result.type === 'success' && (result.data as any)?.success) {
memberUsername = '';
addingMemberTo = null;
await update();
} else if (result.type === 'success' && (result.data as any)?.error) {
actionError = (result.data as any).error;
}
};
}}
class="mt-3 flex gap-2"
>
<input type="hidden" name="teamId" value={t.id} />
<input
type="text"
name="username"
bind:value={memberUsername}
placeholder="osu! username"
required
class="flex-1 rounded-md border border-border bg-surface-600 px-3 py-1.5 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
<button type="submit" class="rounded-md bg-accent px-3 py-1.5 text-xs font-600 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="text-sm font-600 text-text-secondary">Other Teams</h2>
<div class="mt-3 flex flex-col gap-2">
{#each otherTeams as t}
<div class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3">
{#if t.avatarUrl}
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
{:else}
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary">
{t.name.charAt(0).toUpperCase()}
</div>
{/if}
<div class="flex-1">
<span class="text-sm font-600">{t.name}</span>
<p class="text-xs text-text-secondary">{t.members.length} member{t.members.length !== 1 ? 's' : ''}</p>
</div>
</div>
{/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}
</div>