Finished sidebar

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

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

@@ -0,0 +1,22 @@
import { Button } from "@/components/ui/button"
import { Settings } from "lucide-react"
import Link from "next/link"
import { ReactNode } from "react"
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
return (
<>
<nav className="flex items-center justify-between px-6 py-4 border-b">
<Link href="/">
<span className="font-semibold text-lg">Hypixel Stats</span>
</Link>
<Button variant="ghost" size="icon" aria-label="Settings">
<Settings className="h-5 w-5" />
</Button>
</nav>
<div>
{children}
</div>
</>
)
}

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

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

View File

@@ -0,0 +1,24 @@
import { SearchBar } from "@/components/search-bar"
import { Button } from "@/components/ui/button"
import { Settings } from "lucide-react"
import Link from "next/link"
import { ReactNode } from "react"
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
return (
<>
<nav className="flex items-center justify-between px-6 py-4 border-b">
<Link href="/">
<span className="font-semibold text-lg">Hypixel Stats</span>
</Link>
<SearchBar navbar />
<Button variant="ghost" size="icon" aria-label="Settings">
<Settings className="h-5 w-5" />
</Button>
</nav>
<div>
{children}
</div>
</>
)
}

View File

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

View File

@@ -0,0 +1,74 @@
"use client"
import { Button } from "@/components/ui/button"
import { CopyIcon } from "lucide-react"
import Link from "next/link"
import { CgWebsite } from "react-icons/cg"
import { FaDiscord, FaTwitch, FaYoutube } from "react-icons/fa"
import { FiTwitter } from "react-icons/fi"
import { toast } from "sonner"
export default function SocialIcons(
{ discord, twitch, youtube, twitter, hypixel }: { discord?: string, twitch?: string, youtube?: string, twitter?: string, hypixel?: string }
) {
return (
<>
<DiscordIcon username={discord} />
<SocialIcon href={twitch}>
<FaTwitch />
</SocialIcon>
<SocialIcon href={youtube}>
<FaYoutube />
</SocialIcon>
<SocialIcon href={twitter}>
<FiTwitter />
</SocialIcon>
<SocialIcon href={hypixel}>
<CgWebsite />
</SocialIcon>
</>
)
}
function DiscordIcon({ username }: { username?: string }) {
if (!username) return null
function handleClick() {
toast(
<div className="flex gap-8">
<h1 className="text-2xl">{username}</h1>
<button
onClick={() => {
navigator.clipboard.writeText(username!)
toast.dismiss("discord-username")
}}
>
<CopyIcon />
</button>
</div>,
{
position: "bottom-center",
id: "discord-username",
className: "flex justify-center items-center gap-4"
}
)
}
return (
<Button variant="ghost" className="transition-all hover:scale-125 hover:cursor-pointer" onClick={handleClick}>
<FaDiscord />
</Button>
)
}
function SocialIcon({ href, children }: { href?: string, children: React.ReactNode }) {
if (!href) return null
return (
<Button variant="ghost" className="transition-all hover:scale-125" asChild>
<Link href={href}>
{children}
</Link>
</Button>
)
}

View File

@@ -0,0 +1,63 @@
import DisplayName from "@/components/player/displayname"
import { getGuild } from "@/lib/hypixel/api/guild"
import { getUuid } from "@/lib/hypixel/api/mojang"
import { getPlayer } from "@/lib/hypixel/api/player"
import { getExactLevel } from "@/lib/hypixel/level"
import Sidebar from "./_components/Sidebar"
export default async function PlayerPage({
params
}: {
params: Promise<{ ign: string }>
}) {
const { ign: pign } = await params
const uuid = await getUuid(pign)
if (!uuid) {
return (
<div className="flex flex-col items-center min-h-screen">
<h1 className="mt-25">Player not found</h1>
</div>
)
}
const player = await getPlayer(uuid)
if (!player) {
return (
<div className="flex flex-col items-center min-h-screen">
<h1 className="mt-25">Player not found</h1>
</div>
)
}
const guild = await getGuild(uuid)
const level = getExactLevel(player.networkExp)
return (
<div className="flex flex-col items-center min-h-screen">
<h1 className="text-3xl font-bold mt-25">
<DisplayName
ign={player.displayname}
rank={player.newPackageRank}
monthly={player.monthlyPackageRank}
rankColor={player.monthlyRankColor}
plusColor={player.rankPlusColor}
guildTag={guild?.tag}
tagColor={guild?.tagColor}
/>
</h1>
<h1>
{player.uuid}
</h1>
<div className="flex gap-6 px-6 mt-8 w-full max-w-7xl">
<Sidebar level={level} ign={pign} player={player} guild={guild ?? undefined} />
<div className="p-6 w-3/4 rounded-xl border shadow-sm bg-card">
<h2 className="mb-4 text-xl font-semibold">Game Statistics</h2>
<p>Game stats will be displayed here...</p>
</div>
</div>
</div>
)
}

View File

@@ -3,6 +3,26 @@
@custom-variant dark (&:is(.dark *));
@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;
}

View File

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

View File

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

View File

@@ -0,0 +1,105 @@
import { getColor } from "@/data/colors"
import { Player } from "@/lib/schema/player"
type NewPackageRank = Player["player"]["newPackageRank"]
type MonthlyPackageRank = Player["player"]["monthlyPackageRank"]
type RankColor = Player["player"]["monthlyRankColor"]
export default function DisplayName(
{ ign, rank, monthly, rankColor, plusColor, guildTag, tagColor }: {
ign: string
rank: NewPackageRank
monthly: MonthlyPackageRank
rankColor: RankColor
plusColor: string | undefined
guildTag: string | undefined
tagColor: string | undefined
}
) {
return (
<>
<PlayerRank rank={rank} monthly={monthly} plusColor={plusColor} rankColor={rankColor} />{" "}
<PlayerIGN ign={ign} rank={rank} monthly={monthly} rankColor={rankColor} /> <GuildTag tag={guildTag} tagColor={tagColor} />
</>
)
}
function PlayerIGN({ ign, rank, monthly, rankColor }: { ign: string, rank: NewPackageRank, monthly: MonthlyPackageRank, rankColor: RankColor }) {
if (monthly === "SUPERSTAR") {
if (rankColor === "GOLD") {
return <span className="text-mc-gold">{ign}</span>
} else {
return <span className="text-mc-aqua">{ign}</span>
}
}
switch (rank) {
case "VIP":
return <span className="text-mc-green">{ign}</span>
case "VIP_PLUS":
return <span className="text-mc-green">{ign}</span>
case "MVP":
return <span className="text-mc-aqua">{ign}</span>
case "MVP_PLUS":
return <span className="text-mc-aqua">{ign}</span>
default:
return <span className="text-mc-gray">{ign}</span>
}
}
function PlayerRank(
{ rank, monthly, plusColor, rankColor }: { rank: NewPackageRank, monthly: MonthlyPackageRank, plusColor?: string, rankColor: RankColor }
) {
if (monthly === "SUPERSTAR") {
if (rankColor === "GOLD") {
return (
<>
<span className="text-mc-gold">[MVP</span>
<span className={getColor(plusColor)}>++</span>
<span className="text-mc-gold">]</span>
</>
)
} else {
return (
<>
<span className="text-mc-aqua">[MVP</span>
<span className={getColor(plusColor)}>++</span>
<span className="text-mc-aqua">]</span>
</>
)
}
}
switch (rank) {
case "VIP":
return <span className="text-mc-green">[VIP]</span>
case "VIP_PLUS":
return (
<>
<span className="text-mc-green">[VIP</span>
<span className="text-mc-gold">+</span>
<span className="text-mc-green">]</span>
</>
)
case "MVP":
return <span className="text-mc-aqua">[MVP]</span>
case "MVP_PLUS":
return (
<>
<span className="text-mc-aqua">[MVP</span>
<span className={getColor(plusColor)}>+</span>
<span className="text-mc-aqua">]</span>
</>
)
default:
return null
}
}
function GuildTag({ tag, tagColor }: { tag?: string, tagColor?: string }) {
if (!tag) return null
const color = getColor(tagColor, "text", "gray")
return <span className={color}>[{tag}]</span>
}

View File

@@ -0,0 +1,57 @@
"use client"
import { Input } from "@/components/ui/input"
import { validatePlayer } from "@/lib/hypixel/validatePlayer"
import { cn } from "@/lib/utils"
import { Search } from "lucide-react"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { toast } from "sonner"
export function SearchBar({ navbar }: { navbar?: boolean }) {
const [input, setInput] = useState("")
const router = useRouter()
async function handleSearch(e: React.FormEvent) {
e.preventDefault()
const validatedPlayer = await validatePlayer(input.trim())
if (validatedPlayer.error === true) {
toast.error(validatedPlayer.message)
setInput("")
return
}
if (input.trim()) {
router.push(`/player/${encodeURIComponent(input.trim())}`)
}
if (navbar) {
setInput("")
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch(e)
}
}
return (
<div className={cn("w-full max-w-4xl px-4", !navbar && "mt-8")}>
<form onSubmit={handleSearch}>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
type="text"
placeholder={!navbar ? "Search for a player..." : ""}
className="pl-10"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

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

@@ -0,0 +1,36 @@
type ReturnType = `text-mc-${string}` | `bg-mc-${string}`
export function getColor(color?: string, type: "text" | "bg" = "text", defaultColor: "red" | "gray" = "red"): ReturnType {
switch (color) {
case "RED":
return type === "text" ? "text-mc-red" : "bg-mc-red"
case "GOLD":
return type === "text" ? "text-mc-gold" : "bg-mc-gold"
case "LIME":
return type === "text" ? "text-mc-green" : "bg-mc-green"
case "YELLOW":
return type === "text" ? "text-mc-yellow" : "bg-mc-yellow"
case "LIGHT_PURPLE":
return type === "text" ? "text-mc-light-purple" : "bg-mc-light-purple"
case "WHITE":
return type === "text" ? "text-mc-white" : "bg-mc-white"
case "BLUE":
return type === "text" ? "text-mc-blue" : "bg-mc-blue"
case "GREEN":
return type === "text" ? "text-mc-green" : "bg-mc-green"
case "DARK_RED":
return type === "text" ? "text-mc-dark-red" : "bg-mc-dark-red"
case "DARK_GREEN":
return type === "text" ? "text-mc-dark-green" : "bg-mc-dark-green"
case "DARK_PURPLE":
return type === "text" ? "text-mc-dark-purple" : "bg-mc-dark-purple"
case "DARK_GRAY":
return type === "text" ? "text-mc-dark-gray" : "bg-mc-dark-gray"
case "BLACK":
return type === "text" ? "text-mc-black" : "bg-mc-black"
case "DARK_BLUE":
return type === "text" ? "text-mc-dark-blue" : "bg-mc-dark-blue"
default:
return type === "text" ? `text-mc-${defaultColor}` : `bg-mc-${defaultColor}`
}
}

View File

@@ -0,0 +1,16 @@
export const MULTIPLIER = [
{ level: 0, value: 1 },
{ level: 5, value: 1.5 },
{ level: 10, value: 2 },
{ level: 15, value: 2.5 },
{ level: 20, value: 3 },
{ level: 25, value: 3.5 },
{ level: 30, value: 4 },
{ level: 40, value: 4.5 },
{ level: 50, value: 5 },
{ level: 100, value: 5.5 },
{ level: 125, value: 6 },
{ level: 150, value: 6.5 },
{ level: 200, value: 7 },
{ level: 250, value: 8 },
]

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

@@ -0,0 +1,10 @@
import { createEnv } from "@t3-oss/env-nextjs";
import z from "zod";
export const env = createEnv({
server: {
HYPIXEL_API_KEY: z.string().min(1),
},
experimental__runtimeEnv: true,
emptyStringAsUndefined: true,
})

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

@@ -0,0 +1,20 @@
const numberFormatter = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
});
const dateFormatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
export function formatNumber(num: number): string {
return numberFormatter.format(num);
}
export function formatDate(timestamp: number): string {
return dateFormatter.format(new Date(timestamp));
}

View File

@@ -0,0 +1,20 @@
import { env } from "../../env/server"
import { guildSchema } from "../../schema/guild"
const guildApi = "https://api.hypixel.net/v2/guild"
export async function getGuild(id: string, type: "id" | "player" | "name" = "player") {
const res = await fetch(`${guildApi}?${type}=${id}`, {
headers: {
"API-Key": env.HYPIXEL_API_KEY
}
})
if (!res.ok) return null
const { success, data } = guildSchema.safeParse(await res.json())
if (!success) return null
return data.guild
}

View File

@@ -0,0 +1,22 @@
import z from "zod"
const mojangApi = "https://api.mojang.com/users/profiles/minecraft"
const schema = z.object({
name: z.string().min(1),
id: z.string().min(1)
})
export async function getUuid(ign: string) {
const res = await fetch(`${mojangApi}/${ign}`)
if (!res.ok) return null
const data = await res.json()
const parsed = schema.safeParse(data)
if (!parsed.success) return null
return parsed.data.id
}

View File

@@ -0,0 +1,20 @@
import { env } from "../../env/server"
import { playerSchema } from "../../schema/player"
const playerApi = "https://api.hypixel.net/v2/player"
export async function getPlayer(uuid: string) {
const res = await fetch(`${playerApi}?uuid=${uuid}`, {
headers: {
"API-Key": env.HYPIXEL_API_KEY
}
})
if (!res.ok) return null
const { success, data } = playerSchema.safeParse(await res.json())
if (!success) return null
return data.player
}

View File

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

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

@@ -0,0 +1,31 @@
import { object } from "zod"
import { Guild } from "../schema/guild"
export function getGuildMember(guild: Guild["guild"], uuid: string) {
return guild.members.find(m => m.uuid === uuid)
}
export function getGuildRankTag(guild: Guild["guild"], uuid: string) {
const member = getGuildMember(guild, uuid)
return member?.rank === "Guild Master"
? "[GM]"
: `[${guild.ranks.find(r => r.name === member?.rank)?.tag}]`
}
export function getMemberGEXP(guild: Guild["guild"], uuid: string, days: number = 0) {
const member = getGuildMember(guild, uuid)
if (!member) return null
const index = Object.keys(member.expHistory)[days]
return member.expHistory[index]
}
export function getMemberWeeklyGEXP(guild: Guild["guild"], uuid: string) {
const member = getGuildMember(guild, uuid)
if (!member) return null
return Object.values(member.expHistory).reduce((a, b) => a + b)
}

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

@@ -0,0 +1,37 @@
const BASE = 10000;
const GROWTH = 2500;
const HALF_GROWTH = 0.5 * GROWTH;
const REVERSE_PQ_PREFIX = -(BASE - 0.5 * GROWTH) / GROWTH;
const REVERSE_CONST = REVERSE_PQ_PREFIX * REVERSE_PQ_PREFIX;
const GROWTH_DIVIDES_2 = 2 / GROWTH;
export function getLevel(exp: number) {
return exp <= 1 ? 1 : Math.floor(1 + REVERSE_PQ_PREFIX + Math.sqrt(REVERSE_CONST + GROWTH_DIVIDES_2 * exp));
}
export function getExactLevel(exp: number) {
return getLevel(exp) + getPercentageToNextLevel(exp);
}
// function getExpFromLevelToNext(level: number) {
// return level < 1 ? BASE : GROWTH * (level - 1) + BASE;
// }
function getTotalExpToLevel(level: number) {
const lv = Math.floor(level); const
x0 = getTotalExpToFullLevel(lv);
if (level === lv) return x0;
return (getTotalExpToFullLevel(lv + 1) - x0) * (level % 1) + x0;
}
function getTotalExpToFullLevel(level: number) {
return (HALF_GROWTH * (level - 2) + BASE) * (level - 1);
}
function getPercentageToNextLevel(exp: number) {
const lv = getLevel(exp);
const x0 = getTotalExpToLevel(lv);
return (exp - x0) / (getTotalExpToLevel(lv + 1) - x0);
}

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

@@ -0,0 +1,41 @@
import { MULTIPLIER } from "@/data/hypixel/general"
import { Player } from "@/lib/schema/player"
export function getCoinMultiplier(level: number) {
if (level > MULTIPLIER[MULTIPLIER.length - 1].level) {
return MULTIPLIER[MULTIPLIER.length - 1].value;
}
for (let i = MULTIPLIER.length - 1; i >= 0; i--) {
if (level >= MULTIPLIER[i].level) {
return MULTIPLIER[i].value;
}
}
return MULTIPLIER[0].value
}
export function getTotalCoins(stats: Player["player"]["stats"]) {
return Object.values(stats).reduce((total, stat) => total + (stat.coins || 0), 0);
}
export function getTotalQuests(quests: Player["player"]["quests"]) {
return Object.values(quests).reduce((total, quest) => total + (quest.completions?.length || 0), 0);
}
export function getTotalChallenges(challenges: Player["player"]["challenges"]["all_time"]) {
return Object.values(challenges).reduce((total, challenge) => total + challenge, 0);
}
export function rewardClaimed(claimedAt?: number) {
if (!claimedAt) return false
const now = new Date()
const claimedDate = new Date(claimedAt)
const oneDay = 24 * 60 * 60 * 1000
if (now.getMilliseconds() - claimedDate.getMilliseconds() > oneDay) {
return true
} else {
return false
}
}

View File

@@ -0,0 +1,29 @@
"use server"
import { getUuid } from "./api/mojang"
import { getPlayer } from "./api/player"
export async function validatePlayer(ign: string) {
const uuid = await getUuid(ign)
if (!uuid) {
return {
error: true,
message: "Player not found",
}
}
const player = await getPlayer(uuid)
if (!player) {
return {
error: true,
message: "Player never logged on to Hypixel",
}
}
return {
error: false,
message: "Player found",
}
}

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

@@ -0,0 +1,26 @@
import z from "zod"
export const guildSchema = z.object({
guild: z.object({
_id: z.string().min(1),
name: z.string().min(1),
tag: z.string().optional(),
tagColor: z.string().optional(),
members: z.array(z.object({
uuid: z.string(),
rank: z.string(),
joined: z.number(),
questParticipation: z.number().optional(),
expHistory: z.record(z.string(), z.number())
})),
ranks: z.array(z.object({
name: z.string(),
default: z.boolean(),
tag: z.string().nullish().optional(),
created: z.number(),
priority: z.number()
}))
})
})
export type Guild = z.infer<typeof guildSchema>

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

@@ -0,0 +1,56 @@
import z from "zod"
export const playerSchema = z.object({
player: z.object({
displayname: z.string(),
uuid: z.string(),
newPackageRank: z.literal("VIP").or(z.literal("VIP_PLUS").or(z.literal("MVP")).or(z.literal("MVP_PLUS"))).optional(),
monthlyPackageRank: z.string().optional(),
rankPlusColor: z.string().optional(),
monthlyRankColor: z.literal("GOLD").or(z.literal("AQUA")).optional(),
networkExp: z.number(),
karma: z.number(),
achievementPoints: z.number().optional(),
stats: z.record(
z.string(),
z.object({
coins: z.number().optional()
})
),
quests: z.record(
z.string(),
z.object({
completions: z.array(
z.object({
time: z.number()
}).optional()
).optional()
})
),
challenges: z.object({
all_time: z.record(z.string(), z.number())
}),
lastClaimedReward: z.number().optional(),
rewardHighScore: z.number().optional(),
rewardStreak: z.number().optional(),
totalRewards: z.number().optional(),
giftingMeta: z.object({
giftsGiven: z.number().optional(),
ranksGiven: z.number().optional()
}).optional(),
firstLogin: z.number().optional(),
lastLogin: z.number().optional(),
socialMedia: z.object({
links: z.object({
DISCORD: z.string().optional(),
TWITCH: z.string().optional(),
HYPIXEL: z.string().optional(),
TWITTER: z.string().optional(),
YOUTUBE: z.string().optional()
})
})
})
})
export type Player = z.infer<typeof playerSchema>