From c79d06f27273736dab49091314c54305b4206a33 Mon Sep 17 00:00:00 2001 From: Taken Date: Sat, 16 Aug 2025 23:39:11 +0200 Subject: [PATCH] Finished sidebar --- .github/copilot-instructions.md | 15 ++ .npmrc | 1 + package.json | 7 + pnpm-lock.yaml | 134 ++++++++++++++ pnpm-workspace.yaml | 3 + src/app/(main)/layout.tsx | 22 +++ src/app/(main)/page.tsx | 12 ++ src/app/(stats)/layout.tsx | 24 +++ .../player/[ign]/_components/Sidebar.tsx | 167 ++++++++++++++++++ .../player/[ign]/_components/SocialIcons.tsx | 74 ++++++++ src/app/(stats)/player/[ign]/page.tsx | 63 +++++++ src/app/globals.css | 21 +++ src/app/layout.tsx | 6 +- src/app/page.tsx | 7 - src/components/player/displayname.tsx | 105 +++++++++++ src/components/search-bar.tsx | 57 ++++++ src/components/ui/button.tsx | 59 +++++++ src/components/ui/card.tsx | 92 ++++++++++ src/components/ui/input.tsx | 21 +++ src/components/ui/separator.tsx | 28 +++ src/components/ui/sonner.tsx | 25 +++ src/data/colors.ts | 36 ++++ src/data/hypixel/general.ts | 16 ++ src/lib/env/server.ts | 10 ++ src/lib/formatters.ts | 20 +++ src/lib/hypixel/api/guild.ts | 20 +++ src/lib/hypixel/api/mojang.ts | 22 +++ src/lib/hypixel/api/player.ts | 20 +++ src/lib/hypixel/formatters.ts | 9 + src/lib/hypixel/guild.ts | 31 ++++ src/lib/hypixel/level.ts | 37 ++++ src/lib/hypixel/stats.ts | 41 +++++ src/lib/hypixel/validatePlayer.ts | 29 +++ src/lib/schema/guild.ts | 26 +++ src/lib/schema/player.ts | 56 ++++++ 35 files changed, 1307 insertions(+), 9 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .npmrc create mode 100644 src/app/(main)/layout.tsx create mode 100644 src/app/(main)/page.tsx create mode 100644 src/app/(stats)/layout.tsx create mode 100644 src/app/(stats)/player/[ign]/_components/Sidebar.tsx create mode 100644 src/app/(stats)/player/[ign]/_components/SocialIcons.tsx create mode 100644 src/app/(stats)/player/[ign]/page.tsx delete mode 100644 src/app/page.tsx create mode 100644 src/components/player/displayname.tsx create mode 100644 src/components/search-bar.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/data/colors.ts create mode 100644 src/data/hypixel/general.ts create mode 100644 src/lib/env/server.ts create mode 100644 src/lib/formatters.ts create mode 100644 src/lib/hypixel/api/guild.ts create mode 100644 src/lib/hypixel/api/mojang.ts create mode 100644 src/lib/hypixel/api/player.ts create mode 100644 src/lib/hypixel/formatters.ts create mode 100644 src/lib/hypixel/guild.ts create mode 100644 src/lib/hypixel/level.ts create mode 100644 src/lib/hypixel/stats.ts create mode 100644 src/lib/hypixel/validatePlayer.ts create mode 100644 src/lib/schema/guild.ts create mode 100644 src/lib/schema/player.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..89a1392 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,15 @@ +# Project Guidelines +This project uses tailwind and shadcn make sure to use any component from shadcn and not create your own components unless necessary. + +# Project reqs +- Make sure to use pnpm and pnpx any time you use npm commands. +- `pnpm dev` to start the development server. +- `pnpm build` to build the project. +- `pnpm lint` to run the linter. +- `pnpm fmt` to format the code. + +# Annoying things +- Do not create random index files to export functions just export them from a filre directly. + +# Copilot Instructions +- Make sure to reach out to context7 for the latest up to date documentation. \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..1778f10 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +public-hoist-pattern[]=*@nextui-org/* diff --git a/package.json b/package.json index 5e504d6..049ef6f 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,24 @@ "fmt": "dprint fmt src/**/*.ts src/**/*.tsx" }, "dependencies": { + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", "@t3-oss/env-nextjs": "^0.13.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.528.0", "next": "15.4.4", + "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", + "react-icons": "^5.5.0", + "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", "zod": "^4.0.10" }, "devDependencies": { "@eslint/eslintrc": "^3", + "@next/eslint-plugin-next": "^15.4.6", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", @@ -29,6 +35,7 @@ "dprint": "^0.50.1", "eslint": "^9", "eslint-config-next": "15.4.4", + "eslint-plugin-react-hooks": "^5.2.0", "tailwindcss": "^4", "tw-animate-css": "^1.3.6", "typescript": "^5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5770957..84bcf97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@radix-ui/react-separator': + specifier: ^1.1.7 + version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': + specifier: ^1.2.3 + version: 1.2.3(@types/react@19.1.8)(react@19.1.0) '@t3-oss/env-nextjs': specifier: ^0.13.8 version: 0.13.8(typescript@5.8.3)(zod@4.0.10) @@ -23,12 +29,21 @@ importers: next: specifier: 15.4.4 version: 15.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: 19.1.0 version: 19.1.0 react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.1.0) + sonner: + specifier: ^2.0.6 + version: 2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -39,6 +54,9 @@ importers: '@eslint/eslintrc': specifier: ^3 version: 3.3.1 + '@next/eslint-plugin-next': + specifier: ^15.4.6 + version: 15.4.6 '@tailwindcss/postcss': specifier: ^4 version: 4.1.11 @@ -60,6 +78,9 @@ importers: eslint-config-next: specifier: 15.4.4 version: 15.4.4(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.32.0(jiti@2.5.1)) tailwindcss: specifier: ^4 version: 4.1.11 @@ -340,6 +361,9 @@ packages: '@next/eslint-plugin-next@15.4.4': resolution: {integrity: sha512-1FDsyN//ai3Jd97SEd7scw5h1yLdzDACGOPRofr2GD3sEFsBylEEoL0MHSerd4n2dq9Zm/mFMqi4+NRMOreOKA==} + '@next/eslint-plugin-next@15.4.6': + resolution: {integrity: sha512-2NOu3ln+BTcpnbIDuxx6MNq+pRrCyey4WSXGaJIyt0D2TYicHeO9QrUENNjcf673n3B1s7hsiV5xBYRCK1Q8kA==} + '@next/swc-darwin-arm64@15.4.4': resolution: {integrity: sha512-eVG55dnGwfUuG+TtnUCt+mEJ+8TGgul6nHEvdb8HEH7dmJIFYOCApAaFrIrxwtEq2Cdf+0m5sG1Np8cNpw9EAw==} engines: {node: '>= 10'} @@ -404,6 +428,50 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1520,6 +1588,12 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@15.4.4: resolution: {integrity: sha512-kNcubvJjOL9yUOfwtZF3HfDhuhp+kVD+FM2A6Tyua1eI/xfmY4r/8ZS913MMz+oWKDlbps/dQOWdDricuIkXLw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -1646,6 +1720,11 @@ packages: peerDependencies: react: ^19.1.0 + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -1751,6 +1830,12 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sonner@2.0.6: + resolution: {integrity: sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2151,6 +2236,10 @@ snapshots: dependencies: fast-glob: 3.3.1 + '@next/eslint-plugin-next@15.4.6': + dependencies: + fast-glob: 3.3.1 + '@next/swc-darwin-arm64@15.4.4': optional: true @@ -2189,6 +2278,37 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.8)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + + '@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.12.0': {} @@ -3428,6 +3548,11 @@ snapshots: natural-compare@1.4.0: {} + next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + next@15.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.4.4 @@ -3563,6 +3688,10 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-icons@5.5.0(react@19.1.0): + dependencies: + react: 19.1.0 + react-is@16.13.1: {} react@19.1.0: {} @@ -3725,6 +3854,11 @@ snapshots: is-arrayish: 0.3.2 optional: true + sonner@2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + source-map-js@1.2.1: {} stable-hash@0.0.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f7fff18..cf7b690 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,5 @@ onlyBuiltDependencies: + - '@tailwindcss/oxide' - dprint + - sharp + - unrs-resolver diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx new file mode 100644 index 0000000..d0878bb --- /dev/null +++ b/src/app/(main)/layout.tsx @@ -0,0 +1,22 @@ +import { Button } from "@/components/ui/button" +import { Settings } from "lucide-react" +import Link from "next/link" +import { ReactNode } from "react" + +export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { + return ( + <> + +
+ {children} +
+ + ) +} diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx new file mode 100644 index 0000000..ac0e99e --- /dev/null +++ b/src/app/(main)/page.tsx @@ -0,0 +1,12 @@ +import { SearchBar } from "@/components/search-bar" + +export default function Home() { + return ( +
+
+

Stats Hypixel

+
+ +
+ ) +} diff --git a/src/app/(stats)/layout.tsx b/src/app/(stats)/layout.tsx new file mode 100644 index 0000000..f6d9311 --- /dev/null +++ b/src/app/(stats)/layout.tsx @@ -0,0 +1,24 @@ +import { SearchBar } from "@/components/search-bar" +import { Button } from "@/components/ui/button" +import { Settings } from "lucide-react" +import Link from "next/link" +import { ReactNode } from "react" + +export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { + return ( + <> + +
+ {children} +
+ + ) +} diff --git a/src/app/(stats)/player/[ign]/_components/Sidebar.tsx b/src/app/(stats)/player/[ign]/_components/Sidebar.tsx new file mode 100644 index 0000000..3cbeb66 --- /dev/null +++ b/src/app/(stats)/player/[ign]/_components/Sidebar.tsx @@ -0,0 +1,167 @@ +import { Card, CardContent } from "@/components/ui/card" +import { getColor } from "@/data/colors" +import { formatDate, formatNumber } from "@/lib/formatters" +import { getGuildMember, getGuildRankTag, getMemberGEXP, getMemberWeeklyGEXP } from "@/lib/hypixel/guild" +import { getCoinMultiplier, getTotalChallenges, getTotalCoins, getTotalQuests, rewardClaimed } from "@/lib/hypixel/stats" +import { Guild } from "@/lib/schema/guild" +import { Player } from "@/lib/schema/player" +import { Separator } from "@radix-ui/react-separator" +import Link from "next/link" +import SocialIcons from "./SocialIcons" + +type SidebarProps = { + level: number + ign: string + player: Player["player"] + guild: Guild["guild"] | undefined +} + +export default function Sidebar({ level, ign, player, guild }: SidebarProps) { + return ( + + +
+
+

Hypixel level

+

{level.toFixed(2)}

+
+
+

Karma

+

{formatNumber(player.karma)}

+
+
+ +
+

+ {"Coin multiplier: "} + {`x${getCoinMultiplier(level)} (Level ${level.toFixed(1).split(".")[0]})`} +

+

+ {"Total coins: "} + {formatNumber(getTotalCoins(player.stats))} +

+
+ +
+

+ + + Achievement Points + + + {": "} + {formatNumber(player.achievementPoints ?? 0)} +

+

+ + + Quests Completed + + + {": "} + {formatNumber(getTotalQuests(player.quests))} +

+

+ {"Challenges Completed: "} + {formatNumber(getTotalChallenges(player.challenges.all_time))} +

+
+ +
+

+ {"Today's Reward: "} + {rewardClaimed(player.lastClaimedReward) ? "Claimed" : "Unclaimed"} +

+

+ {"Rewards Claimed: "} + {player.totalRewards} +

+

+ {"Reward Streak: "} + {player.rewardStreak} +

+

+ {"Top Reward Streak: "} + {player.rewardHighScore} +

+
+ +
+

+ {"Gifts Given: "} + {player.giftingMeta?.giftsGiven ?? 0} +

+

+ {"Ranks Given: "} + {player.giftingMeta?.ranksGiven ?? 0} +

+
+ +
+

+ {"First Login: "} + {formatDate(player.firstLogin ?? 0)} +

+

+ {"Last Login: "} + {formatDate(player.lastLogin ?? 0)} +

+
+ + {guild && ( + <> +
+
+ +

Guild

+ +

+ {"Name: "} + {guild.name} +

+

+ {"Members: "} + {guild.members.length} +

+
+
+

+ {"Rank: "} + {`${getGuildMember(guild, player.uuid)?.rank} `} + + {getGuildRankTag(guild, player.uuid)} + +

+

+ {"Daily GEXP: "} + {formatNumber(getMemberGEXP(guild, player.uuid, 0) ?? 0)} +

+

+ {"Weekly GEXP: "} + {formatNumber(getMemberWeeklyGEXP(guild, player.uuid) ?? 0)} +

+

+ {"Joined: "} + {formatDate(getGuildMember(guild, player.uuid)?.joined ?? 0)} +

+
+
+ + + )} +
+

Social Links

+
+ +
+
+
+
+ ) +} diff --git a/src/app/(stats)/player/[ign]/_components/SocialIcons.tsx b/src/app/(stats)/player/[ign]/_components/SocialIcons.tsx new file mode 100644 index 0000000..b959809 --- /dev/null +++ b/src/app/(stats)/player/[ign]/_components/SocialIcons.tsx @@ -0,0 +1,74 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { CopyIcon } from "lucide-react" +import Link from "next/link" +import { CgWebsite } from "react-icons/cg" +import { FaDiscord, FaTwitch, FaYoutube } from "react-icons/fa" +import { FiTwitter } from "react-icons/fi" +import { toast } from "sonner" + +export default function SocialIcons( + { discord, twitch, youtube, twitter, hypixel }: { discord?: string, twitch?: string, youtube?: string, twitter?: string, hypixel?: string } +) { + return ( + <> + + + + + + + + + + + + + + + ) +} + +function DiscordIcon({ username }: { username?: string }) { + if (!username) return null + + function handleClick() { + toast( +
+

{username}

+ +
, + { + position: "bottom-center", + id: "discord-username", + className: "flex justify-center items-center gap-4" + } + ) + } + + return ( + + ) +} + +function SocialIcon({ href, children }: { href?: string, children: React.ReactNode }) { + if (!href) return null + + return ( + + ) +} diff --git a/src/app/(stats)/player/[ign]/page.tsx b/src/app/(stats)/player/[ign]/page.tsx new file mode 100644 index 0000000..34dee3c --- /dev/null +++ b/src/app/(stats)/player/[ign]/page.tsx @@ -0,0 +1,63 @@ +import DisplayName from "@/components/player/displayname" +import { getGuild } from "@/lib/hypixel/api/guild" +import { getUuid } from "@/lib/hypixel/api/mojang" +import { getPlayer } from "@/lib/hypixel/api/player" +import { getExactLevel } from "@/lib/hypixel/level" +import Sidebar from "./_components/Sidebar" + +export default async function PlayerPage({ + params +}: { + params: Promise<{ ign: string }> +}) { + const { ign: pign } = await params + + const uuid = await getUuid(pign) + if (!uuid) { + return ( +
+

Player not found

+
+ ) + } + + const player = await getPlayer(uuid) + + if (!player) { + return ( +
+

Player not found

+
+ ) + } + + const guild = await getGuild(uuid) + + const level = getExactLevel(player.networkExp) + + return ( +
+

+ +

+

+ {player.uuid} +

+
+ +
+

Game Statistics

+

Game stats will be displayed here...

+
+
+
+ ) +} diff --git a/src/app/globals.css b/src/app/globals.css index 7550e24..95c7ed9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,6 +3,26 @@ @custom-variant dark (&:is(.dark *)); +@theme inline { + --color-mc-black: #000000; + --color-mc-dark-blue: #0000AA; + --color-mc-dark-green: #00AA00; + --color-mc-dark-aqua: #00AAAA; + --color-mc-dark-red: #AA0000; + --color-mc-dark-purple: #AA00AA; + --color-mc-gold: #FFAA00; + --color-mc-gray: #AAAAAA; + --color-mc-light-gray: #C6C6C6; + --color-mc-dark-gray: #555555; + --color-mc-blue: #5555FF; + --color-mc-green: #55FF55; + --color-mc-aqua: #55FFFF; + --color-mc-red: #FF5555; + --color-mc-light-purple: #FF55FF; + --color-mc-yellow: #FFFF55; + --color-mc-white: #FFFFFF; +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -114,6 +134,7 @@ * { @apply border-border outline-ring/50; } + body { @apply bg-background text-foreground; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 09ad7ee..bcc9b2c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,11 +1,13 @@ import { ReactNode } from "react" import "./globals.css" +import { Toaster } from "@/components/ui/sonner" export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { return ( - - + + {children} + ) diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index 68206d2..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function Home() { - return ( -
- Hello -
- ) -} diff --git a/src/components/player/displayname.tsx b/src/components/player/displayname.tsx new file mode 100644 index 0000000..7a30595 --- /dev/null +++ b/src/components/player/displayname.tsx @@ -0,0 +1,105 @@ +import { getColor } from "@/data/colors" +import { Player } from "@/lib/schema/player" + +type NewPackageRank = Player["player"]["newPackageRank"] +type MonthlyPackageRank = Player["player"]["monthlyPackageRank"] +type RankColor = Player["player"]["monthlyRankColor"] + +export default function DisplayName( + { ign, rank, monthly, rankColor, plusColor, guildTag, tagColor }: { + ign: string + rank: NewPackageRank + monthly: MonthlyPackageRank + rankColor: RankColor + plusColor: string | undefined + guildTag: string | undefined + tagColor: string | undefined + } +) { + return ( + <> + {" "} + + + ) +} + +function PlayerIGN({ ign, rank, monthly, rankColor }: { ign: string, rank: NewPackageRank, monthly: MonthlyPackageRank, rankColor: RankColor }) { + if (monthly === "SUPERSTAR") { + if (rankColor === "GOLD") { + return {ign} + } else { + return {ign} + } + } + + switch (rank) { + case "VIP": + return {ign} + case "VIP_PLUS": + return {ign} + case "MVP": + return {ign} + case "MVP_PLUS": + return {ign} + default: + return {ign} + } +} + +function PlayerRank( + { rank, monthly, plusColor, rankColor }: { rank: NewPackageRank, monthly: MonthlyPackageRank, plusColor?: string, rankColor: RankColor } +) { + if (monthly === "SUPERSTAR") { + if (rankColor === "GOLD") { + return ( + <> + [MVP + ++ + ] + + ) + } else { + return ( + <> + [MVP + ++ + ] + + ) + } + } + + switch (rank) { + case "VIP": + return [VIP] + case "VIP_PLUS": + return ( + <> + [VIP + + + ] + + ) + case "MVP": + return [MVP] + case "MVP_PLUS": + return ( + <> + [MVP + + + ] + + ) + default: + return null + } +} + +function GuildTag({ tag, tagColor }: { tag?: string, tagColor?: string }) { + if (!tag) return null + + const color = getColor(tagColor, "text", "gray") + + return [{tag}] +} diff --git a/src/components/search-bar.tsx b/src/components/search-bar.tsx new file mode 100644 index 0000000..e2290c0 --- /dev/null +++ b/src/components/search-bar.tsx @@ -0,0 +1,57 @@ +"use client" + +import { Input } from "@/components/ui/input" +import { validatePlayer } from "@/lib/hypixel/validatePlayer" +import { cn } from "@/lib/utils" +import { Search } from "lucide-react" +import { useRouter } from "next/navigation" +import { useState } from "react" +import { toast } from "sonner" + +export function SearchBar({ navbar }: { navbar?: boolean }) { + const [input, setInput] = useState("") + const router = useRouter() + + async function handleSearch(e: React.FormEvent) { + e.preventDefault() + + const validatedPlayer = await validatePlayer(input.trim()) + + if (validatedPlayer.error === true) { + toast.error(validatedPlayer.message) + setInput("") + return + } + + if (input.trim()) { + router.push(`/player/${encodeURIComponent(input.trim())}`) + } + if (navbar) { + setInput("") + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSearch(e) + } + } + + return ( +
+
+
+ + setInput(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+
+
+ ) +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..a2df8dc --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..d05bbc6 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..03295ca --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..275381c --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..957524e --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/src/data/colors.ts b/src/data/colors.ts new file mode 100644 index 0000000..37f27b7 --- /dev/null +++ b/src/data/colors.ts @@ -0,0 +1,36 @@ +type ReturnType = `text-mc-${string}` | `bg-mc-${string}` + +export function getColor(color?: string, type: "text" | "bg" = "text", defaultColor: "red" | "gray" = "red"): ReturnType { + switch (color) { + case "RED": + return type === "text" ? "text-mc-red" : "bg-mc-red" + case "GOLD": + return type === "text" ? "text-mc-gold" : "bg-mc-gold" + case "LIME": + return type === "text" ? "text-mc-green" : "bg-mc-green" + case "YELLOW": + return type === "text" ? "text-mc-yellow" : "bg-mc-yellow" + case "LIGHT_PURPLE": + return type === "text" ? "text-mc-light-purple" : "bg-mc-light-purple" + case "WHITE": + return type === "text" ? "text-mc-white" : "bg-mc-white" + case "BLUE": + return type === "text" ? "text-mc-blue" : "bg-mc-blue" + case "GREEN": + return type === "text" ? "text-mc-green" : "bg-mc-green" + case "DARK_RED": + return type === "text" ? "text-mc-dark-red" : "bg-mc-dark-red" + case "DARK_GREEN": + return type === "text" ? "text-mc-dark-green" : "bg-mc-dark-green" + case "DARK_PURPLE": + return type === "text" ? "text-mc-dark-purple" : "bg-mc-dark-purple" + case "DARK_GRAY": + return type === "text" ? "text-mc-dark-gray" : "bg-mc-dark-gray" + case "BLACK": + return type === "text" ? "text-mc-black" : "bg-mc-black" + case "DARK_BLUE": + return type === "text" ? "text-mc-dark-blue" : "bg-mc-dark-blue" + default: + return type === "text" ? `text-mc-${defaultColor}` : `bg-mc-${defaultColor}` + } +} \ No newline at end of file diff --git a/src/data/hypixel/general.ts b/src/data/hypixel/general.ts new file mode 100644 index 0000000..f813215 --- /dev/null +++ b/src/data/hypixel/general.ts @@ -0,0 +1,16 @@ +export const MULTIPLIER = [ + { level: 0, value: 1 }, + { level: 5, value: 1.5 }, + { level: 10, value: 2 }, + { level: 15, value: 2.5 }, + { level: 20, value: 3 }, + { level: 25, value: 3.5 }, + { level: 30, value: 4 }, + { level: 40, value: 4.5 }, + { level: 50, value: 5 }, + { level: 100, value: 5.5 }, + { level: 125, value: 6 }, + { level: 150, value: 6.5 }, + { level: 200, value: 7 }, + { level: 250, value: 8 }, +] \ No newline at end of file diff --git a/src/lib/env/server.ts b/src/lib/env/server.ts new file mode 100644 index 0000000..029f28e --- /dev/null +++ b/src/lib/env/server.ts @@ -0,0 +1,10 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import z from "zod"; + +export const env = createEnv({ + server: { + HYPIXEL_API_KEY: z.string().min(1), + }, + experimental__runtimeEnv: true, + emptyStringAsUndefined: true, +}) \ No newline at end of file diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts new file mode 100644 index 0000000..a29be37 --- /dev/null +++ b/src/lib/formatters.ts @@ -0,0 +1,20 @@ +const numberFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, + minimumFractionDigits: 0, +}); + +const dateFormatter = new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, +}); + +export function formatNumber(num: number): string { + return numberFormatter.format(num); +} +export function formatDate(timestamp: number): string { + return dateFormatter.format(new Date(timestamp)); +} \ No newline at end of file diff --git a/src/lib/hypixel/api/guild.ts b/src/lib/hypixel/api/guild.ts new file mode 100644 index 0000000..aa92caa --- /dev/null +++ b/src/lib/hypixel/api/guild.ts @@ -0,0 +1,20 @@ +import { env } from "../../env/server" +import { guildSchema } from "../../schema/guild" + +const guildApi = "https://api.hypixel.net/v2/guild" + +export async function getGuild(id: string, type: "id" | "player" | "name" = "player") { + const res = await fetch(`${guildApi}?${type}=${id}`, { + headers: { + "API-Key": env.HYPIXEL_API_KEY + } + }) + + if (!res.ok) return null + + const { success, data } = guildSchema.safeParse(await res.json()) + + if (!success) return null + + return data.guild +} \ No newline at end of file diff --git a/src/lib/hypixel/api/mojang.ts b/src/lib/hypixel/api/mojang.ts new file mode 100644 index 0000000..b44b265 --- /dev/null +++ b/src/lib/hypixel/api/mojang.ts @@ -0,0 +1,22 @@ +import z from "zod" + +const mojangApi = "https://api.mojang.com/users/profiles/minecraft" + +const schema = z.object({ + name: z.string().min(1), + id: z.string().min(1) +}) + +export async function getUuid(ign: string) { + const res = await fetch(`${mojangApi}/${ign}`) + + if (!res.ok) return null + + const data = await res.json() + + const parsed = schema.safeParse(data) + + if (!parsed.success) return null + + return parsed.data.id +} \ No newline at end of file diff --git a/src/lib/hypixel/api/player.ts b/src/lib/hypixel/api/player.ts new file mode 100644 index 0000000..436b706 --- /dev/null +++ b/src/lib/hypixel/api/player.ts @@ -0,0 +1,20 @@ +import { env } from "../../env/server" +import { playerSchema } from "../../schema/player" + +const playerApi = "https://api.hypixel.net/v2/player" + +export async function getPlayer(uuid: string) { + const res = await fetch(`${playerApi}?uuid=${uuid}`, { + headers: { + "API-Key": env.HYPIXEL_API_KEY + } + }) + + if (!res.ok) return null + + const { success, data } = playerSchema.safeParse(await res.json()) + + if (!success) return null + + return data.player +} \ No newline at end of file diff --git a/src/lib/hypixel/formatters.ts b/src/lib/hypixel/formatters.ts new file mode 100644 index 0000000..b80cc5b --- /dev/null +++ b/src/lib/hypixel/formatters.ts @@ -0,0 +1,9 @@ +export function floorLevel(level: number, base: number) { + const extra = level % base + + if (extra === 0) { + return level; + } + + return level - extra; +} \ No newline at end of file diff --git a/src/lib/hypixel/guild.ts b/src/lib/hypixel/guild.ts new file mode 100644 index 0000000..8a64fb6 --- /dev/null +++ b/src/lib/hypixel/guild.ts @@ -0,0 +1,31 @@ +import { object } from "zod" +import { Guild } from "../schema/guild" + +export function getGuildMember(guild: Guild["guild"], uuid: string) { + return guild.members.find(m => m.uuid === uuid) +} + +export function getGuildRankTag(guild: Guild["guild"], uuid: string) { + const member = getGuildMember(guild, uuid) + return member?.rank === "Guild Master" + ? "[GM]" + : `[${guild.ranks.find(r => r.name === member?.rank)?.tag}]` +} + +export function getMemberGEXP(guild: Guild["guild"], uuid: string, days: number = 0) { + const member = getGuildMember(guild, uuid) + + if (!member) return null + + const index = Object.keys(member.expHistory)[days] + + return member.expHistory[index] +} + +export function getMemberWeeklyGEXP(guild: Guild["guild"], uuid: string) { + const member = getGuildMember(guild, uuid) + + if (!member) return null + + return Object.values(member.expHistory).reduce((a, b) => a + b) +} diff --git a/src/lib/hypixel/level.ts b/src/lib/hypixel/level.ts new file mode 100644 index 0000000..13e57f7 --- /dev/null +++ b/src/lib/hypixel/level.ts @@ -0,0 +1,37 @@ +const BASE = 10000; +const GROWTH = 2500; + +const HALF_GROWTH = 0.5 * GROWTH; + +const REVERSE_PQ_PREFIX = -(BASE - 0.5 * GROWTH) / GROWTH; +const REVERSE_CONST = REVERSE_PQ_PREFIX * REVERSE_PQ_PREFIX; +const GROWTH_DIVIDES_2 = 2 / GROWTH; + +export function getLevel(exp: number) { + return exp <= 1 ? 1 : Math.floor(1 + REVERSE_PQ_PREFIX + Math.sqrt(REVERSE_CONST + GROWTH_DIVIDES_2 * exp)); +} + +export function getExactLevel(exp: number) { + return getLevel(exp) + getPercentageToNextLevel(exp); +} + +// function getExpFromLevelToNext(level: number) { +// return level < 1 ? BASE : GROWTH * (level - 1) + BASE; +// } + +function getTotalExpToLevel(level: number) { + const lv = Math.floor(level); const + x0 = getTotalExpToFullLevel(lv); + if (level === lv) return x0; + return (getTotalExpToFullLevel(lv + 1) - x0) * (level % 1) + x0; +} + +function getTotalExpToFullLevel(level: number) { + return (HALF_GROWTH * (level - 2) + BASE) * (level - 1); +} + +function getPercentageToNextLevel(exp: number) { + const lv = getLevel(exp); + const x0 = getTotalExpToLevel(lv); + return (exp - x0) / (getTotalExpToLevel(lv + 1) - x0); +} \ No newline at end of file diff --git a/src/lib/hypixel/stats.ts b/src/lib/hypixel/stats.ts new file mode 100644 index 0000000..f22fa0c --- /dev/null +++ b/src/lib/hypixel/stats.ts @@ -0,0 +1,41 @@ +import { MULTIPLIER } from "@/data/hypixel/general" +import { Player } from "@/lib/schema/player" + +export function getCoinMultiplier(level: number) { + if (level > MULTIPLIER[MULTIPLIER.length - 1].level) { + return MULTIPLIER[MULTIPLIER.length - 1].value; + } + + for (let i = MULTIPLIER.length - 1; i >= 0; i--) { + if (level >= MULTIPLIER[i].level) { + return MULTIPLIER[i].value; + } + } + + return MULTIPLIER[0].value +} + +export function getTotalCoins(stats: Player["player"]["stats"]) { + return Object.values(stats).reduce((total, stat) => total + (stat.coins || 0), 0); +} + +export function getTotalQuests(quests: Player["player"]["quests"]) { + return Object.values(quests).reduce((total, quest) => total + (quest.completions?.length || 0), 0); +} + +export function getTotalChallenges(challenges: Player["player"]["challenges"]["all_time"]) { + return Object.values(challenges).reduce((total, challenge) => total + challenge, 0); +} + +export function rewardClaimed(claimedAt?: number) { + if (!claimedAt) return false + const now = new Date() + const claimedDate = new Date(claimedAt) + const oneDay = 24 * 60 * 60 * 1000 + + if (now.getMilliseconds() - claimedDate.getMilliseconds() > oneDay) { + return true + } else { + return false + } +} \ No newline at end of file diff --git a/src/lib/hypixel/validatePlayer.ts b/src/lib/hypixel/validatePlayer.ts new file mode 100644 index 0000000..91b70cd --- /dev/null +++ b/src/lib/hypixel/validatePlayer.ts @@ -0,0 +1,29 @@ +"use server" + +import { getUuid } from "./api/mojang" +import { getPlayer } from "./api/player" + +export async function validatePlayer(ign: string) { + const uuid = await getUuid(ign) + + if (!uuid) { + return { + error: true, + message: "Player not found", + } + } + + const player = await getPlayer(uuid) + + if (!player) { + return { + error: true, + message: "Player never logged on to Hypixel", + } + } + + return { + error: false, + message: "Player found", + } +} \ No newline at end of file diff --git a/src/lib/schema/guild.ts b/src/lib/schema/guild.ts new file mode 100644 index 0000000..1366eb1 --- /dev/null +++ b/src/lib/schema/guild.ts @@ -0,0 +1,26 @@ +import z from "zod" + +export const guildSchema = z.object({ + guild: z.object({ + _id: z.string().min(1), + name: z.string().min(1), + tag: z.string().optional(), + tagColor: z.string().optional(), + members: z.array(z.object({ + uuid: z.string(), + rank: z.string(), + joined: z.number(), + questParticipation: z.number().optional(), + expHistory: z.record(z.string(), z.number()) + })), + ranks: z.array(z.object({ + name: z.string(), + default: z.boolean(), + tag: z.string().nullish().optional(), + created: z.number(), + priority: z.number() + })) + }) +}) + +export type Guild = z.infer diff --git a/src/lib/schema/player.ts b/src/lib/schema/player.ts new file mode 100644 index 0000000..8e9fa48 --- /dev/null +++ b/src/lib/schema/player.ts @@ -0,0 +1,56 @@ +import z from "zod" + +export const playerSchema = z.object({ + player: z.object({ + displayname: z.string(), + uuid: z.string(), + newPackageRank: z.literal("VIP").or(z.literal("VIP_PLUS").or(z.literal("MVP")).or(z.literal("MVP_PLUS"))).optional(), + monthlyPackageRank: z.string().optional(), + rankPlusColor: z.string().optional(), + monthlyRankColor: z.literal("GOLD").or(z.literal("AQUA")).optional(), + networkExp: z.number(), + karma: z.number(), + achievementPoints: z.number().optional(), + stats: z.record( + z.string(), + z.object({ + coins: z.number().optional() + }) + ), + quests: z.record( + z.string(), + z.object({ + completions: z.array( + z.object({ + time: z.number() + }).optional() + ).optional() + }) + ), + challenges: z.object({ + all_time: z.record(z.string(), z.number()) + }), + lastClaimedReward: z.number().optional(), + rewardHighScore: z.number().optional(), + rewardStreak: z.number().optional(), + totalRewards: z.number().optional(), + giftingMeta: z.object({ + giftsGiven: z.number().optional(), + ranksGiven: z.number().optional() + }).optional(), + firstLogin: z.number().optional(), + lastLogin: z.number().optional(), + socialMedia: z.object({ + links: z.object({ + DISCORD: z.string().optional(), + TWITCH: z.string().optional(), + HYPIXEL: z.string().optional(), + TWITTER: z.string().optional(), + YOUTUBE: z.string().optional() + }) + }) + }) +}) + +export type Player = z.infer +