Finished sidebar

This commit is contained in:
2025-08-16 23:39:11 +02:00
parent 1921efc76a
commit c79d06f272
35 changed files with 1307 additions and 9 deletions

15
.github/copilot-instructions.md vendored Normal file
View File

@@ -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.

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
public-hoist-pattern[]=*@nextui-org/*

View File

@@ -10,18 +10,24 @@
"fmt": "dprint fmt src/**/*.ts src/**/*.tsx" "fmt": "dprint fmt src/**/*.ts src/**/*.tsx"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.528.0", "lucide-react": "^0.528.0",
"next": "15.4.4", "next": "15.4.4",
"next-themes": "^0.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"zod": "^4.0.10" "zod": "^4.0.10"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@next/eslint-plugin-next": "^15.4.6",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
@@ -29,6 +35,7 @@
"dprint": "^0.50.1", "dprint": "^0.50.1",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.4", "eslint-config-next": "15.4.4",
"eslint-plugin-react-hooks": "^5.2.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
"typescript": "^5" "typescript": "^5"

134
pnpm-lock.yaml generated
View File

@@ -8,6 +8,12 @@ importers:
.: .:
dependencies: 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': '@t3-oss/env-nextjs':
specifier: ^0.13.8 specifier: ^0.13.8
version: 0.13.8(typescript@5.8.3)(zod@4.0.10) version: 0.13.8(typescript@5.8.3)(zod@4.0.10)
@@ -23,12 +29,21 @@ importers:
next: next:
specifier: 15.4.4 specifier: 15.4.4
version: 15.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) 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: react:
specifier: 19.1.0 specifier: 19.1.0
version: 19.1.0 version: 19.1.0
react-dom: react-dom:
specifier: 19.1.0 specifier: 19.1.0
version: 19.1.0(react@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: tailwind-merge:
specifier: ^3.3.1 specifier: ^3.3.1
version: 3.3.1 version: 3.3.1
@@ -39,6 +54,9 @@ importers:
'@eslint/eslintrc': '@eslint/eslintrc':
specifier: ^3 specifier: ^3
version: 3.3.1 version: 3.3.1
'@next/eslint-plugin-next':
specifier: ^15.4.6
version: 15.4.6
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
version: 4.1.11 version: 4.1.11
@@ -60,6 +78,9 @@ importers:
eslint-config-next: eslint-config-next:
specifier: 15.4.4 specifier: 15.4.4
version: 15.4.4(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) 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: tailwindcss:
specifier: ^4 specifier: ^4
version: 4.1.11 version: 4.1.11
@@ -340,6 +361,9 @@ packages:
'@next/eslint-plugin-next@15.4.4': '@next/eslint-plugin-next@15.4.4':
resolution: {integrity: sha512-1FDsyN//ai3Jd97SEd7scw5h1yLdzDACGOPRofr2GD3sEFsBylEEoL0MHSerd4n2dq9Zm/mFMqi4+NRMOreOKA==} 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': '@next/swc-darwin-arm64@15.4.4':
resolution: {integrity: sha512-eVG55dnGwfUuG+TtnUCt+mEJ+8TGgul6nHEvdb8HEH7dmJIFYOCApAaFrIrxwtEq2Cdf+0m5sG1Np8cNpw9EAw==} resolution: {integrity: sha512-eVG55dnGwfUuG+TtnUCt+mEJ+8TGgul6nHEvdb8HEH7dmJIFYOCApAaFrIrxwtEq2Cdf+0m5sG1Np8cNpw9EAw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@@ -404,6 +428,50 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} 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': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -1520,6 +1588,12 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} 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: next@15.4.4:
resolution: {integrity: sha512-kNcubvJjOL9yUOfwtZF3HfDhuhp+kVD+FM2A6Tyua1eI/xfmY4r/8ZS913MMz+oWKDlbps/dQOWdDricuIkXLw==} resolution: {integrity: sha512-kNcubvJjOL9yUOfwtZF3HfDhuhp+kVD+FM2A6Tyua1eI/xfmY4r/8ZS913MMz+oWKDlbps/dQOWdDricuIkXLw==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -1646,6 +1720,11 @@ packages:
peerDependencies: peerDependencies:
react: ^19.1.0 react: ^19.1.0
react-icons@5.5.0:
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
peerDependencies:
react: '*'
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -1751,6 +1830,12 @@ packages:
simple-swizzle@0.2.2: simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} 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: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2151,6 +2236,10 @@ snapshots:
dependencies: dependencies:
fast-glob: 3.3.1 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': '@next/swc-darwin-arm64@15.4.4':
optional: true optional: true
@@ -2189,6 +2278,37 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@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': {} '@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.12.0': {} '@rushstack/eslint-patch@1.12.0': {}
@@ -3428,6 +3548,11 @@ snapshots:
natural-compare@1.4.0: {} 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): next@15.4.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies: dependencies:
'@next/env': 15.4.4 '@next/env': 15.4.4
@@ -3563,6 +3688,10 @@ snapshots:
react: 19.1.0 react: 19.1.0
scheduler: 0.26.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-is@16.13.1: {}
react@19.1.0: {} react@19.1.0: {}
@@ -3725,6 +3854,11 @@ snapshots:
is-arrayish: 0.3.2 is-arrayish: 0.3.2
optional: true 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: {} source-map-js@1.2.1: {}
stable-hash@0.0.5: {} stable-hash@0.0.5: {}

View File

@@ -1,2 +1,5 @@
onlyBuiltDependencies: onlyBuiltDependencies:
- '@tailwindcss/oxide'
- dprint - dprint
- sharp
- unrs-resolver

22
src/app/(main)/layout.tsx Normal file
View File

@@ -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 (
<>
<nav className="flex items-center justify-between px-6 py-4 border-b">
<Link href="/">
<span className="font-semibold text-lg">Hypixel Stats</span>
</Link>
<Button variant="ghost" size="icon" aria-label="Settings">
<Settings className="h-5 w-5" />
</Button>
</nav>
<div>
{children}
</div>
</>
)
}

12
src/app/(main)/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { SearchBar } from "@/components/search-bar"
export default function Home() {
return (
<div className="min-h-screen flex flex-col items-center">
<div className="mt-[20vh]">
<h1 className="text-4xl font-bold text-center">Stats Hypixel</h1>
</div>
<SearchBar />
</div>
)
}

View File

@@ -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 (
<>
<nav className="flex items-center justify-between px-6 py-4 border-b">
<Link href="/">
<span className="font-semibold text-lg">Hypixel Stats</span>
</Link>
<SearchBar navbar />
<Button variant="ghost" size="icon" aria-label="Settings">
<Settings className="h-5 w-5" />
</Button>
</nav>
<div>
{children}
</div>
</>
)
}

View File

@@ -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 (
<Card className="w-1/4">
<CardContent>
<div className="flex justify-between px-8">
<div className="text-center">
<p>Hypixel level</p>
<p>{level.toFixed(2)}</p>
</div>
<div className="text-center">
<p>Karma</p>
<p className="text-mc-light-purple">{formatNumber(player.karma)}</p>
</div>
</div>
<Separator className="my-4" />
<div>
<p>
<span className="font-bold">{"Coin multiplier: "}</span>
<span>{`x${getCoinMultiplier(level)} (Level ${level.toFixed(1).split(".")[0]})`}</span>
</p>
<p>
<span className="font-bold">{"Total coins: "}</span>
<span className="text-mc-gold">{formatNumber(getTotalCoins(player.stats))}</span>
</p>
</div>
<Separator className="my-4" />
<div>
<p>
<span>
<Link href={`/achievements/${ign}`} className="font-bold underline">
Achievement Points
</Link>
</span>
<span className="font-bold">{": "}</span>
<span>{formatNumber(player.achievementPoints ?? 0)}</span>
</p>
<p>
<span>
<Link href={`/quests/${ign}`} className="font-bold underline">
Quests Completed
</Link>
</span>
<span className="font-bold">{": "}</span>
<span>{formatNumber(getTotalQuests(player.quests))}</span>
</p>
<p>
<span className="font-bold">{"Challenges Completed: "}</span>
<span>{formatNumber(getTotalChallenges(player.challenges.all_time))}</span>
</p>
</div>
<Separator className="my-4" />
<div>
<p>
<span className="font-bold">{"Today's Reward: "}</span>
<span>{rewardClaimed(player.lastClaimedReward) ? "Claimed" : "Unclaimed"}</span>
</p>
<p>
<span className="font-bold">{"Rewards Claimed: "}</span>
<span>{player.totalRewards}</span>
</p>
<p>
<span className="font-bold">{"Reward Streak: "}</span>
<span>{player.rewardStreak}</span>
</p>
<p>
<span className="font-bold">{"Top Reward Streak: "}</span>
<span>{player.rewardHighScore}</span>
</p>
</div>
<Separator className="my-4" />
<div>
<p>
<span className="font-bold">{"Gifts Given: "}</span>
<span>{player.giftingMeta?.giftsGiven ?? 0}</span>
</p>
<p>
<span className="font-bold">{"Ranks Given: "}</span>
<span>{player.giftingMeta?.ranksGiven ?? 0}</span>
</p>
</div>
<Separator className="my-4" />
<div>
<p>
<span className="font-bold">{"First Login: "}</span>
<span>{formatDate(player.firstLogin ?? 0)}</span>
</p>
<p>
<span className="font-bold">{"Last Login: "}</span>
<span>{formatDate(player.lastLogin ?? 0)}</span>
</p>
</div>
<Separator className="my-4" />
{guild && (
<>
<div className="flex flex-col gap-8">
<div>
<Link href={`/guild/${ign}`}>
<h1 className="text-xl font-bold underline">Guild</h1>
</Link>
<p>
<span className="font-bold">{"Name: "}</span>
<span className={getColor(guild.tagColor, "text", "gray")}>{guild.name}</span>
</p>
<p>
<span className="font-bold">{"Members: "}</span>
<span>{guild.members.length}</span>
</p>
</div>
<div>
<p>
<span className="font-bold">{"Rank: "}</span>
<span>{`${getGuildMember(guild, player.uuid)?.rank} `}</span>
<span className={getColor(guild.tagColor, "text", "gray")}>
{getGuildRankTag(guild, player.uuid)}
</span>
</p>
<p>
<span className="font-bold">{"Daily GEXP: "}</span>
<span>{formatNumber(getMemberGEXP(guild, player.uuid, 0) ?? 0)}</span>
</p>
<p>
<span className="font-bold">{"Weekly GEXP: "}</span>
<span>{formatNumber(getMemberWeeklyGEXP(guild, player.uuid) ?? 0)}</span>
</p>
<p>
<span className="font-bold">{"Joined: "}</span>
<span>{formatDate(getGuildMember(guild, player.uuid)?.joined ?? 0)}</span>
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<div className="flex flex-col gap-2">
<h1 className="text-xl font-bold underline">Social Links</h1>
<div className="flex gap-2">
<SocialIcons
discord={player.socialMedia.links.DISCORD}
twitch={player.socialMedia.links.TWITCH}
youtube={player.socialMedia.links.YOUTUBE}
twitter={player.socialMedia.links.TWITCH}
hypixel={player.socialMedia.links.HYPIXEL}
/>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<>
<DiscordIcon username={discord} />
<SocialIcon href={twitch}>
<FaTwitch />
</SocialIcon>
<SocialIcon href={youtube}>
<FaYoutube />
</SocialIcon>
<SocialIcon href={twitter}>
<FiTwitter />
</SocialIcon>
<SocialIcon href={hypixel}>
<CgWebsite />
</SocialIcon>
</>
)
}
function DiscordIcon({ username }: { username?: string }) {
if (!username) return null
function handleClick() {
toast(
<div className="flex gap-8">
<h1 className="text-2xl">{username}</h1>
<button
onClick={() => {
navigator.clipboard.writeText(username!)
toast.dismiss("discord-username")
}}
>
<CopyIcon />
</button>
</div>,
{
position: "bottom-center",
id: "discord-username",
className: "flex justify-center items-center gap-4"
}
)
}
return (
<Button variant="ghost" className="transition-all hover:scale-125 hover:cursor-pointer" onClick={handleClick}>
<FaDiscord />
</Button>
)
}
function SocialIcon({ href, children }: { href?: string, children: React.ReactNode }) {
if (!href) return null
return (
<Button variant="ghost" className="transition-all hover:scale-125" asChild>
<Link href={href}>
{children}
</Link>
</Button>
)
}

View File

@@ -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 (
<div className="flex flex-col items-center min-h-screen">
<h1 className="mt-25">Player not found</h1>
</div>
)
}
const player = await getPlayer(uuid)
if (!player) {
return (
<div className="flex flex-col items-center min-h-screen">
<h1 className="mt-25">Player not found</h1>
</div>
)
}
const guild = await getGuild(uuid)
const level = getExactLevel(player.networkExp)
return (
<div className="flex flex-col items-center min-h-screen">
<h1 className="text-3xl font-bold mt-25">
<DisplayName
ign={player.displayname}
rank={player.newPackageRank}
monthly={player.monthlyPackageRank}
rankColor={player.monthlyRankColor}
plusColor={player.rankPlusColor}
guildTag={guild?.tag}
tagColor={guild?.tagColor}
/>
</h1>
<h1>
{player.uuid}
</h1>
<div className="flex gap-6 px-6 mt-8 w-full max-w-7xl">
<Sidebar level={level} ign={pign} player={player} guild={guild ?? undefined} />
<div className="p-6 w-3/4 rounded-xl border shadow-sm bg-card">
<h2 className="mb-4 text-xl font-semibold">Game Statistics</h2>
<p>Game stats will be displayed here...</p>
</div>
</div>
</div>
)
}

View File

@@ -3,6 +3,26 @@
@custom-variant dark (&:is(.dark *)); @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 { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
@@ -114,6 +134,7 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }

View File

@@ -1,11 +1,13 @@
import { ReactNode } from "react" import { ReactNode } from "react"
import "./globals.css" import "./globals.css"
import { Toaster } from "@/components/ui/sonner"
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<body className="antialiased"> <body className="antialiased dark">
{children} {children}
<Toaster />
</body> </body>
</html> </html>
) )

View File

@@ -1,7 +0,0 @@
export default function Home() {
return (
<div>
Hello
</div>
)
}

View File

@@ -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 (
<>
<PlayerRank rank={rank} monthly={monthly} plusColor={plusColor} rankColor={rankColor} />{" "}
<PlayerIGN ign={ign} rank={rank} monthly={monthly} rankColor={rankColor} /> <GuildTag tag={guildTag} tagColor={tagColor} />
</>
)
}
function PlayerIGN({ ign, rank, monthly, rankColor }: { ign: string, rank: NewPackageRank, monthly: MonthlyPackageRank, rankColor: RankColor }) {
if (monthly === "SUPERSTAR") {
if (rankColor === "GOLD") {
return <span className="text-mc-gold">{ign}</span>
} else {
return <span className="text-mc-aqua">{ign}</span>
}
}
switch (rank) {
case "VIP":
return <span className="text-mc-green">{ign}</span>
case "VIP_PLUS":
return <span className="text-mc-green">{ign}</span>
case "MVP":
return <span className="text-mc-aqua">{ign}</span>
case "MVP_PLUS":
return <span className="text-mc-aqua">{ign}</span>
default:
return <span className="text-mc-gray">{ign}</span>
}
}
function PlayerRank(
{ rank, monthly, plusColor, rankColor }: { rank: NewPackageRank, monthly: MonthlyPackageRank, plusColor?: string, rankColor: RankColor }
) {
if (monthly === "SUPERSTAR") {
if (rankColor === "GOLD") {
return (
<>
<span className="text-mc-gold">[MVP</span>
<span className={getColor(plusColor)}>++</span>
<span className="text-mc-gold">]</span>
</>
)
} else {
return (
<>
<span className="text-mc-aqua">[MVP</span>
<span className={getColor(plusColor)}>++</span>
<span className="text-mc-aqua">]</span>
</>
)
}
}
switch (rank) {
case "VIP":
return <span className="text-mc-green">[VIP]</span>
case "VIP_PLUS":
return (
<>
<span className="text-mc-green">[VIP</span>
<span className="text-mc-gold">+</span>
<span className="text-mc-green">]</span>
</>
)
case "MVP":
return <span className="text-mc-aqua">[MVP]</span>
case "MVP_PLUS":
return (
<>
<span className="text-mc-aqua">[MVP</span>
<span className={getColor(plusColor)}>+</span>
<span className="text-mc-aqua">]</span>
</>
)
default:
return null
}
}
function GuildTag({ tag, tagColor }: { tag?: string, tagColor?: string }) {
if (!tag) return null
const color = getColor(tagColor, "text", "gray")
return <span className={color}>[{tag}]</span>
}

View File

@@ -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 (
<div className={cn("w-full max-w-4xl px-4", !navbar && "mt-8")}>
<form onSubmit={handleSearch}>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
type="text"
placeholder={!navbar ? "Search for a player..." : ""}
className="pl-10"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
</form>
</div>
)
}

View File

@@ -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<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -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<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -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 (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

36
src/data/colors.ts Normal file
View File

@@ -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}`
}
}

View File

@@ -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 },
]

10
src/lib/env/server.ts vendored Normal file
View File

@@ -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,
})

20
src/lib/formatters.ts Normal file
View File

@@ -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));
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,9 @@
export function floorLevel(level: number, base: number) {
const extra = level % base
if (extra === 0) {
return level;
}
return level - extra;
}

31
src/lib/hypixel/guild.ts Normal file
View File

@@ -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)
}

37
src/lib/hypixel/level.ts Normal file
View File

@@ -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);
}

41
src/lib/hypixel/stats.ts Normal file
View File

@@ -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
}
}

View File

@@ -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",
}
}

26
src/lib/schema/guild.ts Normal file
View File

@@ -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<typeof guildSchema>

56
src/lib/schema/player.ts Normal file
View File

@@ -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<typeof playerSchema>