Finished sidebar
This commit is contained in:
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