reactions SSE!

This commit is contained in:
yohlo
2025-09-19 20:53:05 -05:00
parent f99d6efaf9
commit 5e20b94a1f
14 changed files with 173 additions and 99 deletions

View File

@@ -24,6 +24,7 @@ export const ServerRoute = createServerFileRoute("/api/events/$").middleware([su
serverEvents.on("test", handleEvent); serverEvents.on("test", handleEvent);
serverEvents.on("match", handleEvent); serverEvents.on("match", handleEvent);
serverEvents.on("reaction", handleEvent);
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
try { try {

View File

@@ -29,8 +29,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
} }
}; };
console.log(match);
return ( return (
<Indicator <Indicator
disabled={!isStarted} disabled={!isStarted}

View File

@@ -1,18 +0,0 @@
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));

View File

@@ -153,7 +153,6 @@ export const startMatch = createServerFn()
status: "started", status: "started",
}); });
console.log('emitting start match...')
serverEvents.emit("match", { serverEvents.emit("match", {
type: "match", type: "match",
matchId: match.id, matchId: match.id,
@@ -300,7 +299,6 @@ export const toggleMatchReaction = createServerFn()
serverEvents.emit("reaction", { serverEvents.emit("reaction", {
type: "reaction", type: "reaction",
matchId, matchId,
tournamentId: match.tournament.id,
reactions, reactions,
}); });

View File

@@ -15,8 +15,6 @@ const Profile = ({ id }: ProfileProps) => {
const { data: matches } = usePlayerMatches(id); const { data: matches } = usePlayerMatches(id);
const { data: stats, isLoading: statsLoading } = usePlayerStats(id); const { data: stats, isLoading: statsLoading } = usePlayerStats(id);
console.log(player.teams)
const tabs = [ const tabs = [
{ {
label: "Overview", label: "Overview",

View File

@@ -2,7 +2,9 @@ import {
Group, Group,
Button, Button,
Text, Text,
Tabs, Stack,
ScrollArea,
Paper,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback } from "react";
@@ -27,12 +29,12 @@ const EmojiBar = ({
const toggleReaction = useToggleMatchReaction(matchId); const toggleReaction = useToggleMatchReaction(matchId);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [activeTab, setActiveTab] = useState<string | null>(null); const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
const longPressTimeout = useRef<NodeJS.Timeout | null>(null); const longPressTimeout = useRef<NodeJS.Timeout | null>(null);
const handleLongPressStart = (emoji: string) => { const handleLongPressStart = (emoji: string) => {
longPressTimeout.current = setTimeout(() => { longPressTimeout.current = setTimeout(() => {
setActiveTab(emoji); setSelectedEmoji(emoji);
open(); open();
}, 500); }, 500);
}; };
@@ -54,17 +56,29 @@ const EmojiBar = ({
const hasReacted = useCallback((reaction: Reaction) => { const hasReacted = useCallback((reaction: Reaction) => {
return reaction.players.map(p => p.id).includes(user?.id || ""); return reaction.players.map(p => p.id).includes(user?.id || "");
}, []); }, [user?.id]);
// Get emojis the current user has reacted to
const userReactions = reactions?.filter(r => hasReacted(r)).map(r => r.emoji) || [];
if (!reactions) return; if (!reactions) return;
console.log(reactions) // Sort reactions by count (descending)
const sortedReactions = [...reactions].sort((a, b) => b.count - a.count);
// Group reactions: show first 3, group the rest
const visibleReactions = sortedReactions.slice(0, 3);
const groupedReactions = sortedReactions.slice(3);
const hasGrouped = groupedReactions.length > 0;
const groupedCount = groupedReactions.reduce((sum, r) => sum + r.count, 0);
const userHasReactedToGrouped = groupedReactions.some(r => hasReacted(r));
return ( return (
<> <>
<Group gap="xs" wrap="wrap" justify="space-between"> <Group gap="xs" wrap="wrap" justify="space-between">
<Group gap="xs" wrap="wrap"> <Group gap="xs" wrap="wrap">
{reactions.map((reaction) => ( {visibleReactions.map((reaction) => (
<Button <Button
key={reaction.emoji} key={reaction.emoji}
variant={hasReacted(reaction) ? "filled" : "light"} variant={hasReacted(reaction) ? "filled" : "light"}
@@ -95,31 +109,100 @@ const EmojiBar = ({
</Group> </Group>
</Button> </Button>
))} ))}
{hasGrouped && (
<Button
variant={userHasReactedToGrouped ? "filled" : "light"}
color="gray"
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs"
radius="xl"
onMouseDown={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
onMouseUp={handleLongPressEnd}
onMouseLeave={handleLongPressEnd}
onTouchStart={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
onTouchEnd={handleLongPressEnd}
onClick={() => {
setSelectedEmoji(groupedReactions[0]?.emoji || "");
open();
}}
style={{
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
position: "relative",
}}
>
<Group gap={2} align="center">
<div style={{
display: "flex",
gap: "1px",
alignItems: "center",
fontSize: "10px",
lineHeight: 1
}}>
{groupedReactions.slice(0, 2).map((reaction) => (
<span key={reaction.emoji}>{reaction.emoji}</span>
))}
{groupedReactions.length > 2 && (
<Text size="8px" fw={600} c="dimmed">
+{groupedReactions.length - 2}
</Text>
)}
</div>
<Text size="xs" fw={600}>
{groupedCount}
</Text>
</Group> </Group>
<EmojiPicker onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))} /> </Button>
)}
</Group>
<EmojiPicker
onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))}
userReactions={userReactions}
/>
</Group> </Group>
<Sheet title="Reactions" opened={opened} onChange={() => close()}> <Sheet title="Reactions" opened={opened} onChange={() => close()}>
<Tabs value={activeTab || reactions[0]?.emoji} onChange={setActiveTab}> <Stack gap="md">
<Tabs.List grow> <ScrollArea w="100%" offsetScrollbars>
{reactions.map((reaction) => ( <Group gap="xs" wrap="nowrap" px="xs">
<Tabs.Tab key={reaction.emoji} value={reaction.emoji}> {sortedReactions.map((reaction) => (
<Group gap="xs" align="center"> <Button
<Text size="lg">{reaction.emoji}</Text> key={reaction.emoji}
<Text size="xs" c="dimmed"> variant={selectedEmoji === reaction.emoji ? "filled" : "light"}
color="gray"
size="compact-sm"
radius="xl"
onClick={() => setSelectedEmoji(reaction.emoji)}
style={{ flexShrink: 0 }}
>
<Group gap={4} align="center">
<Text size="sm">{reaction.emoji}</Text>
<Text size="xs" fw={600}>
{reaction.count} {reaction.count}
</Text> </Text>
</Group> </Group>
</Tabs.Tab> </Button>
))} ))}
</Tabs.List> </Group>
</ScrollArea>
{reactions.map((reaction) => ( {selectedEmoji && (
<Tabs.Panel key={reaction.emoji} value={reaction.emoji} pt="md"> <Paper p="md" withBorder radius="md">
<PlayerList players={reaction.players} /> <Group gap="sm" mb="md">
</Tabs.Panel> <Text size="2xl">{selectedEmoji}</Text>
))} <div>
</Tabs> <Text size="lg" fw={600}>
{sortedReactions.find(r => r.emoji === selectedEmoji)?.count || 0}
</Text>
</div>
</Group>
<PlayerList players={sortedReactions.find(r => r.emoji === selectedEmoji)?.players || []} />
</Paper>
)}
</Stack>
</Sheet> </Sheet>
</> </>
); );

View File

@@ -5,20 +5,28 @@ import { useState } from "react";
interface EmojiPickerProps { interface EmojiPickerProps {
onSelect: (emoji: string) => void; onSelect: (emoji: string) => void;
disabled?: boolean; disabled?: boolean;
userReactions?: string[];
} }
const EMOJIS = [ const EMOJIS = [
{ emoji: "😊", label: "smile" }, { emoji: "🫡", label: "salute" },
{ emoji: "😢", label: "cry" }, { emoji: "😭", label: "crying" },
{ emoji: "🫦", label: "lip" },
{ emoji: "🏗️", label: "crane" },
{ emoji: "👀", label: "eyes" }, { emoji: "👀", label: "eyes" },
{ emoji: "🔥", label: "fire" }, { emoji: "😱", label: "scream" },
{ emoji: "❤️", label: "heart" }, { emoji: "🥹", label: "owo" },
{ emoji: "👑", label: "crown" }, { emoji: "🤣", label: "rofl" },
{ emoji: "🤪", label: "crazy" },
{ emoji: "🤓", label: "nerd" },
{ emoji: "🥵", label: "hot" },
{ emoji: "🥶", label: "cold" },
]; ];
const EmojiPicker = ({ const EmojiPicker = ({
onSelect, onSelect,
disabled = false disabled = false,
userReactions = []
}: EmojiPickerProps) => { }: EmojiPickerProps) => {
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
@@ -50,8 +58,10 @@ const EmojiPicker = ({
</Popover.Target> </Popover.Target>
<Popover.Dropdown p="xs"> <Popover.Dropdown p="xs">
<SimpleGrid cols={6} spacing={0}> <SimpleGrid cols={6} spacing={4}>
{EMOJIS.map(({ emoji, label }) => ( {EMOJIS.map(({ emoji, label }) => {
const hasReacted = userReactions.includes(emoji);
return (
<UnstyledButton <UnstyledButton
key={emoji} key={emoji}
onClick={() => handleEmojiSelect(emoji)} onClick={() => handleEmojiSelect(emoji)}
@@ -62,11 +72,15 @@ const EmojiPicker = ({
justifyContent: "center", justifyContent: "center",
minHeight: 36, minHeight: 36,
minWidth: 36, minWidth: 36,
backgroundColor: hasReacted ? 'var(--mantine-primary-color-light)' : undefined,
border: hasReacted ? '1px solid var(--mantine-primary-color-filled)' : undefined,
}} }}
styles={{ styles={{
root: { root: {
'&:hover': { '&:hover': {
backgroundColor: 'var(--mantine-color-gray-1)', backgroundColor: hasReacted
? 'var(--mantine-primary-color-light-hover)'
: 'var(--mantine-color-gray-1)',
}, },
'&:active': { '&:active': {
transform: 'scale(0.95)', transform: 'scale(0.95)',
@@ -79,7 +93,8 @@ const EmojiPicker = ({
{emoji} {emoji}
</Text> </Text>
</UnstyledButton> </UnstyledButton>
))} );
})}
</SimpleGrid> </SimpleGrid>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>

View File

@@ -1,7 +1,6 @@
import { useServerQuery, useServerMutation } from "@/lib/tanstack-query/hooks"; import { useServerQuery, useServerMutation } from "@/lib/tanstack-query/hooks";
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server"; import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { matchKeys } from "@/features/matches/queries";
export const reactionKeys = { export const reactionKeys = {
match: (matchId: string) => ['reactions', 'match', matchId] as const, match: (matchId: string) => ['reactions', 'match', matchId] as const,
@@ -26,9 +25,6 @@ export const useToggleMatchReaction = (matchId: string) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: reactionKeys.match(matchId) queryKey: reactionKeys.match(matchId)
}); });
queryClient.invalidateQueries({
queryKey: matchKeys.reactions(matchId)
});
}, },
}); });
}; };

View File

@@ -17,8 +17,6 @@ interface TeamCardProps {
const TeamCard = ({ teamId }: TeamCardProps) => { const TeamCard = ({ teamId }: TeamCardProps) => {
const { data: team, error } = useTeam(teamId); const { data: team, error } = useTeam(teamId);
console.log(team)
if (error || !team) { if (error || !team) {
return ( return (
<Paper p="sm" withBorder radius="md"> <Paper p="sm" withBorder radius="md">

View File

@@ -106,7 +106,6 @@ const TeamForm = ({
mutation(teamData, { mutation(teamData, {
onSuccess: async (team: any) => { onSuccess: async (team: any) => {
console.log(team)
queryClient.invalidateQueries({ queryKey: teamKeys.list }); queryClient.invalidateQueries({ queryKey: teamKeys.list });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: teamKeys.details(team.id), queryKey: teamKeys.details(team.id),
@@ -130,7 +129,6 @@ const TeamForm = ({
const result = await response.json(); const result = await response.json();
console.log('here for some reason', result)
queryClient.setQueryData( queryClient.setQueryData(
tournamentKeys.details(result.team!.id), tournamentKeys.details(result.team!.id),

View File

@@ -15,7 +15,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
); );
return ( return (
<Stack align="center" gap={0}> <Stack px="sm" align="center" gap={0}>
<Avatar <Avatar
name={tournament.name} name={tournament.name}
src={ src={

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Logger } from "@/lib/logger"; import { Logger } from "@/lib/logger";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { tournamentKeys, tournamentQueries } from "@/features/tournaments/queries"; import { tournamentKeys, tournamentQueries } from "@/features/tournaments/queries";
import { reactionKeys, reactionQueries } from "@/features/reactions/queries";
let newIdeasAvailable = false; let newIdeasAvailable = false;
let newIdeasCallbacks: (() => void)[] = []; let newIdeasCallbacks: (() => void)[] = [];
@@ -28,10 +29,13 @@ const eventHandlers: Record<string, EventHandler> = {
}, },
"match": (event, queryClient) => { "match": (event, queryClient) => {
console.log(event);
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId)) queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
queryClient.invalidateQueries(tournamentQueries.current()) queryClient.invalidateQueries(tournamentQueries.current())
},
"reaction": (event, queryClient) => {
queryClient.invalidateQueries(reactionQueries.match(event.matchId));
queryClient.setQueryData(reactionKeys.match(event.matchId), () => event.reactions);
} }
}; };

View File

@@ -13,4 +13,9 @@ export type MatchEvent = {
tournamentId: string; tournamentId: string;
} }
export type ServerEvent = TestEvent | MatchEvent; export type ReactionEvent = {
type: "reaction";
matchId: string;
}
export type ServerEvent = TestEvent | MatchEvent | ReactionEvent;

View File

@@ -80,8 +80,6 @@ export function createPlayersService(pb: PocketBase) {
}, },
async getPlayerMatches(playerId: string): Promise<Match[]> { async getPlayerMatches(playerId: string): Promise<Match[]> {
console.log('----------------')
console.log(playerId)
const player = await pb.collection("players").getOne(playerId.trim(), { const player = await pb.collection("players").getOne(playerId.trim(), {
expand: "teams", expand: "teams",
}); });