upcoming tournament page, minor changes
This commit is contained in:
229
src/features/teams/components/team-form.tsx
Normal file
229
src/features/teams/components/team-form.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
|
||||
import { useForm, UseFormInput } from "@mantine/form";
|
||||
import { LinkIcon } from "@phosphor-icons/react";
|
||||
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||
import { TournamentInput } from "@/features/tournaments/types";
|
||||
import { isNotEmpty } from "@mantine/form";
|
||||
import useCreateTournament from "../hooks/use-create-team";
|
||||
import useUpdateTournament from "../hooks/use-update-tournament";
|
||||
import toast from "@/lib/sonner";
|
||||
import { logger } from "..";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||
import { useCallback } from "react";
|
||||
import { DateTimePicker } from "@/components/date-time-picker";
|
||||
|
||||
interface TournamentFormProps {
|
||||
close: () => void;
|
||||
initialValues?: Partial<TournamentInput>;
|
||||
tournamentId?: string;
|
||||
}
|
||||
|
||||
const TournamentForm = ({
|
||||
close,
|
||||
initialValues,
|
||||
tournamentId,
|
||||
}: TournamentFormProps) => {
|
||||
const isEditMode = !!tournamentId;
|
||||
|
||||
const config: UseFormInput<TournamentInput> = {
|
||||
initialValues: {
|
||||
name: initialValues?.name || "",
|
||||
location: initialValues?.location || "",
|
||||
desc: initialValues?.desc || "",
|
||||
start_time: initialValues?.start_time || "",
|
||||
enroll_time: initialValues?.enroll_time || "",
|
||||
end_time: initialValues?.end_time || "",
|
||||
logo: undefined,
|
||||
},
|
||||
onSubmitPreventDefault: "always",
|
||||
validate: {
|
||||
name: isNotEmpty("Name is required"),
|
||||
location: isNotEmpty("Location is required"),
|
||||
start_time: isNotEmpty("Start time is required"),
|
||||
enroll_time: isNotEmpty("Enrollment time is required"),
|
||||
},
|
||||
};
|
||||
|
||||
const form = useForm(config);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: createTournament, isPending: createPending } =
|
||||
useCreateTournament();
|
||||
const { mutate: updateTournament, isPending: updatePending } =
|
||||
useUpdateTournament(tournamentId || "");
|
||||
|
||||
const isPending = createPending || updatePending;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (values: TournamentInput) => {
|
||||
const { logo, ...tournamentData } = values;
|
||||
|
||||
const mutation = isEditMode ? updateTournament : createTournament;
|
||||
const successMessage = isEditMode
|
||||
? "Tournament updated successfully!"
|
||||
: "Tournament created successfully!";
|
||||
const errorMessage = isEditMode
|
||||
? "Failed to update tournament"
|
||||
: "Failed to create tournament";
|
||||
|
||||
mutation(tournamentData, {
|
||||
onSuccess: async (tournament) => {
|
||||
if (logo && tournament) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("tournamentId", tournament.id);
|
||||
formData.append("logo", logo);
|
||||
|
||||
const response = await fetch("/api/tournaments/upload-logo", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Failed to upload logo");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.list });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tournamentKeys.details(result.tournament!.id),
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
tournamentKeys.details(result.tournament!.id),
|
||||
result.tournament
|
||||
);
|
||||
|
||||
toast.success(successMessage);
|
||||
} catch (error: any) {
|
||||
const logoErrorMessage = isEditMode
|
||||
? `Tournament updated but logo upload failed: ${error.message}`
|
||||
: `Tournament created but logo upload failed: ${error.message}`;
|
||||
toast.error(logoErrorMessage);
|
||||
logger.error("Tournament logo upload error", error);
|
||||
}
|
||||
} else {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
close();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`${errorMessage}: ${error.message}`);
|
||||
logger.error(
|
||||
`Tournament ${isEditMode ? "update" : "create"} error`,
|
||||
error
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
[isEditMode, createTournament, updateTournament, queryClient]
|
||||
);
|
||||
|
||||
return (
|
||||
<SlidePanel
|
||||
onSubmit={form.onSubmit(handleSubmit)}
|
||||
onCancel={close}
|
||||
submitText={isEditMode ? "Update Tournament" : "Create Tournament"}
|
||||
cancelText="Cancel"
|
||||
loading={isPending}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Name"
|
||||
withAsterisk
|
||||
key={form.key("name")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Location"
|
||||
withAsterisk
|
||||
key={form.key("location")}
|
||||
{...form.getInputProps("location")}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
key={form.key("desc")}
|
||||
{...form.getInputProps("desc")}
|
||||
minRows={3}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
key={form.key("logo")}
|
||||
accept="image/png,image/jpeg,image/gif,image/jpg"
|
||||
label={isEditMode ? "Change Logo" : "Logo"}
|
||||
leftSection={<LinkIcon size={16} />}
|
||||
{...form.getInputProps("logo")}
|
||||
/>
|
||||
|
||||
<SlidePanelField
|
||||
key={form.key("start_time")}
|
||||
{...form.getInputProps("start_time")}
|
||||
Component={DateTimePicker}
|
||||
title="Select Start Date"
|
||||
label="Start Date"
|
||||
withAsterisk
|
||||
formatValue={(date) =>
|
||||
!date ? 'Select a time' :
|
||||
new Date(date).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<SlidePanelField
|
||||
key={form.key("enroll_time")}
|
||||
{...form.getInputProps("enroll_time")}
|
||||
Component={DateTimePicker}
|
||||
title="Select Enrollment Due Date"
|
||||
label="Enrollment Due"
|
||||
withAsterisk
|
||||
formatValue={(date) =>
|
||||
!date ? 'Select a time' :
|
||||
new Date(date).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{isEditMode && (
|
||||
<SlidePanelField
|
||||
key={form.key("end_time")}
|
||||
{...form.getInputProps("end_time")}
|
||||
Component={DateTimePicker}
|
||||
title="Select End Date"
|
||||
label="End Date (Optional)"
|
||||
formatValue={(date) =>
|
||||
!date ? 'Select a time' :
|
||||
new Date(date).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</SlidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default TournamentForm;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ColorPicker, TextInput, Stack, Group, ColorSwatch, Text } from '@mantine/core';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
interface TeamColorPickerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
label = "Select Color"
|
||||
}) => {
|
||||
const [customHex, setCustomHex] = useState(value || '');
|
||||
|
||||
const isValidHex = useMemo(() => {
|
||||
const hexRegex = /^#[0-9A-F]{6}$/i;
|
||||
return hexRegex.test(customHex);
|
||||
}, [customHex]);
|
||||
|
||||
const handleColorChange = useCallback((color: string) => {
|
||||
setCustomHex(color);
|
||||
onChange(color);
|
||||
}, [onChange]);
|
||||
|
||||
const handleHexInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const hex = event.currentTarget.value;
|
||||
setCustomHex(hex);
|
||||
|
||||
if (/^#[0-9A-F]{6}$/i.test(hex)) {
|
||||
onChange(hex);
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
const presetColors = [
|
||||
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
|
||||
'#FF00FF', '#00FFFF', '#FFA500', '#800080',
|
||||
'#008000', '#000080', '#800000', '#808000'
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md" p="md">
|
||||
<Text fw={500}>{label}</Text>
|
||||
|
||||
<ColorPicker
|
||||
value={value || '#000000'}
|
||||
onChange={handleColorChange}
|
||||
format="hex"
|
||||
swatches={presetColors}
|
||||
withPicker={true}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
label="Custom Hex Code"
|
||||
placeholder="#FF0000"
|
||||
value={customHex}
|
||||
onChange={handleHexInputChange}
|
||||
error={customHex && !isValidHex ? 'Invalid hex color format' : undefined}
|
||||
/>
|
||||
|
||||
{isValidHex && (
|
||||
<ColorSwatch
|
||||
color={customHex}
|
||||
size={36}
|
||||
style={{ marginBottom: customHex && !isValidHex ? 24 : 0 }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamColorPicker;
|
||||
43
src/features/teams/hooks/use-available-players.ts
Normal file
43
src/features/teams/hooks/use-available-players.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from 'react';
|
||||
import { listPlayers } from '@/features/players/server';
|
||||
import { useServerSuspenseQuery } from '@/lib/tanstack-query/hooks';
|
||||
|
||||
export const playerKeys = {
|
||||
all: ['players'] as const,
|
||||
available: (excludedIds: string[] = []) => [...playerKeys.all, 'available', excludedIds] as const,
|
||||
};
|
||||
|
||||
export const playerQueries = {
|
||||
all: () => ({
|
||||
queryKey: playerKeys.all,
|
||||
queryFn: listPlayers
|
||||
}),
|
||||
};
|
||||
|
||||
export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => {
|
||||
const { data: allPlayers } = useServerSuspenseQuery(playerQueries.all());
|
||||
|
||||
const availablePlayers = useMemo(() => {
|
||||
if (!allPlayers) return [];
|
||||
|
||||
return allPlayers.filter(player =>
|
||||
!excludedPlayerIds.includes(player.id) &&
|
||||
player.first_name &&
|
||||
player.last_name
|
||||
);
|
||||
}, [allPlayers, excludedPlayerIds]);
|
||||
|
||||
const playerOptions = useMemo(() =>
|
||||
availablePlayers.map(player => ({
|
||||
value: player.id,
|
||||
label: `${player.first_name} ${player.last_name}`.trim() || 'Unnamed Player'
|
||||
})),
|
||||
[availablePlayers]
|
||||
);
|
||||
|
||||
return {
|
||||
availablePlayers,
|
||||
playerOptions,
|
||||
allPlayers
|
||||
};
|
||||
};
|
||||
22
src/features/teams/hooks/use-create-team.ts
Normal file
22
src/features/teams/hooks/use-create-team.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { createTeam } from "@/features/teams/server";
|
||||
import { TeamInput } from "@/features/teams/types";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
|
||||
const useCreateTeam = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useServerMutation({
|
||||
mutationFn: (data: TeamInput) => createTeam({ data }),
|
||||
onMutate: (data) => {
|
||||
logger.info('Creating team', data);
|
||||
},
|
||||
onSuccess: (team) => {
|
||||
navigate({ to: '/teams/$id', params: { id: team.id } });
|
||||
},
|
||||
successMessage: 'Team created successfully!',
|
||||
});
|
||||
};
|
||||
|
||||
export default useCreateTeam;
|
||||
17
src/features/teams/hooks/use-update-team.ts
Normal file
17
src/features/teams/hooks/use-update-team.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { updateTeam } from "@/features/teams/server";
|
||||
import { TeamUpdateInput } from "@/features/teams/types";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const useUpdateTeam = (teamId: string) => {
|
||||
return useServerMutation({
|
||||
mutationFn: (data: TeamUpdateInput) =>
|
||||
updateTeam({ data: { id: teamId, updates: data } }),
|
||||
onMutate: (data) => {
|
||||
logger.info('Updating team', { teamId, updates: Object.keys(data) });
|
||||
},
|
||||
successMessage: 'Team updated successfully!',
|
||||
});
|
||||
};
|
||||
|
||||
export default useUpdateTeam;
|
||||
@@ -1,8 +1,22 @@
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { superTokensFunctionMiddleware, superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { z } from "zod";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { teamInputSchema, teamUpdateSchema } from "./types";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export const listTeams = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(() => pbAdmin.listTeams())
|
||||
);
|
||||
|
||||
export const listTeamInfos = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(() => pbAdmin.listTeamInfos())
|
||||
);
|
||||
|
||||
export const getTeam = createServerFn()
|
||||
.validator(z.string())
|
||||
@@ -10,3 +24,74 @@ export const getTeam = createServerFn()
|
||||
.handler(async ({ data: teamId }) =>
|
||||
toServerResult(() => pbAdmin.getTeam(teamId))
|
||||
);
|
||||
|
||||
export const getTeamInfo = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: teamId }) =>
|
||||
toServerResult(() => pbAdmin.getTeamInfo(teamId))
|
||||
);
|
||||
|
||||
export const createTeam = createServerFn()
|
||||
.validator(teamInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data, context }) =>
|
||||
toServerResult(async () => {
|
||||
const userId = context.userAuthId;
|
||||
const isAdmin = context.roles.includes("Admin");
|
||||
|
||||
// Check if user is trying to create a team with themselves as a player
|
||||
if (!isAdmin && !data.players.includes(userId)) {
|
||||
throw new Error("You can only create teams that include yourself as a player");
|
||||
}
|
||||
|
||||
// Additional validation: ensure user is not already on another team
|
||||
if (!isAdmin) {
|
||||
const userTeams = await pbAdmin.listTeams();
|
||||
const existingTeam = userTeams.find(team =>
|
||||
team.players.some(player => player.id === userId)
|
||||
);
|
||||
if (existingTeam) {
|
||||
throw new Error(`You are already a member of team "${existingTeam.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Creating team", { name: data.name, userId, isAdmin });
|
||||
return pbAdmin.createTeam(data);
|
||||
})
|
||||
);
|
||||
|
||||
export const updateTeam = createServerFn()
|
||||
.validator(z.object({
|
||||
id: z.string(),
|
||||
updates: teamUpdateSchema
|
||||
}))
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { id, updates }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const userId = context.userAuthId;
|
||||
const isAdmin = context.roles.includes("Admin");
|
||||
|
||||
// Get the team to verify ownership
|
||||
const team = await pbAdmin.getTeam(id);
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
// Check if user has permission to update this team
|
||||
const isPlayerOnTeam = team.players.some(player => player.id === userId);
|
||||
if (!isAdmin && !isPlayerOnTeam) {
|
||||
throw new Error("You can only update teams that you are a member of");
|
||||
}
|
||||
|
||||
logger.info("Updating team", { teamId: id, userId, isAdmin });
|
||||
return pbAdmin.updateTeam(id, updates);
|
||||
})
|
||||
);
|
||||
|
||||
export const deleteTeam = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: teamId }) =>
|
||||
toServerResult(() => pbAdmin.deleteTeam(teamId))
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface TeamInfo {
|
||||
name: string;
|
||||
primary_color: string;
|
||||
accent_color: string;
|
||||
logo?: string;
|
||||
players: PlayerInfo[];
|
||||
}
|
||||
|
||||
@@ -50,6 +51,38 @@ export const teamInputSchema = z
|
||||
song_start: z.number().int().optional(),
|
||||
song_end: z.number().int().optional(),
|
||||
song_image_url: z.url("Invalid song image URL").optional(),
|
||||
players: z.array(z.string()).min(1, "At least one player is required").max(10, "Maximum 10 players allowed"),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.song_start && data.song_end) {
|
||||
return data.song_end > data.song_start;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "Song end time must be after start time", path: ["song_end"] }
|
||||
);
|
||||
|
||||
export const teamUpdateSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Team name is required").max(100, "Name too long").optional(),
|
||||
logo: z.file("Invalid logo").optional(),
|
||||
primary_color: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)")
|
||||
.optional(),
|
||||
accent_color: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)")
|
||||
.optional(),
|
||||
song_id: z.string().max(255).optional(),
|
||||
song_name: z.string().max(255).optional(),
|
||||
song_artist: z.string().max(255).optional(),
|
||||
song_album: z.string().max(255).optional(),
|
||||
song_year: z.number().int().optional(),
|
||||
song_start: z.number().int().optional(),
|
||||
song_end: z.number().int().optional(),
|
||||
song_image_url: z.url("Invalid song image URL").optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -62,4 +95,4 @@ export const teamInputSchema = z
|
||||
);
|
||||
|
||||
export type TeamInput = z.infer<typeof teamInputSchema>;
|
||||
export type TeamUpdateInput = Partial<TeamInput>;
|
||||
export type TeamUpdateInput = z.infer<typeof teamUpdateSchema>;
|
||||
|
||||
Reference in New Issue
Block a user