various improvements
This commit is contained in:
@@ -24,6 +24,9 @@
|
|||||||
"@tanstack/react-router-devtools": "^1.130.13",
|
"@tanstack/react-router-devtools": "^1.130.13",
|
||||||
"@tanstack/react-router-with-query": "^1.130.12",
|
"@tanstack/react-router-with-query": "^1.130.12",
|
||||||
"@tanstack/react-start": "^1.130.15",
|
"@tanstack/react-start": "^1.130.15",
|
||||||
|
"@tiptap/pm": "^3.4.3",
|
||||||
|
"@tiptap/react": "^3.4.3",
|
||||||
|
"@tiptap/starter-kit": "^3.4.3",
|
||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
|
|||||||
37
pb_migrations/1758049631_updated_player_stats.js
Normal file
37
pb_migrations/1758049631_updated_player_stats.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1358894712")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended'\n GROUP BY p.id"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(4, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3837590211",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "tournaments",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1358894712")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended'\n GROUP BY p.id"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number3837590211")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1758054877_updated_tournaments.js
Normal file
28
pb_migrations/1758054877_updated_tournaments.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(11, new Field({
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation1584152981",
|
||||||
|
"maxSelect": 999,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "free_agents",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("relation1584152981")
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
84
pb_migrations/1758081731_created_reactions.js
Normal file
84
pb_migrations/1758081731_created_reactions.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3058431538",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "emoji",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_3072146508",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2551806565",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "player",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1549310251",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "reactions",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1549310251");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
import "@mantine/dates/styles.css";
|
import "@mantine/dates/styles.css";
|
||||||
import "@mantine/carousel/styles.css";
|
import "@mantine/carousel/styles.css";
|
||||||
|
import '@mantine/tiptap/styles.css';
|
||||||
import {
|
import {
|
||||||
HeadContent,
|
HeadContent,
|
||||||
Navigate,
|
Navigate,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const Route = createFileRoute("/_authed/stats")({
|
|||||||
await ensureServerQueryData(queryClient, playerQueries.allStats());
|
await ensureServerQueryData(queryClient, playerQueries.allStats());
|
||||||
},
|
},
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
withPadding: true,
|
withPadding: false,
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
header: {
|
header: {
|
||||||
title: "Player Stats"
|
title: "Player Stats"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function RouteComponent() {
|
|||||||
const { roles } = useAuth();
|
const { roles } = useAuth();
|
||||||
const sheet = useSheet();
|
const sheet = useSheet();
|
||||||
|
|
||||||
return <Page>
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
{
|
{
|
||||||
roles?.includes("Admin") ? (
|
roles?.includes("Admin") ? (
|
||||||
@@ -49,5 +49,5 @@ function RouteComponent() {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Page>
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const Page = ({ children, noPadding, fullWidth, ...props }: PageProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{header.collapsed && header.withBackButton && (
|
{header.collapsed && header.withBackButton && (
|
||||||
<BackButton />
|
<BackButton top={4} />
|
||||||
)}
|
)}
|
||||||
{header.collapsed && header.settingsLink && (
|
{header.collapsed && header.settingsLink && (
|
||||||
<SettingsButton to={header.settingsLink} />
|
<SettingsButton to={header.settingsLink} />
|
||||||
|
|||||||
40
src/components/rich-text-editor.tsx
Normal file
40
src/components/rich-text-editor.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEditor } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { RichTextEditor as MantineRichTextEditor } from '@mantine/tiptap';
|
||||||
|
|
||||||
|
interface RichTextEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RichTextEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: RichTextEditorProps) {
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [StarterKit],
|
||||||
|
content: value,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
onChange(editor.getHTML());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MantineRichTextEditor editor={editor}>
|
||||||
|
<MantineRichTextEditor.Toolbar>
|
||||||
|
<MantineRichTextEditor.ControlsGroup>
|
||||||
|
<MantineRichTextEditor.Bold />
|
||||||
|
<MantineRichTextEditor.Italic />
|
||||||
|
</MantineRichTextEditor.ControlsGroup>
|
||||||
|
<MantineRichTextEditor.ControlsGroup>
|
||||||
|
<MantineRichTextEditor.Blockquote />
|
||||||
|
<MantineRichTextEditor.Hr />
|
||||||
|
<MantineRichTextEditor.BulletList />
|
||||||
|
<MantineRichTextEditor.OrderedList />
|
||||||
|
</MantineRichTextEditor.ControlsGroup>
|
||||||
|
</MantineRichTextEditor.Toolbar>
|
||||||
|
|
||||||
|
<MantineRichTextEditor.Content />
|
||||||
|
</MantineRichTextEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,13 +98,11 @@ function SwipeableTabs({
|
|||||||
updateHeight();
|
updateHeight();
|
||||||
}, [activeTab, updateHeight]);
|
}, [activeTab, updateHeight]);
|
||||||
|
|
||||||
// Update height when content changes (after render)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = setTimeout(updateHeight, 0);
|
const timeoutId = setTimeout(updateHeight, 0);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use ResizeObserver to watch for content size changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeSlideRef = slideRefs.current[activeTab];
|
const activeSlideRef = slideRefs.current[activeTab];
|
||||||
if (!activeSlideRef) return;
|
if (!activeSlideRef) return;
|
||||||
@@ -142,6 +140,7 @@ function SwipeableTabs({
|
|||||||
top={0}
|
top={0}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
paddingInline: "var(--mantine-spacing-md)",
|
||||||
marginBottom: "var(--mantine-spacing-md)",
|
marginBottom: "var(--mantine-spacing-md)",
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
backgroundColor: "var(--mantine-color-body)",
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Box } from "@mantine/core"
|
|||||||
import { ArrowLeftIcon } from "@phosphor-icons/react"
|
import { ArrowLeftIcon } from "@phosphor-icons/react"
|
||||||
import { useRouter } from "@tanstack/react-router"
|
import { useRouter } from "@tanstack/react-router"
|
||||||
|
|
||||||
const BackButton = () => {
|
const BackButton = ({ top=20, left=20 }: { top?: number, left?: number }) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -10,8 +10,8 @@ const BackButton = () => {
|
|||||||
style={{ cursor: 'pointer', zIndex: 1000 }}
|
style={{ cursor: 'pointer', zIndex: 1000 }}
|
||||||
onClick={() => router.history.back()}
|
onClick={() => router.history.back()}
|
||||||
pos='absolute'
|
pos='absolute'
|
||||||
left={16}
|
left={left}
|
||||||
top={0}
|
top={top}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon weight='bold' size={20} />
|
<ArrowLeftIcon weight='bold' size={20} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Title, AppShell, Flex } from "@mantine/core";
|
import { Title, AppShell, Flex } from "@mantine/core";
|
||||||
import { HeaderConfig } from "../types/header-config";
|
import { HeaderConfig } from "../types/header-config";
|
||||||
|
import useRouterConfig from "../hooks/use-router-config";
|
||||||
|
import BackButton from "./back-button";
|
||||||
|
|
||||||
interface HeaderProps extends HeaderConfig {}
|
interface HeaderProps extends HeaderConfig {}
|
||||||
|
|
||||||
const Header = ({ collapsed, title }: HeaderProps) => {
|
const Header = ({ collapsed, title }: HeaderProps) => {
|
||||||
|
const { header } = useRouterConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
||||||
|
{ header.withBackButton && <BackButton /> }
|
||||||
<Flex justify='center' align='center' h='100%' px='md'>
|
<Flex justify='center' align='center' h='100%' px='md'>
|
||||||
<Title order={2}>{title}</Title>
|
<Title order={2}>{title}</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { memo } from "react";
|
|||||||
|
|
||||||
interface SettingButtonProps {
|
interface SettingButtonProps {
|
||||||
to: string;
|
to: string;
|
||||||
|
top?: number;
|
||||||
|
right?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsButton = ({ to }: SettingButtonProps) => {
|
const SettingsButton = ({ to }: SettingButtonProps) => {
|
||||||
@@ -15,8 +17,8 @@ const SettingsButton = ({ to }: SettingButtonProps) => {
|
|||||||
style={{ cursor: 'pointer', zIndex: 1000 }}
|
style={{ cursor: 'pointer', zIndex: 1000 }}
|
||||||
onClick={() => navigate({ to })}
|
onClick={() => navigate({ to })}
|
||||||
pos='absolute'
|
pos='absolute'
|
||||||
right={16}
|
right={20}
|
||||||
top={0}
|
top={6}
|
||||||
>
|
>
|
||||||
<GearIcon weight='bold' size={20} />
|
<GearIcon weight='bold' size={20} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Table,
|
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -9,10 +8,11 @@ import {
|
|||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
ScrollArea,
|
Divider,
|
||||||
Paper,
|
UnstyledButton,
|
||||||
Popover,
|
Popover,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Skeleton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
@@ -22,7 +22,8 @@ import {
|
|||||||
InfoIcon,
|
InfoIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { PlayerStats } from "../types";
|
import { PlayerStats } from "../types";
|
||||||
import { motion } from "framer-motion";
|
import Avatar from "@/components/avatar";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
interface PlayerStatsTableProps {
|
interface PlayerStatsTableProps {
|
||||||
playerStats: PlayerStats[];
|
playerStats: PlayerStats[];
|
||||||
@@ -36,7 +37,138 @@ interface SortConfig {
|
|||||||
direction: SortDirection;
|
direction: SortDirection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlayerListItemProps {
|
||||||
|
stat: PlayerStats;
|
||||||
|
index: number;
|
||||||
|
onPlayerClick: (playerId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerListItem = ({ stat, index, onPlayerClick }: PlayerListItemProps) => {
|
||||||
|
const calculateMMR = (stat: PlayerStats): number => {
|
||||||
|
if (stat.matches === 0) return 0;
|
||||||
|
|
||||||
|
const winScore = stat.win_percentage;
|
||||||
|
const matchConfidence = Math.min(stat.matches / 15, 1);
|
||||||
|
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
|
||||||
|
const marginScore = stat.margin_of_victory
|
||||||
|
? Math.min(stat.margin_of_victory * 20, 50)
|
||||||
|
: 0;
|
||||||
|
const volumeBonus = Math.min(stat.matches * 0.5, 10);
|
||||||
|
|
||||||
|
const baseMMR =
|
||||||
|
winScore * 0.5 +
|
||||||
|
avgCupsScore * 0.25 +
|
||||||
|
marginScore * 0.15 +
|
||||||
|
volumeBonus * 0.1;
|
||||||
|
|
||||||
|
const finalMMR = baseMMR * matchConfidence;
|
||||||
|
return Math.round(finalMMR * 10) / 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mmr = calculateMMR(stat);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<UnstyledButton
|
||||||
|
w="100%"
|
||||||
|
p="md"
|
||||||
|
onClick={() => onPlayerClick(stat.id)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 0,
|
||||||
|
transition: "background-color 0.15s ease",
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" w="100%">
|
||||||
|
<Group gap="sm" align="center">
|
||||||
|
<Avatar name={stat.player_name} size={40} />
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Group gap='xs'>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{stat.player_name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
|
{stat.matches} matches
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
|
{stat.tournaments} tournaments
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="md" ta="center">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
MMR
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{mmr.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
W
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.wins}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
L
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.losses}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
W%
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.win_percentage.toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
AVG
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.avg_cups_per_match.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
CF
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.total_cups_made}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
CA
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stat.total_cups_against}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||||
key: "mmr" as SortKey,
|
key: "mmr" as SortKey,
|
||||||
@@ -47,14 +179,11 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
if (stat.matches === 0) return 0;
|
if (stat.matches === 0) return 0;
|
||||||
|
|
||||||
const winScore = stat.win_percentage;
|
const winScore = stat.win_percentage;
|
||||||
|
|
||||||
const matchConfidence = Math.min(stat.matches / 15, 1);
|
const matchConfidence = Math.min(stat.matches / 15, 1);
|
||||||
|
|
||||||
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
|
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
|
||||||
const marginScore = stat.margin_of_victory
|
const marginScore = stat.margin_of_victory
|
||||||
? Math.min(stat.margin_of_victory * 20, 50)
|
? Math.min(stat.margin_of_victory * 20, 50)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const volumeBonus = Math.min(stat.matches * 0.5, 10);
|
const volumeBonus = Math.min(stat.matches * 0.5, 10);
|
||||||
|
|
||||||
const baseMMR =
|
const baseMMR =
|
||||||
@@ -64,26 +193,9 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
volumeBonus * 0.1;
|
volumeBonus * 0.1;
|
||||||
|
|
||||||
const finalMMR = baseMMR * matchConfidence;
|
const finalMMR = baseMMR * matchConfidence;
|
||||||
|
|
||||||
return Math.round(finalMMR * 10) / 10;
|
return Math.round(finalMMR * 10) / 10;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (key: SortKey) => {
|
|
||||||
setSortConfig((prev) => ({
|
|
||||||
key,
|
|
||||||
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSortIcon = (key: SortKey) => {
|
|
||||||
if (sortConfig.key !== key) return null;
|
|
||||||
return sortConfig.direction === "desc" ? (
|
|
||||||
<CaretDownIcon size={14} />
|
|
||||||
) : (
|
|
||||||
<CaretUpIcon size={14} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredAndSortedStats = useMemo(() => {
|
const filteredAndSortedStats = useMemo(() => {
|
||||||
let filtered = playerStats.filter((stat) =>
|
let filtered = playerStats.filter((stat) =>
|
||||||
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -117,78 +229,29 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
});
|
});
|
||||||
}, [playerStats, search, sortConfig]);
|
}, [playerStats, search, sortConfig]);
|
||||||
|
|
||||||
const formatPercentage = (value: number) => `${value.toFixed(1)}%`;
|
const handlePlayerClick = (playerId: string) => {
|
||||||
const formatDecimal = (value: number) => value.toFixed(2);
|
navigate({ to: `/profile/${playerId}` });
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const handleSort = (key: SortKey) => {
|
||||||
{ key: "player_name" as SortKey, label: "Player", width: 175 },
|
setSortConfig((prev) => ({
|
||||||
{ key: "mmr" as SortKey, label: "MMR", width: 90 },
|
key,
|
||||||
{ key: "win_percentage" as SortKey, label: "Win %", width: 110 },
|
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
||||||
{ key: "matches" as SortKey, label: "Matches", width: 90 },
|
}));
|
||||||
{ key: "wins" as SortKey, label: "Wins", width: 80 },
|
};
|
||||||
{ key: "losses" as SortKey, label: "Losses", width: 80 },
|
|
||||||
{ key: "total_cups_made" as SortKey, label: "Cups Made", width: 110 },
|
|
||||||
{ key: "total_cups_against" as SortKey, label: "Cups Against", width: 120 },
|
|
||||||
{ key: "avg_cups_per_match" as SortKey, label: "Avg/Match", width: 100 },
|
|
||||||
{ key: "margin_of_victory" as SortKey, label: "Win Margin", width: 110 },
|
|
||||||
{ key: "margin_of_loss" as SortKey, label: "Loss Margin", width: 110 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderCellContent = (
|
const getSortIcon = (key: SortKey) => {
|
||||||
stat: PlayerStats,
|
if (sortConfig.key !== key) return null;
|
||||||
column: (typeof columns)[0],
|
return sortConfig.direction === "desc" ? (
|
||||||
index: number
|
<CaretDownIcon size={14} />
|
||||||
) => {
|
) : (
|
||||||
switch (column.key) {
|
<CaretUpIcon size={14} />
|
||||||
case "player_name":
|
|
||||||
return (
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{stat.player_name}
|
|
||||||
</Text>
|
|
||||||
);
|
);
|
||||||
case "mmr":
|
|
||||||
const mmr = calculateMMR(stat);
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Text fw={700} size="md">
|
|
||||||
{mmr.toFixed(1)}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
case "win_percentage":
|
|
||||||
return <Text size="sm">{formatPercentage(stat.win_percentage)}</Text>;
|
|
||||||
case "wins":
|
|
||||||
return <Text fw={500}>{stat.wins}</Text>;
|
|
||||||
case "losses":
|
|
||||||
return <Text fw={500}>{stat.losses}</Text>;
|
|
||||||
case "total_cups_made":
|
|
||||||
return <Text fw={500}>{stat.total_cups_made}</Text>;
|
|
||||||
case "matches":
|
|
||||||
return <Text fw={500}>{stat.matches}</Text>;
|
|
||||||
case "avg_cups_per_match":
|
|
||||||
return <Text>{formatDecimal(stat.avg_cups_per_match)}</Text>;
|
|
||||||
case "margin_of_victory":
|
|
||||||
return (
|
|
||||||
<Text>
|
|
||||||
{stat.margin_of_victory
|
|
||||||
? formatDecimal(stat.margin_of_victory)
|
|
||||||
: "N/A"}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
case "margin_of_loss":
|
|
||||||
return (
|
|
||||||
<Text>
|
|
||||||
{stat.margin_of_loss ? formatDecimal(stat.margin_of_loss) : "N/A"}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return <Text>{(stat as any)[column.key]}</Text>;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (playerStats.length === 0) {
|
if (playerStats.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Container size="md">
|
<Container px={0} size="md">
|
||||||
<Stack align="center" gap="md" py="xl">
|
<Stack align="center" gap="md" py="xl">
|
||||||
<ThemeIcon size="xl" variant="light" radius="md">
|
<ThemeIcon size="xl" variant="light" radius="md">
|
||||||
<ChartBarIcon size={32} />
|
<ChartBarIcon size={32} />
|
||||||
@@ -196,9 +259,6 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
<Title order={3} c="dimmed">
|
<Title order={3} c="dimmed">
|
||||||
No Stats Available
|
No Stats Available
|
||||||
</Title>
|
</Title>
|
||||||
<Text c="dimmed" ta="center">
|
|
||||||
Player statistics will appear here once matches have been played.
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
@@ -207,84 +267,54 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
return (
|
return (
|
||||||
<Container size="100%" px={0}>
|
<Container size="100%" px={0}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text ml="auto" size="xs" c="dimmed">
|
|
||||||
{filteredAndSortedStats.length} of {playerStats.length} players
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search players"
|
placeholder="Search players"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
size="md"
|
size="md"
|
||||||
|
px="md"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Paper withBorder radius="md" p={0} style={{ overflow: "hidden" }}>
|
<Group px="md" justify="space-between" align="center">
|
||||||
<ScrollArea>
|
<Text size="10px" lh={0} c="dimmed">
|
||||||
<Table
|
{filteredAndSortedStats.length} of {playerStats.length} players
|
||||||
highlightOnHover
|
|
||||||
striped
|
|
||||||
withTableBorder={false}
|
|
||||||
style={{
|
|
||||||
minWidth: 1000,
|
|
||||||
borderRadius: "inherit",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table.Thead
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--mantine-color-body)",
|
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table.Tr>
|
|
||||||
{columns.map((column, index) => (
|
|
||||||
<Table.Th
|
|
||||||
key={column.key}
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
userSelect: "none",
|
|
||||||
width: column.width,
|
|
||||||
minWidth: column.width,
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontWeight: 600,
|
|
||||||
backgroundColor: "var(--mantine-color-body)",
|
|
||||||
borderBottom:
|
|
||||||
"2px solid var(--mantine-color-default-border)",
|
|
||||||
...(index === 0 && {
|
|
||||||
position: "sticky",
|
|
||||||
left: 0,
|
|
||||||
zIndex: 2,
|
|
||||||
borderTopLeftRadius: "var(--mantine-radius-md)",
|
|
||||||
}),
|
|
||||||
...(index === columns.length - 1 && {
|
|
||||||
borderTopRightRadius: "var(--mantine-radius-md)",
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
onClick={() => handleSort(column.key)}
|
|
||||||
>
|
|
||||||
<Group
|
|
||||||
gap="xs"
|
|
||||||
wrap="nowrap"
|
|
||||||
style={{ position: "relative" }}
|
|
||||||
>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{column.label}
|
|
||||||
</Text>
|
</Text>
|
||||||
{column.key === "mmr" && (
|
<Group gap="xs">
|
||||||
<div
|
<Text size="xs" c="dimmed">Sort:</Text>
|
||||||
onClick={(e) => {
|
<UnstyledButton
|
||||||
e.stopPropagation();
|
onClick={() => handleSort("mmr")}
|
||||||
e.preventDefault();
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Popover position="bottom" withArrow shadow="md">
|
<Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "dark" : "dimmed"}>
|
||||||
|
MMR
|
||||||
|
</Text>
|
||||||
|
{getSortIcon("mmr")}
|
||||||
|
</UnstyledButton>
|
||||||
|
<Text size="xs" c="dimmed">•</Text>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => handleSort("wins")}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "dark" : "dimmed"}>
|
||||||
|
Wins
|
||||||
|
</Text>
|
||||||
|
{getSortIcon("wins")}
|
||||||
|
</UnstyledButton>
|
||||||
|
<Text size="xs" c="dimmed">•</Text>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => handleSort("matches")}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
|
>
|
||||||
|
<Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "dark" : "dimmed"}>
|
||||||
|
Matches
|
||||||
|
</Text>
|
||||||
|
{getSortIcon("matches")}
|
||||||
|
</UnstyledButton>
|
||||||
|
<Popover position="bottom-end" withArrow shadow="md">
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionIcon variant="subtle" size="xs">
|
<ActionIcon variant="subtle" size="sm">
|
||||||
<InfoIcon size={12} />
|
<InfoIcon size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
@@ -305,8 +335,7 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
• Match Volume Bonus (10%)
|
• Match Volume Bonus (10%)
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mt="xs" c="dimmed">
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
* Confidence penalty applied for players
|
* Confidence penalty applied for players with <15 matches
|
||||||
with <15 matches
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mt="xs" c="dimmed">
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
** Not an official rating
|
** Not an official rating
|
||||||
@@ -314,87 +343,21 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
minWidth: 16,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getSortIcon(column.key)}
|
|
||||||
</Box>
|
|
||||||
{index === 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: "2px",
|
|
||||||
backgroundColor:
|
|
||||||
"var(--mantine-color-default-border)",
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Th>
|
</Group>
|
||||||
))}
|
|
||||||
</Table.Tr>
|
<Stack gap={0}>
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{filteredAndSortedStats.map((stat, index) => (
|
{filteredAndSortedStats.map((stat, index) => (
|
||||||
<motion.tr
|
<Box key={stat.id}>
|
||||||
key={stat.id}
|
<PlayerListItem
|
||||||
initial={{ opacity: 0, y: 10 }}
|
stat={stat}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
index={index}
|
||||||
transition={{ duration: 0.2, delay: index * 0.01 }}
|
onPlayerClick={handlePlayerClick}
|
||||||
style={{
|
|
||||||
borderBottom:
|
|
||||||
"1px solid var(--mantine-color-default-border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{columns.map((column, columnIndex) => (
|
|
||||||
<Table.Td
|
|
||||||
key={`${stat.id}-${column.key}`}
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
verticalAlign: "middle",
|
|
||||||
...(columnIndex === 0 && {
|
|
||||||
position: "sticky",
|
|
||||||
left: 0,
|
|
||||||
backgroundColor: "var(--mantine-color-body)",
|
|
||||||
zIndex: 1,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
{renderCellContent(stat, column, index)}
|
|
||||||
{columnIndex === 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: "2px",
|
|
||||||
backgroundColor:
|
|
||||||
"var(--mantine-color-default-border)",
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{index < filteredAndSortedStats.length - 1 && <Divider />}
|
||||||
</div>
|
</Box>
|
||||||
</Table.Td>
|
|
||||||
))}
|
))}
|
||||||
</motion.tr>
|
</Stack>
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</ScrollArea>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{filteredAndSortedStats.length === 0 && search && (
|
{filteredAndSortedStats.length === 0 && search && (
|
||||||
<Text ta="center" c="dimmed" py="xl">
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const Header = ({ player }: HeaderProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex px='lg' w='100%' align='self-end' gap='md'>
|
<Flex px='xl' w='100%' align='self-end' gap='md'>
|
||||||
<Avatar name={name} size={100} />
|
<Avatar name={name} size={100} />
|
||||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||||
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>
|
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header player={player} />
|
<Header player={player} />
|
||||||
<Box m='xs' mt="lg">
|
<Box mt="lg">
|
||||||
<SwipeableTabs tabs={tabs} />
|
<SwipeableTabs tabs={tabs} />
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface PlayerStats {
|
|||||||
player_id: string;
|
player_id: string;
|
||||||
player_name: string;
|
player_name: string;
|
||||||
matches: number;
|
matches: number;
|
||||||
|
tournaments: number;
|
||||||
wins: number;
|
wins: number;
|
||||||
losses: number;
|
losses: number;
|
||||||
total_cups_made: number;
|
total_cups_made: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
|
import { Divider, Group, List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { TeamInfo } from "@/features/teams/types";
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
@@ -15,16 +15,16 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Group justify="space-between" w='100%'>
|
||||||
<Stack gap={0}>
|
|
||||||
<Text fw={500}>{`${team.name}`}</Text>
|
<Text fw={500}>{`${team.name}`}</Text>
|
||||||
|
<Stack ml="auto" gap={0}>
|
||||||
{playerNames.map((name) => (
|
{playerNames.map((name) => (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</Group>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,15 +59,22 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
|||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
{teams?.map((team) => (
|
{teams?.map((team) => (
|
||||||
|
<div key={team.id}>
|
||||||
<ListItem
|
<ListItem
|
||||||
key={team.id}
|
p="xs"
|
||||||
py="xs"
|
|
||||||
icon={<Avatar radius="sm" size={40} name={`${team.name}`} />}
|
icon={<Avatar radius="sm" size={40} name={`${team.name}`} />}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
onClick={() => handleClick(team.id)}
|
onClick={() => handleClick(team.id)}
|
||||||
|
styles={{
|
||||||
|
itemWrapper: { width: "100%" },
|
||||||
|
itemLabel: { width: "100%" }
|
||||||
|
}}
|
||||||
|
w="100%"
|
||||||
>
|
>
|
||||||
<TeamListItem team={team} />
|
<TeamListItem team={team} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<Divider />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const TeamProfile = ({ id }: ProfileProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header name={team.name} logo={team.logo} />
|
<Header name={team.name} logo={team.logo} />
|
||||||
<Box m="xs" mt="lg">
|
<Box mt="lg">
|
||||||
<SwipeableTabs tabs={tabs} />
|
<SwipeableTabs tabs={tabs} />
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import EditEnrolledTeams from "./edit-enrolled-teams";
|
import EditEnrolledTeams from "./edit-enrolled-teams";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
|
import { RichTextEditor } from "@/components/rich-text-editor";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
interface ManageTournamentProps {
|
interface ManageTournamentProps {
|
||||||
tournamentId: string;
|
tournamentId: string;
|
||||||
@@ -37,6 +39,8 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
close: closeEditTeams,
|
close: closeEditTeams,
|
||||||
} = useSheet();
|
} = useSheet();
|
||||||
|
|
||||||
|
const [v, setV] = React.useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<List>
|
<List>
|
||||||
@@ -86,7 +90,9 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
opened={editRulesOpened}
|
opened={editRulesOpened}
|
||||||
onChange={closeEditRules}
|
onChange={closeEditRules}
|
||||||
>
|
>
|
||||||
<p>Test</p>
|
<RichTextEditor value={v} onChange={setV} />
|
||||||
|
|
||||||
|
{v}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Sheet
|
<Sheet
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Header tournament={tournament} />
|
<Header tournament={tournament} />
|
||||||
<Box m='sm' mt='lg'>
|
<Box mt='lg'>
|
||||||
<SwipeableTabs tabs={tabs} />
|
<SwipeableTabs tabs={tabs} />
|
||||||
</Box>
|
</Box>
|
||||||
</>;
|
</>;
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { Badge, Card, Text, Stack, Group, Box, ThemeIcon } from "@mantine/core";
|
import {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Text,
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Box,
|
||||||
|
ThemeIcon,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
import { Tournament } from "@/features/tournaments/types";
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TrophyIcon, CalendarIcon, MapPinIcon, UsersIcon } from "@phosphor-icons/react";
|
import {
|
||||||
|
TrophyIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
interface TournamentCardProps {
|
interface TournamentCardProps {
|
||||||
tournament: Tournament;
|
tournament: Tournament;
|
||||||
@@ -18,9 +31,9 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
const date = new Date(tournament.start_time);
|
const date = new Date(tournament.start_time);
|
||||||
if (isNaN(date.getTime())) return null;
|
if (isNaN(date.getTime())) return null;
|
||||||
return date.toLocaleDateString(undefined, {
|
return date.toLocaleDateString(undefined, {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
year: 'numeric'
|
year: "numeric",
|
||||||
});
|
});
|
||||||
}, [tournament.start_time]);
|
}, [tournament.start_time]);
|
||||||
|
|
||||||
@@ -31,33 +44,42 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
const enrolledTeamsCount = tournament.teams?.length || 0;
|
const enrolledTeamsCount = tournament.teams?.length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<UnstyledButton
|
||||||
whileHover={{ y: -4, scale: 1.02 }}
|
w="100%"
|
||||||
whileTap={{ scale: 0.98 }}
|
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
|
||||||
transition={{ type: "spring", stiffness: 300 }}
|
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
"&:hover": {
|
||||||
|
transform: "translateY(-2px)",
|
||||||
|
transition: "transform 0.15s ease",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
radius="lg"
|
radius="md"
|
||||||
p="lg"
|
p="lg"
|
||||||
|
w="100%"
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
transition: "all 0.15s ease",
|
||||||
height: "100%",
|
border: "1px solid var(--mantine-color-default-border)",
|
||||||
transition: "box-shadow 0.2s ease"
|
|
||||||
}}
|
}}
|
||||||
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
|
styles={{
|
||||||
onMouseEnter={(e) => {
|
root: {
|
||||||
e.currentTarget.style.boxShadow = "var(--mantine-shadow-md)";
|
"&:hover": {
|
||||||
}}
|
borderColor: "var(--mantine-primary-color-filled)",
|
||||||
onMouseLeave={(e) => {
|
boxShadow: "var(--mantine-shadow-sm)",
|
||||||
e.currentTarget.style.boxShadow = "none";
|
},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack gap="md" h="100%">
|
<Group justify="space-between" align="center">
|
||||||
<Group gap="md" align="flex-start">
|
<Group gap="md" align="center">
|
||||||
<Avatar
|
<Avatar
|
||||||
size={60}
|
size={120}
|
||||||
radius="md"
|
radius="sm"
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
@@ -65,15 +87,41 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TrophyIcon size={24} />
|
<TrophyIcon size={20} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
<Stack gap="xs">
|
||||||
<Text fw={700} size="lg" lineClamp={2} my='auto'>
|
<Text fw={600} size="lg" lineClamp={2}>
|
||||||
{tournament.name}
|
{tournament.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
{displayDate && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
color="gray"
|
||||||
|
>
|
||||||
|
<CalendarIcon size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{displayDate}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon size="sm" variant="light" radius="sm" color="gray">
|
||||||
|
<UsersIcon size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{enrolledTeamsCount} team
|
||||||
|
{enrolledTeamsCount !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Stack, Skeleton, Text, Group, Box, ThemeIcon } from "@mantine/core";
|
import { List, ListItem, Divider, Skeleton, Text, Group, Box, ThemeIcon, Stack } from "@mantine/core";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { TournamentInfo } from "../types";
|
import { TournamentInfo } from "../types";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import React from "react";
|
||||||
import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react";
|
import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface TournamentListProps {
|
interface TournamentListProps {
|
||||||
@@ -11,6 +11,50 @@ interface TournamentListProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TournamentListItemProps {
|
||||||
|
tournament: TournamentInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TournamentListItem = React.memo(({ tournament }: TournamentListItemProps) => {
|
||||||
|
const startDate = tournament.start_time ? new Date(tournament.start_time) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{tournament.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="md">
|
||||||
|
{tournament.location && (
|
||||||
|
<Group gap={4}>
|
||||||
|
<ThemeIcon size="xs" variant="light" radius="sm" color="gray">
|
||||||
|
<MapPinIcon size={10} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{tournament.location}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{startDate && !isNaN(startDate.getTime()) && (
|
||||||
|
<Group gap={4}>
|
||||||
|
<ThemeIcon size="xs" variant="light" radius="sm" color="gray">
|
||||||
|
<CalendarIcon size={10} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{startDate.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const TournamentList = ({ tournaments, loading = false }: TournamentListProps) => {
|
const TournamentList = ({ tournaments, loading = false }: TournamentListProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -19,20 +63,23 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<List>
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<Box key={`skeleton-${i}`} p="md">
|
<ListItem
|
||||||
|
key={`skeleton-${i}`}
|
||||||
|
py="xs"
|
||||||
|
icon={<Skeleton height={40} width={40} radius="sm" />}
|
||||||
|
>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Skeleton height={16} width={200} />
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
<Skeleton height={60} width={60} radius="md" />
|
<Skeleton height={12} width={80} />
|
||||||
<Stack gap="xs" style={{ flex: 1 }}>
|
<Skeleton height={12} width={100} />
|
||||||
<Skeleton height={20} width="60%" />
|
|
||||||
<Skeleton height={16} width="40%" />
|
|
||||||
<Skeleton height={16} width="30%" />
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,96 +97,40 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<List>
|
||||||
<AnimatePresence>
|
{tournaments.map((tournament) => (
|
||||||
{tournaments.map((tournament, index) => {
|
<>
|
||||||
const startDate = tournament.start_time ? new Date(tournament.start_time) : null;
|
<ListItem
|
||||||
|
key={tournament.id}
|
||||||
return (
|
p="xs"
|
||||||
<motion.div
|
icon={
|
||||||
key={`tournament-${tournament.id}-${index}`}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
transition={{ duration: 0.2, delay: index * 0.01 }}
|
|
||||||
whileHover={{ y: -2 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
p="md"
|
|
||||||
style={{
|
|
||||||
borderRadius: "var(--mantine-radius-md)",
|
|
||||||
border: "1px solid var(--mantine-color-gray-3)",
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
}}
|
|
||||||
onClick={() => handleClick(tournament.id)}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "var(--mantine-color-gray-0)";
|
|
||||||
e.currentTarget.style.borderColor = "var(--mantine-primary-color-filled)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group gap="md" wrap="nowrap">
|
|
||||||
<Avatar
|
<Avatar
|
||||||
size={60}
|
radius="sm"
|
||||||
radius="md"
|
size={40}
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
style={{
|
|
||||||
border: "2px solid var(--mantine-primary-color-light)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TrophyIcon size={24} />
|
<TrophyIcon size={16} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
}
|
||||||
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
|
style={{ cursor: "pointer" }}
|
||||||
<Text fw={600} size="lg" lineClamp={1}>
|
onClick={() => handleClick(tournament.id)}
|
||||||
{tournament.name}
|
styles={{
|
||||||
</Text>
|
itemWrapper: { width: "100%" },
|
||||||
|
itemLabel: { width: "100%" }
|
||||||
<Group gap="lg" wrap="wrap">
|
}}
|
||||||
{tournament.location && (
|
w="100%"
|
||||||
<Group gap="xs">
|
>
|
||||||
<ThemeIcon size="xs" variant="light" radius="sm">
|
<TournamentListItem tournament={tournament} />
|
||||||
<MapPinIcon size={12} />
|
</ListItem>
|
||||||
</ThemeIcon>
|
<Divider />
|
||||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
</>
|
||||||
{tournament.location}
|
))}
|
||||||
</Text>
|
</List>
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{startDate && !isNaN(startDate.getTime()) && (
|
|
||||||
<Group gap="xs">
|
|
||||||
<ThemeIcon size="xs" variant="light" radius="sm">
|
|
||||||
<CalendarIcon size={12} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{startDate.toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric'
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AnimatePresence>
|
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
|
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
|
||||||
import { Tournament } from "../../types";
|
import { Tournament } from "../../types";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +20,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap="lg">
|
<Stack align="center" gap={0}>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
src={
|
src={
|
||||||
@@ -29,13 +29,13 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
radius="md"
|
radius="md"
|
||||||
size={200}
|
size={300}
|
||||||
px="xs"
|
px="xs"
|
||||||
withBorder={false}
|
withBorder={false}
|
||||||
>
|
>
|
||||||
<TrophyIcon size={32} />
|
<TrophyIcon size={32} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Stack gap="xs">
|
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
|
||||||
{tournament.location && (
|
{tournament.location && (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
@@ -64,16 +64,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
</Flex>
|
||||||
<Group gap="xs">
|
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
|
||||||
<UsersIcon size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{teamCount} teams enrolled
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Grid,
|
|
||||||
Text,
|
Text,
|
||||||
Group,
|
Group,
|
||||||
Stack,
|
Stack,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Card,
|
|
||||||
Skeleton,
|
Skeleton,
|
||||||
|
Divider,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
CrownIcon,
|
CrownIcon,
|
||||||
@@ -27,69 +26,34 @@ interface StatsOverviewProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatCard = ({
|
const StatItem = ({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
suffix = "",
|
suffix = "",
|
||||||
Icon,
|
Icon,
|
||||||
variant = "default",
|
|
||||||
isLoading = false,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: number | null;
|
value: number | null;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
Icon?: Icon;
|
Icon?: Icon;
|
||||||
variant?: "default" | "compact";
|
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (variant === "compact") {
|
|
||||||
return (
|
return (
|
||||||
<Card p="xs" radius="md" withBorder>
|
<Group justify="space-between" align="center" py="md" px="sm">
|
||||||
<Group gap={2} justify="space-between" align="flex-start">
|
<Group gap="sm" align="center">
|
||||||
<Stack gap={2} flex={1}>
|
|
||||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.3px">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
{isLoading ? (
|
|
||||||
<Skeleton height={20} width="60%" />
|
|
||||||
) : (
|
|
||||||
<Text size="md" fw={700} lh={1}>
|
|
||||||
{value !== null ? `${value}${suffix}` : "—"}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<ThemeIcon size="sm" variant="light" radius="md">
|
<ThemeIcon size="md" variant="light" radius="sm" color="gray">
|
||||||
<Icon size={12} />
|
<Icon size={16} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
)}
|
)}
|
||||||
</Group>
|
<Text size="sm" fw={500}>
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card p="sm" radius="md" withBorder>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.3px">
|
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
{Icon && (
|
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
|
||||||
<Icon size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
{isLoading ? (
|
<Text size="sm" fw={700} c="dimmed">
|
||||||
<Skeleton height={28} width="70%" />
|
|
||||||
) : (
|
|
||||||
<Text size="xl" fw={700} lh={1}>
|
|
||||||
{value !== null ? `${value}${suffix}` : "—"}
|
{value !== null ? `${value}${suffix}` : "—"}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
</Group>
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,157 +84,78 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
|
|||||||
|
|
||||||
const avgCupsPerMatch =
|
const avgCupsPerMatch =
|
||||||
overallStats.matches > 0
|
overallStats.matches > 0
|
||||||
? overallStats.total_cups_made / overallStats.matches
|
? parseFloat((overallStats.total_cups_made / overallStats.matches).toFixed(1))
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const avgCupsAgainstPerMatch =
|
const avgCupsAgainstPerMatch =
|
||||||
overallStats.matches > 0
|
overallStats.matches > 0
|
||||||
? overallStats.total_cups_against / overallStats.matches
|
? parseFloat((overallStats.total_cups_against / overallStats.matches).toFixed(1))
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const avgMarginOfVictory = statsData.margin_of_victory || 0;
|
const avgMarginOfVictory = statsData.margin_of_victory ? parseFloat(statsData.margin_of_victory.toFixed(1)) : 0;
|
||||||
const avgMarginOfLoss = statsData.margin_of_loss || 0;
|
const avgMarginOfLoss = statsData.margin_of_loss ? parseFloat(statsData.margin_of_loss.toFixed(1)) : 0;
|
||||||
|
|
||||||
|
const allStats = [
|
||||||
|
{ label: "Matches Played", value: overallStats.matches, Icon: BoxingGloveIcon },
|
||||||
|
{ label: "Wins", value: overallStats.wins, Icon: CrownIcon },
|
||||||
|
{ label: "Losses", value: overallStats.losses, Icon: XIcon },
|
||||||
|
{ label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon },
|
||||||
|
{ label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon },
|
||||||
|
{ label: "Avg Cups Per Game", value: avgCupsPerMatch > 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
|
||||||
|
{ label: "Avg Cups Against", value: avgCupsAgainstPerMatch > 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
|
||||||
|
{ label: "Avg Win Margin", value: avgMarginOfVictory > 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
|
||||||
|
{ label: "Avg Loss Margin", value: avgMarginOfLoss > 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box p="sm" h="auto" mih={400}>
|
<Box>
|
||||||
<Stack gap="lg">
|
<Stack gap={0}>
|
||||||
<Stack gap="sm">
|
{allStats.map((stat, index) => (
|
||||||
<Text size="md" fw={600} c="dark">
|
<Box key={stat.label}>
|
||||||
Match Statistics
|
<StatItem
|
||||||
</Text>
|
label={stat.label}
|
||||||
<Grid gutter="xs">
|
value={stat.value}
|
||||||
<Grid.Col span={4}>
|
Icon={stat.Icon}
|
||||||
<StatCard
|
isLoading={isLoading}
|
||||||
label="Matches"
|
|
||||||
value={overallStats.matches}
|
|
||||||
Icon={BoxingGloveIcon}
|
|
||||||
variant="compact"
|
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
{index < allStats.length - 1 && <Divider />}
|
||||||
<Grid.Col span={4}>
|
</Box>
|
||||||
<StatCard
|
))}
|
||||||
label="Wins"
|
|
||||||
value={overallStats.wins}
|
|
||||||
Icon={CrownIcon}
|
|
||||||
variant="compact"
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={4}>
|
|
||||||
<StatCard
|
|
||||||
label="Losses"
|
|
||||||
value={overallStats.losses}
|
|
||||||
Icon={XIcon}
|
|
||||||
variant="compact"
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="md" fw={600} c="dark">
|
|
||||||
Metrics
|
|
||||||
</Text>
|
|
||||||
<Grid gutter="xs">
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard
|
|
||||||
label="Cups Made"
|
|
||||||
value={overallStats.total_cups_made}
|
|
||||||
Icon={FireIcon}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard
|
|
||||||
label="Cups Against"
|
|
||||||
value={overallStats.total_cups_against}
|
|
||||||
Icon={ShieldIcon}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard
|
|
||||||
label="Avg Per Game"
|
|
||||||
value={parseFloat(avgCupsPerMatch.toFixed(1))}
|
|
||||||
Icon={ChartLineUpIcon}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard
|
|
||||||
label="Avg Against"
|
|
||||||
value={parseFloat(avgCupsAgainstPerMatch.toFixed(1))}
|
|
||||||
Icon={ShieldCheckIcon}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard
|
|
||||||
label="Win Margin"
|
|
||||||
value={
|
|
||||||
avgMarginOfVictory > 0
|
|
||||||
? parseFloat(avgMarginOfVictory.toFixed(1))
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
Icon={ArrowUpIcon}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard
|
|
||||||
label="Loss Margin"
|
|
||||||
value={
|
|
||||||
avgMarginOfLoss > 0
|
|
||||||
? parseFloat(avgMarginOfLoss.toFixed(1))
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
Icon={ArrowDownIcon}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatsSkeleton = () => (
|
const StatsSkeleton = () => {
|
||||||
<Box p="sm" h="auto" mih={400}>
|
const skeletonStats = [
|
||||||
<Stack gap="lg">
|
{ label: "Matches Played", Icon: BoxingGloveIcon },
|
||||||
<Stack gap="sm">
|
{ label: "Wins", Icon: CrownIcon },
|
||||||
<Skeleton height={20} width="40%" />
|
{ label: "Losses", Icon: XIcon },
|
||||||
<Grid gutter="xs">
|
{ label: "Cups Made", Icon: FireIcon },
|
||||||
<Grid.Col span={4}>
|
{ label: "Cups Against", Icon: ShieldIcon },
|
||||||
<StatCard label="Matches" value={null} Icon={BoxingGloveIcon} variant="compact" isLoading />
|
{ label: "Avg Cups Per Game", Icon: ChartLineUpIcon },
|
||||||
</Grid.Col>
|
{ label: "Avg Cups Against", Icon: ShieldCheckIcon },
|
||||||
<Grid.Col span={4}>
|
{ label: "Avg Win Margin", Icon: ArrowUpIcon },
|
||||||
<StatCard label="Wins" value={null} Icon={CrownIcon} variant="compact" isLoading />
|
{ label: "Avg Loss Margin", Icon: ArrowDownIcon },
|
||||||
</Grid.Col>
|
];
|
||||||
<Grid.Col span={4}>
|
|
||||||
<StatCard label="Losses" value={null} Icon={XIcon} variant="compact" isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="sm">
|
return (
|
||||||
<Skeleton height={20} width="30%" />
|
<Box>
|
||||||
<Grid gutter="xs">
|
<Stack gap={0}>
|
||||||
<Grid.Col span={6}>
|
{skeletonStats.map((stat, index) => (
|
||||||
<StatCard label="Cups Made" value={null} Icon={FireIcon} isLoading />
|
<Box key={stat.label}>
|
||||||
</Grid.Col>
|
<StatItem
|
||||||
<Grid.Col span={6}>
|
label={stat.label}
|
||||||
<StatCard label="Cups Against" value={null} Icon={ShieldIcon} isLoading />
|
value={null}
|
||||||
</Grid.Col>
|
Icon={stat.Icon}
|
||||||
<Grid.Col span={6}>
|
isLoading={true}
|
||||||
<StatCard label="Avg Per Game" value={null} Icon={ChartLineUpIcon} isLoading />
|
/>
|
||||||
</Grid.Col>
|
{index < skeletonStats.length - 1 && <Divider />}
|
||||||
<Grid.Col span={6}>
|
</Box>
|
||||||
<StatCard label="Avg Against" value={null} Icon={ShieldCheckIcon} isLoading />
|
))}
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard label="Win Margin" value={null} Icon={ArrowUpIcon} isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<StatCard label="Loss Margin" value={null} Icon={ArrowDownIcon} isLoading />
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default StatsOverview;
|
export default StatsOverview;
|
||||||
|
|||||||
Reference in New Issue
Block a user