reactions, match sse, etc
This commit is contained in:
@@ -29,6 +29,8 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
console.log(match);
|
||||
|
||||
return (
|
||||
<Indicator
|
||||
disabled={!isStarted}
|
||||
@@ -88,18 +90,30 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="md"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
>
|
||||
{match.home?.name!}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
>
|
||||
{match.home?.name!}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xl" fw={700} c={"dimmed"}>
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={"dimmed"}
|
||||
display={match.status === "ended" ? undefined : "none"}
|
||||
>
|
||||
{match.home_cups}
|
||||
</Text>
|
||||
<Stack gap={1}>
|
||||
{match.home?.players.map((p) => (
|
||||
<Text size="xs" fw={600} c="dimmed" ta="right">
|
||||
{p.first_name} {p.last_name}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
@@ -133,7 +147,7 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="md"
|
||||
size="sm"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
@@ -141,9 +155,21 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
{match.away?.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xl" fw={700} c={"dimmed"}>
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={"dimmed"}
|
||||
display={match.status === "ended" ? undefined : "none"}
|
||||
>
|
||||
{match.away_cups}
|
||||
</Text>
|
||||
<Stack gap={1}>
|
||||
{match.away?.players.map((p) => (
|
||||
<Text size="xs" fw={600} c="dimmed" ta="right">
|
||||
{p.first_name} {p.last_name}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -163,7 +189,7 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<EmojiBar />
|
||||
<EmojiBar matchId={match.id} />
|
||||
</Paper>
|
||||
</Box>
|
||||
</Indicator>
|
||||
|
||||
18
src/features/matches/queries.ts
Normal file
18
src/features/matches/queries.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useServerSuspenseQuery, useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { getMatchReactions } from "./server";
|
||||
|
||||
export const matchKeys = {
|
||||
list: ['matches', 'list'] as const,
|
||||
details: (id: string) => ['matches', 'details', id] as const,
|
||||
reactions: (id: string) => ['matches', 'reactions', id] as const,
|
||||
};
|
||||
|
||||
export const matchQueries = {
|
||||
reactions: (matchId: string) => ({
|
||||
queryKey: matchKeys.reactions(matchId),
|
||||
queryFn: () => getMatchReactions({ data: matchId }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const useMatchReactions = (matchId: string) =>
|
||||
useServerQuery(matchQueries.reactions(matchId));
|
||||
@@ -6,6 +6,9 @@ import { z } from "zod";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import brackets from "@/features/bracket/utils";
|
||||
import { MatchInput } from "@/features/matches/types";
|
||||
import { serverEvents } from "@/lib/events/emitter";
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { PlayerInfo } from "../players/types";
|
||||
|
||||
const orderedTeamsSchema = z.object({
|
||||
tournamentId: z.string(),
|
||||
@@ -150,6 +153,13 @@ export const startMatch = createServerFn()
|
||||
status: "started",
|
||||
});
|
||||
|
||||
console.log('emitting start match...')
|
||||
serverEvents.emit("match", {
|
||||
type: "match",
|
||||
matchId: match.id,
|
||||
tournamentId: match.tournament.id
|
||||
});
|
||||
|
||||
return match;
|
||||
})
|
||||
);
|
||||
@@ -184,14 +194,9 @@ export const endMatch = createServerFn()
|
||||
const matchLoser = home_cups < away_cups ? match.home : match.away;
|
||||
if (!matchWinner || !matchLoser) throw new Error("Something went wrong");
|
||||
|
||||
console.log(matchWinner)
|
||||
console.log(matchLoser)
|
||||
|
||||
// winner -> where to send match winner to, loser same
|
||||
const { winner, loser } = await pbAdmin.getChildMatches(matchId);
|
||||
|
||||
console.log(winner, loser)
|
||||
|
||||
// reset match check
|
||||
if (winner && winner.reset) {
|
||||
const awayTeamWon = match.away === matchWinner;
|
||||
@@ -232,8 +237,114 @@ export const endMatch = createServerFn()
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: send SSE
|
||||
serverEvents.emit("match", {
|
||||
type: "match",
|
||||
matchId: match.id,
|
||||
tournamentId: match.tournament.id
|
||||
});
|
||||
|
||||
return match;
|
||||
})
|
||||
);
|
||||
|
||||
const toggleReactionSchema = z.object({
|
||||
matchId: z.string(),
|
||||
emoji: z.string(),
|
||||
});
|
||||
|
||||
export const toggleMatchReaction = createServerFn()
|
||||
.validator(toggleReactionSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId, emoji }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const user = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
const userId = user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const match = await pbAdmin.getMatch(matchId);
|
||||
if (!match) {
|
||||
throw new Error("Match not found");
|
||||
}
|
||||
|
||||
const existingReaction = await pbAdmin.getUserReaction(matchId, userId, emoji);
|
||||
if (existingReaction) {
|
||||
await pbAdmin.deleteReaction(existingReaction.id);
|
||||
logger.info("Removed reaction", { matchId, emoji, userId });
|
||||
} else {
|
||||
await pbAdmin.createReaction(matchId, userId, emoji);
|
||||
logger.info("Added reaction", { matchId, emoji, userId });
|
||||
}
|
||||
|
||||
const all = await pbAdmin.getReactionsForMatch(matchId);
|
||||
|
||||
const reactionsByEmoji = all.reduce((acc, reaction) => {
|
||||
const emoji = reaction.emoji;
|
||||
if (!acc[emoji]) {
|
||||
acc[emoji] = {
|
||||
emoji,
|
||||
count: 0,
|
||||
players: [],
|
||||
};
|
||||
}
|
||||
acc[emoji].count++;
|
||||
acc[emoji].players.push({
|
||||
id: reaction?.player?.id,
|
||||
first_name: reaction.player?.first_name,
|
||||
last_name: reaction.player?.last_name,
|
||||
});
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
const reactions = Object.values(reactionsByEmoji);
|
||||
|
||||
serverEvents.emit("reaction", {
|
||||
type: "reaction",
|
||||
matchId,
|
||||
tournamentId: match.tournament.id,
|
||||
reactions,
|
||||
});
|
||||
|
||||
return reactions as Reaction[]
|
||||
})
|
||||
);
|
||||
|
||||
export interface Reaction {
|
||||
emoji: string;
|
||||
count: number;
|
||||
players: PlayerInfo[];
|
||||
}
|
||||
export const getMatchReactions = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: matchId, context }) =>
|
||||
toServerResult(async () => {
|
||||
const match = await pbAdmin.getMatch(matchId);
|
||||
if (!match) {
|
||||
throw new Error("Match not found");
|
||||
}
|
||||
|
||||
const all = await pbAdmin.getReactionsForMatch(matchId);
|
||||
|
||||
const reactionsByEmoji = all.reduce((acc, reaction) => {
|
||||
const emoji = reaction.emoji;
|
||||
if (!acc[emoji]) {
|
||||
acc[emoji] = {
|
||||
emoji,
|
||||
count: 0,
|
||||
players: [],
|
||||
};
|
||||
}
|
||||
acc[emoji].count++;
|
||||
acc[emoji].players.push({
|
||||
id: reaction?.player?.id,
|
||||
first_name: reaction.player?.first_name,
|
||||
last_name: reaction.player?.last_name,
|
||||
});
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
const reactions = Object.values(reactionsByEmoji);
|
||||
|
||||
return reactions as Reaction[]
|
||||
})
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = useCallback((playerId: string) =>
|
||||
navigate({ to: `/profile/${playerId} `}), [navigate]);
|
||||
navigate({ to: `/profile/${playerId}`}), [navigate]);
|
||||
|
||||
if (loading) return <List>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
|
||||
@@ -5,43 +5,27 @@ import {
|
||||
Tabs,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useState, useRef } from "react";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
import PlayerList from "@/features/players/components/player-list";
|
||||
import EmojiPicker from "./emoji-picker";
|
||||
|
||||
interface Reaction {
|
||||
emoji: string;
|
||||
count: number;
|
||||
players: PlayerInfo[];
|
||||
hasReacted: boolean;
|
||||
}
|
||||
import { useMatchReactions, useToggleMatchReaction } from "../queries";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { Reaction } from "@/features/matches/server";
|
||||
|
||||
interface EmojiBarProps {
|
||||
reactions?: Reaction[];
|
||||
matchId: string;
|
||||
onReactionPress?: (emoji: string) => void;
|
||||
}
|
||||
|
||||
const EXAMPLE_DATA: Reaction[] = [
|
||||
{
|
||||
emoji: "👍",
|
||||
count: 1,
|
||||
players: [{ id: "dasfasdf", first_name: "Kyle", last_name: "Yohler" }],
|
||||
hasReacted: true,
|
||||
},
|
||||
{
|
||||
emoji: "❤️",
|
||||
count: 1,
|
||||
players: [{ id: "f3234f", first_name: "Salah", last_name: "Atiyeh" }],
|
||||
hasReacted: false,
|
||||
},
|
||||
];
|
||||
|
||||
const EmojiBar = ({
|
||||
reactions = EXAMPLE_DATA,
|
||||
matchId,
|
||||
onReactionPress,
|
||||
}: EmojiBarProps) => {
|
||||
const { user } = useAuth();
|
||||
const { data: reactions } = useMatchReactions(matchId);
|
||||
const toggleReaction = useToggleMatchReaction(matchId);
|
||||
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [activeTab, setActiveTab] = useState<string | null>(null);
|
||||
const longPressTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -61,10 +45,20 @@ const EmojiBar = ({
|
||||
|
||||
const handleReactionClick = (emoji: string) => {
|
||||
handleLongPressEnd();
|
||||
onReactionPress?.(emoji);
|
||||
if (onReactionPress) {
|
||||
onReactionPress(emoji);
|
||||
} else {
|
||||
toggleReaction.mutate({ data: { matchId, emoji } });
|
||||
}
|
||||
};
|
||||
|
||||
if (!reactions.length) return null;
|
||||
const hasReacted = useCallback((reaction: Reaction) => {
|
||||
return reaction.players.map(p => p.id).includes(user?.id || "");
|
||||
}, []);
|
||||
|
||||
if (!reactions) return;
|
||||
|
||||
console.log(reactions)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -73,8 +67,9 @@ const EmojiBar = ({
|
||||
{reactions.map((reaction) => (
|
||||
<Button
|
||||
key={reaction.emoji}
|
||||
variant={reaction.hasReacted ? "filled" : "light"}
|
||||
color={reaction.hasReacted ? "var(--mantine-primary-color-filled)" : "gray"}
|
||||
variant={hasReacted(reaction) ? "filled" : "light"}
|
||||
color="gray"
|
||||
bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined}
|
||||
size="compact-xs"
|
||||
radius="xl"
|
||||
onMouseDown={() => handleLongPressStart(reaction.emoji)}
|
||||
@@ -90,18 +85,18 @@ const EmojiBar = ({
|
||||
msUserSelect: "none",
|
||||
}}
|
||||
>
|
||||
<Group gap={4} align="center">
|
||||
<Text size="10px" style={{ lineHeight: 1 }}>
|
||||
<Group gap={2} align="center">
|
||||
<Text size="xs" style={{ lineHeight: 1 }}>
|
||||
{reaction.emoji}
|
||||
</Text>
|
||||
<Text size="10px" fw={600}>
|
||||
<Text size="xs" fw={600}>
|
||||
{reaction.count}
|
||||
</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
<EmojiPicker onSelect={onReactionPress || (() => {})} />
|
||||
<EmojiPicker onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))} />
|
||||
</Group>
|
||||
|
||||
<Sheet title="Reactions" opened={opened} onChange={() => close()}>
|
||||
|
||||
34
src/features/reactions/queries.ts
Normal file
34
src/features/reactions/queries.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useServerQuery, useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { matchKeys } from "@/features/matches/queries";
|
||||
|
||||
export const reactionKeys = {
|
||||
match: (matchId: string) => ['reactions', 'match', matchId] as const,
|
||||
};
|
||||
|
||||
export const reactionQueries = {
|
||||
match: (matchId: string) => ({
|
||||
queryKey: reactionKeys.match(matchId),
|
||||
queryFn: () => getMatchReactions({ data: matchId }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const useMatchReactions = (matchId: string) =>
|
||||
useServerQuery(reactionQueries.match(matchId));
|
||||
|
||||
export const useToggleMatchReaction = (matchId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useServerMutation({
|
||||
mutationFn: toggleMatchReaction,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: reactionKeys.match(matchId)
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: matchKeys.reactions(matchId)
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { Box, Divider, Text, Stack } from "@mantine/core";
|
||||
import Header from "./header";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
import TournamentList from "@/features/tournaments/components/tournament-list";
|
||||
import StatsOverview from "@/shared/components/stats-overview";
|
||||
import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
|
||||
import MatchList from "@/features/matches/components/match-list";
|
||||
import PlayerList from "@/features/players/components/player-list";
|
||||
|
||||
interface ProfileProps {
|
||||
id: string;
|
||||
@@ -19,7 +20,17 @@ const TeamProfile = ({ id }: ProfileProps) => {
|
||||
const tabs = [
|
||||
{
|
||||
label: "Overview",
|
||||
content: <StatsOverview statsData={statsError ? null : stats || null} isLoading={statsLoading} />,
|
||||
content: <>
|
||||
<Stack px="md">
|
||||
<Text size="md" fw={700}>Players</Text>
|
||||
<PlayerList players={team.players} />
|
||||
</Stack>
|
||||
<Divider my="md" />
|
||||
<Stack>
|
||||
<Text px="md" size="md" fw={700}>Statistics</Text>
|
||||
<StatsOverview statsData={statsError ? null : stats || null} isLoading={statsLoading} />
|
||||
</Stack>
|
||||
</>,
|
||||
},
|
||||
{
|
||||
label: "Matches",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
|
||||
import { Tournament } from "../../types";
|
||||
import Avatar from "@/components/avatar";
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
TrophyIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
const tournamentStart = useMemo(
|
||||
() => new Date(tournament.start_time),
|
||||
[tournament.start_time]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack align="center" gap={0}>
|
||||
<Avatar
|
||||
name={tournament.name}
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
: undefined
|
||||
}
|
||||
radius="md"
|
||||
size={200}
|
||||
px="xs"
|
||||
withBorder={false}
|
||||
>
|
||||
<TrophyIcon size={24} />
|
||||
</Avatar>
|
||||
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
|
||||
{tournament.location && (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<MapPinIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{tournament.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<CalendarIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{tournamentStart.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
at{" "}
|
||||
{tournamentStart.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from "react";
|
||||
import { Tournament } from "../../types";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { TreeStructureIcon, UsersIcon, ClockIcon } from "@phosphor-icons/react";
|
||||
import TeamListButton from "../upcoming-tournament/team-list-button";
|
||||
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
||||
import MatchCard from "@/features/matches/components/match-card";
|
||||
import Header from "./header";
|
||||
|
||||
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
tournament,
|
||||
}) => {
|
||||
const { roles } = useAuth();
|
||||
|
||||
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
||||
|
||||
const startedMatches = useMemo(() =>
|
||||
tournament.matches?.filter(match => match.status === "started") || [],
|
||||
[tournament.matches]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Header tournament={tournament} />
|
||||
|
||||
{startedMatches.length > 0 ? (
|
||||
<Box>
|
||||
<Carousel
|
||||
slideSize="95%"
|
||||
slideGap="xs"
|
||||
withControls={false}
|
||||
>
|
||||
{startedMatches.map((match, index) => (
|
||||
<Carousel.Slide key={match.id}>
|
||||
<Box pl={index === 0 ? "xl" : undefined } pr={index === startedMatches.length - 1 ? "xl" : undefined}>
|
||||
<MatchCard match={match} />
|
||||
</Box>
|
||||
</Carousel.Slide>
|
||||
))}
|
||||
</Carousel>
|
||||
</Box>
|
||||
) : (
|
||||
<Card withBorder radius="lg" p="xl" mx="md">
|
||||
<Center>
|
||||
<Stack align="center" gap="md">
|
||||
<ClockIcon size={48} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="lg" fw={500} c="dimmed">
|
||||
No active matches
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Divider />
|
||||
{isAdmin && (
|
||||
<ListLink
|
||||
label={`Manage ${tournament.name}`}
|
||||
to={`/admin/tournaments/${tournament.id}`}
|
||||
Icon={UsersIcon}
|
||||
/>
|
||||
)}
|
||||
<ListLink
|
||||
label={`View Bracket`}
|
||||
to={`/tournaments/${tournament.id}/bracket`}
|
||||
Icon={TreeStructureIcon}
|
||||
/>
|
||||
<RulesListButton tournamentId={tournament.id} />
|
||||
<TeamListButton teams={tournament.teams || []} />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartedTournament;
|
||||
Reference in New Issue
Block a user