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:
120
src/routes/(app)/teams/+page.server.ts
Normal file
120
src/routes/(app)/teams/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
229
src/routes/(app)/teams/+page.svelte
Normal file
229
src/routes/(app)/teams/+page.svelte
Normal 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' : ''}">›</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">×</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>
|
||||
Reference in New Issue
Block a user