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 (
+
+ )
+}
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)}
+
+
+
+
+ >
+ )}
+
+
+
+ )
+}
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 (
+
+ )
+}
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
+