Finished sidebar
This commit is contained in:
15
.github/copilot-instructions.md
vendored
Normal file
15
.github/copilot-instructions.md
vendored
Normal 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.
|
||||
@@ -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"
|
||||
|
||||
134
pnpm-lock.yaml
generated
134
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@tailwindcss/oxide'
|
||||
- dprint
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
||||
22
src/app/(main)/layout.tsx
Normal file
22
src/app/(main)/layout.tsx
Normal 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
12
src/app/(main)/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/app/(stats)/layout.tsx
Normal file
24
src/app/(stats)/layout.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
167
src/app/(stats)/player/[ign]/_components/Sidebar.tsx
Normal file
167
src/app/(stats)/player/[ign]/_components/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
src/app/(stats)/player/[ign]/_components/SocialIcons.tsx
Normal file
74
src/app/(stats)/player/[ign]/_components/SocialIcons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
src/app/(stats)/player/[ign]/page.tsx
Normal file
63
src/app/(stats)/player/[ign]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<body className="antialiased">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="antialiased dark">
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
Hello
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
src/components/player/displayname.tsx
Normal file
105
src/components/player/displayname.tsx
Normal 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>
|
||||
}
|
||||
57
src/components/search-bar.tsx
Normal file
57
src/components/search-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal 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 }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal 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 }
|
||||
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal 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
36
src/data/colors.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
16
src/data/hypixel/general.ts
Normal file
16
src/data/hypixel/general.ts
Normal 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
10
src/lib/env/server.ts
vendored
Normal 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
20
src/lib/formatters.ts
Normal 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));
|
||||
}
|
||||
20
src/lib/hypixel/api/guild.ts
Normal file
20
src/lib/hypixel/api/guild.ts
Normal 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
|
||||
}
|
||||
22
src/lib/hypixel/api/mojang.ts
Normal file
22
src/lib/hypixel/api/mojang.ts
Normal 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
|
||||
}
|
||||
20
src/lib/hypixel/api/player.ts
Normal file
20
src/lib/hypixel/api/player.ts
Normal 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
|
||||
}
|
||||
9
src/lib/hypixel/formatters.ts
Normal file
9
src/lib/hypixel/formatters.ts
Normal 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
31
src/lib/hypixel/guild.ts
Normal 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
37
src/lib/hypixel/level.ts
Normal 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
41
src/lib/hypixel/stats.ts
Normal 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
|
||||
}
|
||||
}
|
||||
29
src/lib/hypixel/validatePlayer.ts
Normal file
29
src/lib/hypixel/validatePlayer.ts
Normal 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
26
src/lib/schema/guild.ts
Normal 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
56
src/lib/schema/player.ts
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user