init
This commit is contained in:
40
src/features/players/components/player-list.tsx
Normal file
40
src/features/players/components/player-list.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { List, ListItem, Skeleton, Text } from "@mantine/core";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { Player } from "@/features/players/types";
|
||||
|
||||
interface PlayerListProps {
|
||||
players: Player[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const PlayerList = ({ players, loading = false }: PlayerListProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (loading) return <List>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<ListItem py='xs'
|
||||
icon={<Skeleton circle height={40} width={40} />}
|
||||
>
|
||||
<Skeleton height={20} width={200} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
return <List>
|
||||
{players?.map((player) => (
|
||||
<ListItem key={player.id}
|
||||
py='xs'
|
||||
icon={<Avatar size={40} name={`${player.first_name} ${player.last_name}`} />}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
navigate({ to: `/profile/${player.id}` });
|
||||
}}
|
||||
>
|
||||
<Text fw={500}>{`${player.first_name} ${player.last_name}`}</Text>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
}
|
||||
|
||||
export default PlayerList;
|
||||
42
src/features/players/components/profile/header.tsx
Normal file
42
src/features/players/components/profile/header.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { Flex, Title, ActionIcon } from "@mantine/core";
|
||||
import { PencilIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import NameUpdateForm from "./name-form";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import { Player } from "../../types";
|
||||
|
||||
interface HeaderProps {
|
||||
player: Player;
|
||||
}
|
||||
|
||||
const Header = ({ player }: HeaderProps) => {
|
||||
|
||||
const sheet = useSheet();
|
||||
const { user: authUser } = useAuth();
|
||||
|
||||
const owner = useMemo(() => authUser?.id === player.id, [authUser?.id, player.id]);
|
||||
const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex px='xl' w='100%' align='self-end' gap='md'>
|
||||
<Avatar name={name} size={125} />
|
||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||
<Title ta='center' order={2}>{name}</Title>
|
||||
<ActionIcon display={owner ? 'block' : 'none'} radius='xl' variant='subtle' onClick={sheet.open}>
|
||||
<PencilIcon size={20} />
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Sheet title='Update Name' {...sheet.props}>
|
||||
<NameUpdateForm player={player} toggle={sheet.toggle} />
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
export default Header;
|
||||
22
src/features/players/components/profile/index.tsx
Normal file
22
src/features/players/components/profile/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Box, Button, Text } from "@mantine/core";
|
||||
import Header from "./header";
|
||||
import { testEvent } from "@/utils/test-event";
|
||||
import { Player } from "@/features/players/types";
|
||||
import TeamList from "@/features/teams/components/team-list";
|
||||
|
||||
interface ProfileProps {
|
||||
player: Player;
|
||||
}
|
||||
|
||||
const Profile = ({ player }: ProfileProps) => {
|
||||
|
||||
return <>
|
||||
<Header player={player} />
|
||||
<Box m='sm' mt='lg'>
|
||||
<Text size='xl' fw={600}>Teams</Text>
|
||||
<TeamList teams={player.teams ?? []} />
|
||||
</Box>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
60
src/features/players/components/profile/name-form.tsx
Normal file
60
src/features/players/components/profile/name-form.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { updatePlayer } from "@/features/players/server";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Button, Stack, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form";
|
||||
import toast from "@/lib/sonner";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { Player } from "../../types";
|
||||
|
||||
interface NameUpdateFormProps {
|
||||
player: Player;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
first_name: player.first_name,
|
||||
last_name: player.last_name
|
||||
},
|
||||
validate: {
|
||||
first_name: (value: string) => {
|
||||
if (value.length === 0) return 'First name is required'
|
||||
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'First name must be 3-20 characters long and contain only letters'
|
||||
},
|
||||
last_name: (value: string) => {
|
||||
if (value.length === 0) return 'Last name is required'
|
||||
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'Last name must be 3-20 characters long and contain only letters'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { mutate: updateName, isPending } = useMutation({
|
||||
mutationFn: async (data: { first_name: string, last_name: string }) => await updatePlayer({ data }),
|
||||
onSuccess: () => {
|
||||
toggle();
|
||||
toast.success('Name updated successfully!');
|
||||
router.invalidate();
|
||||
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('There was an issue updating your name. Please try again later.');
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: { first_name: string, last_name: string }) => await updateName(data)
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap='xs'>
|
||||
<TextInput label='First Name' {...form.getInputProps('first_name')} />
|
||||
<TextInput label='Last Name' {...form.getInputProps('last_name')} />
|
||||
<Button fullWidth loading={isPending} type='submit'>Save</Button>
|
||||
<Button fullWidth variant='subtle' color='red' onClick={toggle}>Cancel</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default NameUpdateForm;
|
||||
3
src/features/players/index.ts
Normal file
3
src/features/players/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from "@/lib/logger";
|
||||
|
||||
export const logger = new Logger('Players');
|
||||
23
src/features/players/queries.ts
Normal file
23
src/features/players/queries.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { listPlayers, getPlayer, getUnassociatedPlayers } from "./server";
|
||||
|
||||
const playerKeys = {
|
||||
list: ['players', 'list'] as const,
|
||||
details: (id: string) => ['players', 'details', id] as const,
|
||||
unassociated: ['players','unassociated'] as const,
|
||||
};
|
||||
|
||||
export const playerQueries = {
|
||||
list: () => queryOptions({
|
||||
queryKey: playerKeys.list,
|
||||
queryFn: listPlayers,
|
||||
}),
|
||||
details: (id: string) => queryOptions({
|
||||
queryKey: playerKeys.details(id),
|
||||
queryFn: () => getPlayer({ data: id }),
|
||||
}),
|
||||
unassociated: () => queryOptions({
|
||||
queryKey: playerKeys.unassociated,
|
||||
queryFn: getUnassociatedPlayers,
|
||||
}),
|
||||
};
|
||||
143
src/features/players/server.ts
Normal file
143
src/features/players/server.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { fetchSuperTokensAuth, setUserMetadata, superTokensFunctionMiddleware, superTokensRoleFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { playerInputSchema, playerUpdateSchema } from "@/features/players/types";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { z } from "zod";
|
||||
import { logger } from ".";
|
||||
|
||||
export const fetchMe = createServerFn().handler(async () => {
|
||||
const data = await fetchSuperTokensAuth();
|
||||
if (!data || !data.userAuthId) return { user: undefined, roles: [], metadata: {} };
|
||||
|
||||
try {
|
||||
const result = await pbAdmin.getPlayerByAuthId(data.userAuthId);
|
||||
logger.info('Fetched player', result);
|
||||
return {
|
||||
user: result || undefined,
|
||||
roles: data.roles,
|
||||
metadata: data.metadata
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching player:', error);
|
||||
return { user: undefined, roles: data.roles, metadata: data.metadata };
|
||||
}
|
||||
});
|
||||
|
||||
export const getPlayer = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
const player = await pbAdmin.getPlayer(data);
|
||||
return player;
|
||||
} catch (error) {
|
||||
logger.error('Error getting player', error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
export const updatePlayer = createServerFn()
|
||||
.validator(playerUpdateSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const userAuthId = (context as any).userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
try {
|
||||
// Find the player by authId first
|
||||
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
|
||||
|
||||
if (!existing) return;
|
||||
|
||||
// Update the player
|
||||
const updatedPlayer = await pbAdmin.updatePlayer(
|
||||
existing.id!,
|
||||
{
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Updated player name', updatedPlayer);
|
||||
|
||||
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name } });
|
||||
|
||||
return updatedPlayer;
|
||||
} catch (error) {
|
||||
logger.error('Error updating player name', error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
export const createPlayer = createServerFn()
|
||||
.validator(playerInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const userAuthId = (context as any).userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
try {
|
||||
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
|
||||
if (existing) return;
|
||||
|
||||
logger.info('Creating player', data, userAuthId);
|
||||
|
||||
const newPlayer = await pbAdmin.createPlayer({
|
||||
auth_id: userAuthId,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name
|
||||
});
|
||||
|
||||
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name, player_id: newPlayer?.id?.toString() } });
|
||||
logger.info('Created player', newPlayer);
|
||||
return newPlayer;
|
||||
} catch (error) {
|
||||
logger.error('Error creating player', error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
export const associatePlayer = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const userAuthId = (context as any).userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
try {
|
||||
await pbAdmin.updatePlayer(data, {
|
||||
auth_id: userAuthId
|
||||
});
|
||||
|
||||
await setUserMetadata({ data: { player_id: data } });
|
||||
|
||||
const player = await pbAdmin.getPlayer(data);
|
||||
logger.info('Associated player', player);
|
||||
return player;
|
||||
} catch (error) {
|
||||
logger.error('Error associating player', error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
export const listPlayers = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () => {
|
||||
try {
|
||||
return await pbAdmin.listPlayers();
|
||||
} catch (error) {
|
||||
logger.error('Error listing players', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
export const getUnassociatedPlayers = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () => {
|
||||
try {
|
||||
return await pbAdmin.getUnassociatedPlayers();
|
||||
} catch (error) {
|
||||
logger.error('Error getting unassociated players', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
23
src/features/players/types.ts
Normal file
23
src/features/players/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface Player {
|
||||
id?: string;
|
||||
auth_id?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
teams?: Team[];
|
||||
}
|
||||
|
||||
export const playerInputSchema = z.object({
|
||||
auth_id: z.string().optional(),
|
||||
first_name: z.string().min(3).max(20).regex(/^[a-zA-Z0-9\s]+$/, "First name must be 3-20 characters long and contain only letters and spaces"),
|
||||
last_name: z.string().min(3).max(20).regex(/^[a-zA-Z0-9\s]+$/, "Last name must be 3-20 characters long and contain only letters and spaces"),
|
||||
});
|
||||
|
||||
export const playerUpdateSchema = playerInputSchema.partial();
|
||||
|
||||
export type PlayerInput = z.infer<typeof playerInputSchema>;
|
||||
export type PlayerUpdateInput = z.infer<typeof playerUpdateSchema>;
|
||||
Reference in New Issue
Block a user