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("match", handleEvent);
serverEvents.on("reaction", handleEvent);
const pingInterval = setInterval(() => {
try {

View File

@@ -29,8 +29,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
}
};
console.log(match);
return (
<Indicator
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",
});
console.log('emitting start match...')
serverEvents.emit("match", {
type: "match",
matchId: match.id,
@@ -300,7 +299,6 @@ export const toggleMatchReaction = createServerFn()
serverEvents.emit("reaction", {
type: "reaction",
matchId,
tournamentId: match.tournament.id,
reactions,
});

View File

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

View File

@@ -2,7 +2,9 @@ import {
Group,
Button,
Text,
Tabs,
Stack,
ScrollArea,
Paper,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useState, useRef, useCallback } from "react";
@@ -27,12 +29,12 @@ const EmojiBar = ({
const toggleReaction = useToggleMatchReaction(matchId);
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 handleLongPressStart = (emoji: string) => {
longPressTimeout.current = setTimeout(() => {
setActiveTab(emoji);
setSelectedEmoji(emoji);
open();
}, 500);
};
@@ -54,17 +56,29 @@ const EmojiBar = ({
const hasReacted = useCallback((reaction: Reaction) => {
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;
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 (
<>
<Group gap="xs" wrap="wrap" justify="space-between">
<Group gap="xs" wrap="wrap">
{reactions.map((reaction) => (
{visibleReactions.map((reaction) => (
<Button
key={reaction.emoji}
variant={hasReacted(reaction) ? "filled" : "light"}
@@ -95,31 +109,100 @@ const EmojiBar = ({
</Group>
</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>
</Button>
)}
</Group>
<EmojiPicker onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))} />
<EmojiPicker
onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))}
userReactions={userReactions}
/>
</Group>
<Sheet title="Reactions" opened={opened} onChange={() => close()}>
<Tabs value={activeTab || reactions[0]?.emoji} onChange={setActiveTab}>
<Tabs.List grow>
{reactions.map((reaction) => (
<Tabs.Tab key={reaction.emoji} value={reaction.emoji}>
<Group gap="xs" align="center">
<Text size="lg">{reaction.emoji}</Text>
<Text size="xs" c="dimmed">
{reaction.count}
</Text>
</Group>
</Tabs.Tab>
))}
</Tabs.List>
<Stack gap="md">
<ScrollArea w="100%" offsetScrollbars>
<Group gap="xs" wrap="nowrap" px="xs">
{sortedReactions.map((reaction) => (
<Button
key={reaction.emoji}
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}
</Text>
</Group>
</Button>
))}
</Group>
</ScrollArea>
{reactions.map((reaction) => (
<Tabs.Panel key={reaction.emoji} value={reaction.emoji} pt="md">
<PlayerList players={reaction.players} />
</Tabs.Panel>
))}
</Tabs>
{selectedEmoji && (
<Paper p="md" withBorder radius="md">
<Group gap="sm" mb="md">
<Text size="2xl">{selectedEmoji}</Text>
<div>
<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>
</>
);

View File

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

View File

@@ -1,7 +1,6 @@
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,
@@ -26,9 +25,6 @@ export const useToggleMatchReaction = (matchId: string) => {
queryClient.invalidateQueries({
queryKey: reactionKeys.match(matchId)
});
queryClient.invalidateQueries({
queryKey: matchKeys.reactions(matchId)
});
},
});
};

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Logger } from "@/lib/logger";
import { useAuth } from "@/contexts/auth-context";
import { tournamentKeys, tournamentQueries } from "@/features/tournaments/queries";
import { reactionKeys, reactionQueries } from "@/features/reactions/queries";
let newIdeasAvailable = false;
let newIdeasCallbacks: (() => void)[] = [];
@@ -28,10 +29,13 @@ const eventHandlers: Record<string, EventHandler> = {
},
"match": (event, queryClient) => {
console.log(event);
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
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;
}
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[]> {
console.log('----------------')
console.log(playerId)
const player = await pb.collection("players").getOne(playerId.trim(), {
expand: "teams",
});