reactions
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
160
src/features/reactions/components/emoji-bar.tsx
Normal file
160
src/features/reactions/components/emoji-bar.tsx
Normal 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;
|
||||
89
src/features/reactions/components/emoji-picker.tsx
Normal file
89
src/features/reactions/components/emoji-picker.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user