reactions

This commit is contained in:
yohlo
2025-09-17 10:30:10 -05:00
parent 498010e3e2
commit cac42c9b29
5 changed files with 360 additions and 89 deletions

View File

@@ -18,6 +18,7 @@
"@mantine/hooks": "^8.2.4",
"@mantine/tiptap": "^8.2.4",
"@phosphor-icons/react": "^2.1.10",
"@svgmoji/noto": "^3.2.0",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-router": "^1.130.12",

View File

@@ -3,16 +3,14 @@ import {
Group,
Stack,
Paper,
ThemeIcon,
Indicator,
Box,
Badge,
Skeleton,
} from "@mantine/core";
import { TrophyIcon, CrownIcon } from "@phosphor-icons/react";
import { CrownIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types";
import Avatar from "@/components/avatar";
import EmojiBar from "@/features/reactions/components/emoji-bar";
interface MatchCardProps {
match: Match;
@@ -47,98 +45,117 @@ const MatchCard = ({ match }: MatchCardProps) => {
position="top-end"
offset={2}
>
<Paper p="md" withBorder radius="md">
<Stack gap="sm">
<Group gap="xs">
<Text size="xs" fw={600} lineClamp={1} c="dimmed">
{match.tournament.name}
</Text>
<Text c="dimmed">-</Text>
<Text size="xs" c="dimmed">
Round {match.round + 1}
{match.is_losers_bracket && " (Losers)"}
</Text>
</Group>
<Group justify="space-between" align="center">
<Group gap="sm" style={{ flex: 1 }}>
<Box
style={{ position: "relative", cursor: "pointer" }}
onClick={handleHomeTeamClick}
>
<Avatar size={40} name={match.home?.name!} radius="sm" />
{isHomeWin && (
<Box
style={{
position: "absolute",
top: -10,
left: -4,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
<Text
size="md"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1 }}
>
{match.home?.name!}
<Box style={{ position: "relative" }}>
<Paper px="md" py="md" withBorder radius="md" style={{ position: "relative", zIndex: 2 }}>
<Stack gap="sm">
<Group gap="xs">
<Text size="xs" fw={600} lineClamp={1} c="dimmed">
{match.tournament.name}
</Text>
<Text c="dimmed">-</Text>
<Text size="xs" c="dimmed">
Round {match.round + 1}
{match.is_losers_bracket && " (Losers)"}
</Text>
</Group>
<Text
size="xl"
fw={700}
c={"dimmed"}
>
{match.home_cups}
</Text>
</Group>
<Group justify="space-between" align="center">
<Group gap="sm" style={{ flex: 1 }}>
<Box
style={{ position: "relative", cursor: "pointer" }}
onClick={handleAwayTeamClick}
>
<Avatar size={40} name={match.away?.name!} radius="sm" />
{isAwayWin && (
<Box
style={{
position: "absolute",
top: -10,
left: -4,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
<Group justify="space-between" align="center">
<Group gap="sm" style={{ flex: 1 }}>
<Box
style={{ position: "relative", cursor: "pointer" }}
onClick={handleHomeTeamClick}
>
<Avatar size={40} name={match.home?.name!} radius="sm" />
{isHomeWin && (
<Box
style={{
position: "absolute",
top: -10,
left: -4,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
<Text
size="md"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1 }}
>
{match.home?.name!}
</Text>
</Group>
<Text
size="md"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1 }}
size="xl"
fw={700}
c={"dimmed"}
>
{match.away?.name}
{match.home_cups}
</Text>
</Group>
<Text
size="xl"
fw={700}
c={"dimmed"}
>
{match.away_cups}
</Text>
</Group>
</Stack>
</Paper>
<Group justify="space-between" align="center">
<Group gap="sm" style={{ flex: 1 }}>
<Box
style={{ position: "relative", cursor: "pointer" }}
onClick={handleAwayTeamClick}
>
<Avatar size={40} name={match.away?.name!} radius="sm" />
{isAwayWin && (
<Box
style={{
position: "absolute",
top: -10,
left: -4,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
<Text
size="md"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1 }}
>
{match.away?.name}
</Text>
</Group>
<Text
size="xl"
fw={700}
c={"dimmed"}
>
{match.away_cups}
</Text>
</Group>
</Stack>
</Paper>
<Paper
px="md"
pb={4}
style={{
position: "relative",
zIndex: 1,
marginTop: -6,
paddingTop: 8,
backgroundColor: "var(--mantine-color-gray-light)",
borderTop: "none",
borderRadius: "0 0 var(--mantine-radius-md) var(--mantine-radius-md)",
border: "1px solid var(--mantine-color-default-border)",
}}
>
<EmojiBar />
</Paper>
</Box>
</Indicator>
);
};

View File

@@ -0,0 +1,160 @@
import {
Group,
UnstyledButton,
Text,
Tabs,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { useState, useRef } 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;
}
interface EmojiBarProps {
reactions?: Reaction[];
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,
onReactionPress,
}: EmojiBarProps) => {
const [opened, { open, close }] = useDisclosure(false);
const [activeTab, setActiveTab] = useState<string | null>(null);
const longPressTimeout = useRef<NodeJS.Timeout | null>(null);
const handleLongPressStart = (emoji: string) => {
longPressTimeout.current = setTimeout(() => {
setActiveTab(emoji);
open();
}, 500);
};
const handleLongPressEnd = () => {
if (longPressTimeout.current) {
clearTimeout(longPressTimeout.current);
}
};
const handleReactionClick = (emoji: string) => {
handleLongPressEnd();
onReactionPress?.(emoji);
};
if (!reactions.length) return null;
return (
<>
<Group gap="xs" wrap="wrap" justify="space-between">
<Group gap="xs" wrap="wrap">
{reactions.map((reaction) => (
<UnstyledButton
key={reaction.emoji}
onMouseDown={() => handleLongPressStart(reaction.emoji)}
onMouseUp={handleLongPressEnd}
onMouseLeave={handleLongPressEnd}
onTouchStart={() => handleLongPressStart(reaction.emoji)}
onTouchEnd={handleLongPressEnd}
onClick={() => handleReactionClick(reaction.emoji)}
px="8px"
py="10px"
style={{
borderRadius: "var(--mantine-radius-xl)",
border: reaction.hasReacted
? "1px solid var(--mantine-primary-color-filled)"
: "1px solid var(--mantine-color-default-border)",
backgroundColor: reaction.hasReacted
? "var(--mantine-primary-color-light)"
: "transparent",
transition: "all 0.15s ease",
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}
styles={{
root: {
"&:hover": {
backgroundColor: reaction.hasReacted
? "var(--mantine-primary-color-light)"
: "var(--mantine-color-gray-1)",
transform: "scale(1.05)",
},
"&:active": {
transform: "scale(0.95)",
},
},
}}
>
<Group gap={4} align="center">
<Text size="10px" style={{ lineHeight: 1 }}>
{reaction.emoji}
</Text>
<Text
size="10px"
fw={700}
c={
reaction.hasReacted
? "var(--mantine-primary-color-filled)"
: "dimmed"
}
>
{reaction.count}
</Text>
</Group>
</UnstyledButton>
))}
</Group>
<EmojiPicker onSelect={onReactionPress || (() => {})} />
</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>
{reactions.map((reaction) => (
<Tabs.Panel key={reaction.emoji} value={reaction.emoji} pt="md">
<PlayerList players={reaction.players} />
</Tabs.Panel>
))}
</Tabs>
</Sheet>
</>
);
};
export default EmojiBar;

View File

@@ -0,0 +1,89 @@
import { ActionIcon, Popover, SimpleGrid, UnstyledButton, Text } from "@mantine/core";
import { SmileyStickerIcon } from "@phosphor-icons/react";
import { useState } from "react";
interface EmojiPickerProps {
onSelect: (emoji: string) => void;
disabled?: boolean;
}
const EMOJIS = [
{ emoji: "😊", label: "smile" },
{ emoji: "😢", label: "cry" },
{ emoji: "👀", label: "eyes" },
{ emoji: "🔥", label: "fire" },
{ emoji: "❤️", label: "heart" },
{ emoji: "👑", label: "crown" },
];
const EmojiPicker = ({
onSelect,
disabled = false
}: EmojiPickerProps) => {
const [opened, setOpened] = useState(false);
const handleEmojiSelect = (emoji: string) => {
onSelect(emoji);
setOpened(false);
};
return (
<Popover
position="bottom"
withArrow
shadow="sm"
opened={opened}
onChange={setOpened}
trapFocus
closeOnEscape
closeOnClickOutside
>
<Popover.Target>
<ActionIcon
variant="subtle"
onClick={() => setOpened((o) => !o)}
disabled={disabled}
aria-label="Select emoji"
>
<SmileyStickerIcon size={16} />
</ActionIcon>
</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)',
},
'&:active': {
transform: 'scale(0.95)',
},
},
}}
aria-label={label}
>
<Text size="lg" style={{ lineHeight: 1 }}>
{emoji}
</Text>
</UnstyledButton>
))}
</SimpleGrid>
</Popover.Dropdown>
</Popover>
);
};
export default EmojiPicker;

View File

@@ -20,6 +20,8 @@ import EnrollTeam from "./enroll-team";
import EnrollFreeAgent from "./enroll-free-agent";
import TeamListButton from "./team-list-button";
import Header from "./header";
import EmojiPicker from "@/features/reactions/components/emoji-picker";
import EmojiBar from "@/features/reactions/components/emoji-bar";
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
tournament,
@@ -46,6 +48,8 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
<Header tournament={tournament} />
<EmojiBar />
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
<Card withBorder radius="md" p="lg">