feat: figure it out urself

This commit is contained in:
dvelo 2025-03-15 16:58:24 -05:00
parent bbec84c7de
commit bba5504f5d
66 changed files with 2634 additions and 661 deletions

@ -28,7 +28,7 @@
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { useTheme } from "next-themes"; import { useTheme } from "@/lib/hooks/use-theme";
import type { SVGProps } from "react"; import type { SVGProps } from "react";
const Github = (props: SVGProps<SVGSVGElement>) => { const Github = (props: SVGProps<SVGSVGElement>) => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();

@ -28,7 +28,7 @@
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { useTheme } from "next-themes"; import { useTheme } from "@/lib/hooks/use-theme";
import type { SVGProps } from "react"; import type { SVGProps } from "react";
const Github = (props: SVGProps<SVGSVGElement>) => { const Github = (props: SVGProps<SVGSVGElement>) => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();

@ -21,10 +21,12 @@
"@emotion/is-prop-valid": "^1.3.0", "@emotion/is-prop-valid": "^1.3.0",
"@linear/sdk": "^31.0.0", "@linear/sdk": "^31.0.0",
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@number-flow/react": "^0.5.7",
"@radix-ui/react-aspect-ratio": "1.1.1", "@radix-ui/react-aspect-ratio": "1.1.1",
"@radix-ui/react-avatar": "1.1.1", "@radix-ui/react-avatar": "1.1.1",
"@radix-ui/react-collapsible": "1.1.1", "@radix-ui/react-collapsible": "1.1.1",
"@radix-ui/react-context-menu": "^2.2.6", "@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-hover-card": "1.1.1", "@radix-ui/react-hover-card": "1.1.1",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-menubar": "1.1.1", "@radix-ui/react-menubar": "1.1.1",
@ -49,7 +51,7 @@
"inngest": "^3.21.2", "inngest": "^3.21.2",
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"json-beautify": "^1.1.1", "json-beautify": "^1.1.1",
"lucide-react": "^0.454.0", "lucide-react": "^0.479.0",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"minimessage-2-html": "1.6.0", "minimessage-2-html": "1.6.0",
"minimessage-js": "^1.1.3", "minimessage-js": "^1.1.3",
@ -71,6 +73,7 @@
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-qr-code": "^2.0.15", "react-qr-code": "^2.0.15",
"react-snowfall": "^2.2.0", "react-snowfall": "^2.2.0",
"recharts": "^2.15.1",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"sonner": "^1.7.0", "sonner": "^1.7.0",
@ -81,7 +84,8 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tailwindcss-patch": "^4.0.0", "tailwindcss-patch": "^4.0.0",
"turbo": "^2.4.0", "turbo": "^2.4.0",
"unplugin-tailwindcss-mangle": "^3.0.1" "unplugin-tailwindcss-mangle": "^3.0.1",
"vaul": "^1.1.2"
}, },
"devDependencies": { "devDependencies": {
"@clerk/themes": "^2.1.19", "@clerk/themes": "^2.1.19",

@ -249,6 +249,10 @@
} }
} }
.shiki {
@apply p-4 rounded max-w-[530px] overflow-x-auto;
}
.system-ui-font--font-boundary { .system-ui-font--font-boundary {
font-family: font-family:
system-ui, system-ui,

@ -47,7 +47,6 @@ export default function RootLayout({
<html lang="en"> <html lang="en">
<body className={inter.className}> <body className={inter.className}>
<noscript>{children}</noscript> <noscript>{children}</noscript>
<script src="https://unpkg.com/react-scan/dist/auto.global.js" />
{children} {children}
</body> </body>
</html> </html>

@ -53,7 +53,7 @@ export function Footer() {
Rules Rules
</Link> </Link>
, <Link href="https://minehut.com/terms-of-service">ToS</Link> &{" "} , <Link href="https://minehut.com/terms-of-service">ToS</Link> &{" "}
<Link href="https://support.mhsf.app/ECA">ECA</Link>. <Link href="https://t.mhsf.app/pr">Platform Rules</Link>.
</small> </small>
</span> </span>
</footer> </footer>

@ -34,7 +34,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useClerk, useUser } from "@clerk/nextjs"; import { useClerk, useUser } from "@clerk/nextjs";
import { ArrowDown, GalleryVertical } from "lucide-react"; import { ArrowDown, GalleryVertical } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "@/lib/hooks/use-theme";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Gradient } from "stripe-gradient"; import { Gradient } from "stripe-gradient";

@ -29,7 +29,7 @@
*/ */
"use client"; "use client";
import { useTheme } from "next-themes"; import { useTheme } from "@/lib/hooks/use-theme";
import type { SVGProps } from "react"; import type { SVGProps } from "react";
export const brandingIconClipboard = `<svg width="266" height="265" viewBox="0 0 266 265" fill="none" xmlns="http://www.w3.org/2000/svg"> export const brandingIconClipboard = `<svg width="266" height="265" viewBox="0 0 266 265" fill="none" xmlns="http://www.w3.org/2000/svg">

@ -35,8 +35,10 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import Github from "@/components/ui/github"; import Github from "@/components/ui/github";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Link } from "@/components/util/link"; import { Link } from "@/components/util/link";
import { version } from "@/config/version"; import { version } from "@/config/version";
import { useSettingsStore } from "@/lib/hooks/use-settings-store";
import { SignedIn, SignedOut, useClerk } from "@clerk/nextjs"; import { SignedIn, SignedOut, useClerk } from "@clerk/nextjs";
import { LogIn, LogOut, Settings, Ship, User, UserCog } from "lucide-react"; import { LogIn, LogOut, Settings, Ship, User, UserCog } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -44,6 +46,7 @@ import { useRouter } from "next/navigation";
export function MenuDropdown() { export function MenuDropdown() {
const clerk = useClerk(); const clerk = useClerk();
const router = useRouter(); const router = useRouter();
const settings = useSettingsStore();
return ( return (
<> <>
@ -105,14 +108,33 @@ export function MenuDropdown() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<li className="flex flex-col px-2 py-1 mx-auto my-1 text-xs w-full"> <li className="flex flex-col px-2 py-1 mx-auto my-1 text-xs w-full">
<div className="flex flex-row gap-2 w-full items-center"> <div className="flex flex-row gap-2 w-full items-center">
<div className="flex-1"> {settings.get("debug-mode") === "true" ? (
<button <Tooltip>
className="hover:brightness-110 transition-all" <TooltipTrigger>
type="button" <div className="flex-1">
> <button
<Badge variant="blue-subtle">v{version}</Badge> className="hover:brightness-110 transition-all"
</button> type="button"
</div> >
<Badge variant="blue-subtle">v{version}d</Badge>
</button>
</div>
</TooltipTrigger>
<TooltipContent>
You're in debug mode! Are you a dev?
</TooltipContent>
</Tooltip>
) : (
<div className="flex-1">
<button
className="hover:brightness-110 transition-all"
type="button"
>
<Badge variant="blue-subtle">v{version}</Badge>
</button>
</div>
)}
<Link href="Special:GitHub"> <Link href="Special:GitHub">
<Button <Button
variant="tertiary" variant="tertiary"

@ -71,9 +71,9 @@ export function NavBar() {
return ( return (
<div <div
className={cn( className={cn(
"w-screen h-[3rem] grid-cols-3 fixed backdrop-blur-xl z-10 flex", "w-screen h-[3rem] grid-cols-3 fixed z-10 flex",
"items-center justify-self-start me-auto pl-4 flex-1 transition-all justify-between", "items-center justify-self-start me-auto pl-4 flex-1 transition-all justify-between",
showBorder ? "border-b" : "", showBorder ? "border-b backdrop-blur-xl" : "",
pathname !== null && animatedTopbarPages.includes(pathname) pathname !== null && animatedTopbarPages.includes(pathname)
? "[--animation-delay:1000ms] opacity-0 animate-fade-in" ? "[--animation-delay:1000ms] opacity-0 animate-fade-in"
: "" : ""

@ -36,11 +36,16 @@ import { Separator } from "@/components/ui/separator";
import { Statistics } from "./statistics"; import { Statistics } from "./statistics";
import InfiniteScroll from "react-infinite-scroll-component"; import InfiniteScroll from "react-infinite-scroll-component";
import { useInfiniteScrolling } from "@/lib/hooks/use-infinite-scrolling"; import { useInfiniteScrolling } from "@/lib/hooks/use-infinite-scrolling";
import { useMHSFServer } from "@/lib/hooks/use-mhsf-multiple";
export function ServerList() { export function ServerList() {
const { servers, loading, serverCount, playerCount } = useServers(); const { servers, loading, serverCount, playerCount } = useServers();
const { itemsLength, fetchMoreData, hasMoreData, data } = const { itemsLength, fetchMoreData, hasMoreData, data } =
useInfiniteScrolling(servers); useInfiniteScrolling(servers);
const mhsfServers = useMHSFServer(
servers.map((s) => s.staticInfo._id),
true
);
if (loading) if (loading)
return ( return (

@ -63,7 +63,7 @@ export function Statistics({
useEffect(() => { useEffect(() => {
try { try {
(async () => { (async () => {
const fetchRes = await fetch("/api/v0/history/meta-daily-avg"); const fetchRes = await fetch("/api/v1/server/minehut/daily-avg");
const fetchJson: { const fetchJson: {
totalServerAverage: number; totalServerAverage: number;
totalPlayerAverage: number; totalPlayerAverage: number;

@ -0,0 +1,187 @@
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer";
import { Material } from "@/components/ui/material";
import type { MHSFData } from "@/lib/types/data";
import type { OnlineServer, ServerResponse } from "@/lib/types/mh-server";
import type { RouteParams } from "@/pages/api/v1/server/get/[server]";
import { useEffect, useState } from "react";
import {
Setting,
SettingContent,
SettingDescription,
SettingMeta,
SettingTitle,
} from "../../settings/setting";
import { Spinner } from "@/components/ui/spinner";
import { codeToHtml } from "shiki";
import { useTheme } from "@/lib/hooks/use-theme";
import { Button } from "@/components/ui/button";
import { Wrench } from "lucide-react";
import useClipboard from "@/lib/useClipboard";
import { DebugShikiParsedDrawer } from "./debug-shiki-parsed";
import { convert } from "../util";
import { Switch } from "@/components/ui/switch";
export function DebugMenu({
debugOptions,
setOpen,
open,
}: {
debugOptions: {
serverName: string;
serverId: string;
mhsfData: (MHSFData & RouteParams) | null;
serverData: ServerResponse | null;
onlineServerData: OnlineServer | null;
};
open: boolean;
setOpen: (newState: boolean) => void;
}) {
const [mhsfShikiParsed, setMHSFShikiParsed] = useState("");
const [mhShikiParsed, setMHShikiParsed] = useState("");
const clipboard = useClipboard();
const { resolvedTheme } = useTheme();
useEffect(() => {
(async () => {
setMHSFShikiParsed(
await codeToHtml(JSON.stringify(debugOptions.mhsfData, null, 2), {
lang: "json",
theme: resolvedTheme === "dark" ? "vitesse-dark" : "vitesse-light",
})
);
setMHShikiParsed(
await codeToHtml(JSON.stringify(debugOptions.serverData, null, 2), {
lang: "json",
theme: resolvedTheme === "dark" ? "vitesse-dark" : "vitesse-light",
})
);
})();
}, [debugOptions]);
return (
<Drawer onOpenChange={setOpen} open={open} direction="right">
<DrawerContent className="p-4 min-w-[600px] overflow-x-hidden max-h-screen overflow-y-auto">
<DrawerTitle className="text-lg mb-3 flex items-center gap-2">
<Wrench size={24} /> Debug Options
</DrawerTitle>
<span className="m-2 mt-1 text-sm">
This data is only designed for developers; it contains every single
piece of information MHSF knows about the server. Could be useful for
adding new backend options or endpoints.{" "}
<strong>
This only shows up when Debug Mode is enabled. (or when using
Ctrl+Shift+O)
</strong>
</span>
<Material className="mb-2">
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Server name</SettingTitle>
<SettingDescription>
Name of server after being parsed through Minehut API (aka
server.name)
</SettingDescription>
</SettingMeta>
{debugOptions.serverName}
</SettingContent>
</Setting>
</Material>
<Material className="mb-2">
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Server Id</SettingTitle>
<SettingDescription>
Passed usually through query
</SettingDescription>
</SettingMeta>
{debugOptions.serverId}
</SettingContent>
</Setting>
</Material>
<Material className="mb-2">
<strong className="flex items-center gap-2">
{debugOptions.serverData === null && <Spinner />} Parsed Minehut
data
<Button
size="sm"
onClick={() =>
clipboard.writeText(JSON.stringify(debugOptions.serverData))
}
>
Copy (no toast!)
</Button>
</strong>
<span
dangerouslySetInnerHTML={{ __html: mhShikiParsed }}
className="break-all max-w-[100px]"
/>
</Material>
<Material className="mb-2">
<strong className="flex items-center gap-2">
{debugOptions.mhsfData === null && <Spinner />} Parsed MHSF data
<Button
size="sm"
onClick={() =>
clipboard.writeText(JSON.stringify(debugOptions.mhsfData))
}
>
Copy (no toast!)
</Button>
</strong>
{debugOptions.mhsfData !== null && (
<>
<Setting className="py-3">
<SettingContent>
<SettingMeta>
<SettingTitle>See all data</SettingTitle>
<SettingDescription>
WARNING: this data is MASSIVE. (@keyboard yk what else is
massive?)
</SettingDescription>
</SettingMeta>
<DebugShikiParsedDrawer shikiParsed={mhsfShikiParsed}>
<Button>Open data</Button>
</DebugShikiParsedDrawer>
</SettingContent>
</Setting>
<Setting className="py-3">
<SettingContent>
<SettingMeta>
<SettingTitle>Total Statistical Data Count</SettingTitle>
<SettingDescription>
How many times has MHSF grabbed data about this server?
</SettingDescription>
</SettingMeta>
{convert(
debugOptions.mhsfData.achievements.historically.length +
debugOptions.mhsfData.playerData.historically.length +
debugOptions.mhsfData.favoriteData.favoriteHistoricalData
.length
)}
</SettingContent>
</Setting>
<Setting className="py-3">
<SettingContent>
<SettingMeta>
<SettingTitle>
Disable image caching on customization images
</SettingTitle>
<SettingDescription>
Enabling this could result in being tracked but{" "}
<strong>very rarely</strong> could render the image
faster. (removes wsrv.nl caching)
</SettingDescription>
</SettingMeta>
<Switch />
</SettingContent>
</Setting>
</>
)}
</Material>
</DrawerContent>
</Drawer>
);
}

@ -0,0 +1,45 @@
import { ReactNode, useEffect, useState } from "react";
import { DebugMenu } from "./debug-menu";
import type { MHSFData } from "@/lib/types/data";
import type { RouteParams } from "@/pages/api/v1/server/get/[server]";
import type { OnlineServer, ServerResponse } from "@/lib/types/mh-server";
import { useHotkeys } from "react-hotkeys-hook";
import { useSettingsStore } from "@/lib/hooks/use-settings-store";
export function DebugProvider({
debugOptions,
children,
}: {
debugOptions: {
serverName: string;
serverId: string;
mhsfData: (MHSFData & RouteParams) | null;
serverData: ServerResponse | null;
onlineServerData: OnlineServer | null;
};
children: ReactNode | ReactNode[];
}) {
const [open, setOpen] = useState(false);
const settingsStore = useSettingsStore();
useHotkeys(
"ctrl+shift+o",
() => {
if (settingsStore.get("debug-mode") === "true") setOpen(!open);
},
[open]
);
useEffect(() => {
window.addEventListener("open-debug-menu", () => {
setOpen(true);
});
}, []);
return (
<>
<DebugMenu open={open} setOpen={setOpen} debugOptions={debugOptions} />
{children}
</>
);
}

@ -0,0 +1,29 @@
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Wrench } from "lucide-react";
import type { ReactNode } from "react";
export function DebugShikiParsedDrawer({
children,
shikiParsed,
}: {
children: ReactNode | ReactNode[] | undefined;
shikiParsed: string;
}) {
return (
<Drawer direction="right">
<DrawerContent className="p-4 min-w-[600px] max-h-screen overflow-y-auto">
<DrawerTitle className="text-lg mb-3 flex items-center gap-2">
<Wrench size={24} /> Debug Options - MHSF Data
</DrawerTitle>
<span dangerouslySetInnerHTML={{ __html: shikiParsed }} />
</DrawerContent>
<DrawerTrigger>{children}</DrawerTrigger>
</Drawer>
);
}

@ -35,12 +35,13 @@ import useClipboard from "@/lib/useClipboard";
import { miniMessage } from "minimessage-js"; import { miniMessage } from "minimessage-js";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Material } from "@/components/ui/material";
export function MOTDRow({ server }: { server: ServerResponse }) { export function MOTDRow({ server }: { server: ServerResponse }) {
const clipboard = useClipboard(); const clipboard = useClipboard();
return ( return (
<div className="border rounded-xl p-4 relative max-h-[250px] "> <Material className="p-4 relative h-[250px]">
<strong className="text-lg">MOTD</strong> <strong className="text-lg">MOTD</strong>
<br /> <br />
<Separator className="my-2" /> <Separator className="my-2" />
@ -66,6 +67,6 @@ export function MOTDRow({ server }: { server: ServerResponse }) {
click to copy HTML click to copy HTML
</button> </button>
</small> </small>
</div> </Material>
); );
} }

@ -0,0 +1,61 @@
import { Alert } from "@/components/ui/alert";
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer";
import { TextArea } from "@/components/ui/text-area";
import { Link } from "@/components/util/link";
import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import { useState } from "react";
export function ReportingDialog({
server,
open,
setOpen,
}: {
server: ReturnType<typeof useMHSFServer>;
open: boolean;
setOpen: (newState: boolean) => void;
}) {
const [reason, setReason] = useState("");
return (
<Drawer direction="left" open={open} onOpenChange={setOpen}>
<DrawerContent className="p-4 min-w-[600px] overflow-x-hidden max-h-screen overflow-y-auto">
<DrawerTitle className="text-lg mb-3 flex items-center gap-2">
Report server
</DrawerTitle>
<Alert variant="warning" className="text-sm">
<strong>PLEASE READ BEFORE REPORTING:</strong>
<ul className="list-disc pl-8">
<li>This will send a notification to MHSF maintainers.</li>
<li>
This server must be in violation of the{" "}
<Link href="/docs/legal/rules" className="underline">
Platform Rules
</Link>{" "}
to be a valid report.
</li>
<li>
Please do not spam this form with mindless reports. If you do,
your account will be banned.
</li>
<li>
We are not Minehut support,{" "}
<b>
we cannot help you with a problem within the Minehut platform
</b>{" "}
or within the server, we can only moderate the customization of
the server. (if the problem is within the server,{" "}
<Link href="https://support.minehut.com/hc/en-us/requests/new?tf_subject=Reporting%20Server&tf_27062997154195=reports_appeals&tf_27063229498259=report_server">
report it on Minehut
</Link>
)
</li>
</ul>
</Alert>
<br />
<TextArea label="Reason for reporting" />
<br />
<span></span>
</DrawerContent>
</Drawer>
);
}

@ -28,38 +28,29 @@
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { MongoClient } from "mongodb"; import { type ReactNode, useEffect, useState } from "react";
import { NextApiRequest, NextApiResponse } from "next"; import { ReportingDialog } from "./reporting-dialog";
import { waitUntil } from "@vercel/functions"; import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
export default async function handler( export function ReportingProvider({
req: NextApiRequest, children,
res: NextApiResponse, server,
) { }: {
const client = new MongoClient(process.env.MONGO_DB as string); children: ReactNode | ReactNode[];
const db = client.db("mhsf").collection("historical"); server: ReturnType<typeof useMHSFServer>;
const server = req.query.server as string; }) {
const scopes: Array<string> = checkForInfoOrLeave(res, req.body.scopes); const [open, setOpen] = useState(false);
const allData = await db.find({ server }).toArray(); useEffect(() => {
const data: any[] = []; window.addEventListener("open-report-menu", () => {
setOpen(true);
});
});
allData.forEach((d) => { return (
const result: any = {}; <>
scopes.forEach((b) => { <ReportingDialog server={server} open={open} setOpen={setOpen} />{" "}
result[b] = d[b]; {children}
}); </>
data.push(result); );
});
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.send({ data });
}
function checkForInfoOrLeave(res: NextApiResponse, info: any) {
if (info == undefined)
res.status(400).json({ message: "Information wasn't supplied" });
return info;
} }

@ -31,25 +31,38 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ServerResponse } from "@/lib/types/mh-server"; import { ServerResponse } from "@/lib/types/mh-server";
import { SignedIn, SignedOut, useClerk } from "@clerk/nextjs"; import { SignedIn, SignedOut, useClerk } from "@clerk/nextjs";
import { Heart, Star } from "lucide-react"; import { EllipsisVertical, Flag, Heart, Star } from "lucide-react";
import { useFavoriteStore } from "@/lib/hooks/use-favorite-store"; import { useFavoriteStore } from "@/lib/hooks/use-favorite-store";
import { useState } from "react"; import { useState } from "react";
import { Spinner } from "@/components/ui/spinner"; import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import NumberFlow from "@number-flow/react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ServerPageButtons({ server }: { server: ServerResponse }) { export function ServerPageButtons({
server,
mhsfData,
}: {
server: ServerResponse;
mhsfData: ReturnType<typeof useMHSFServer>;
}) {
const clerk = useClerk(); const clerk = useClerk();
const favoritesStore = useFavoriteStore(server.name); const favoritesStore = useFavoriteStore(mhsfData);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
return ( return (
<> <span className="flex items-center gap-2">
<SignedIn> <SignedIn>
<Button <Button
className="flex items-center gap-2 text-sm" className="flex items-center gap-2 text-sm"
variant={favoritesStore.isFavorite ? "secondary" : "default"} variant={favoritesStore.isFavorite ? "secondary" : "default"}
onClick={async () => { onClick={async () => {
setLoading(true); setLoading(true);
await favoritesStore.toggleFavorite(server.name); await favoritesStore.toggleFavorite();
setLoading(false); setLoading(false);
}} }}
disabled={loading || favoritesStore.isFavorite === null} disabled={loading || favoritesStore.isFavorite === null}
@ -61,9 +74,10 @@ export function ServerPageButtons({ server }: { server: ServerResponse }) {
/> />
Favorite Favorite
{favoritesStore.favoriteNumber !== null && ( {favoritesStore.favoriteNumber !== null && (
<code>{favoritesStore.favoriteNumber}</code> <code>
<NumberFlow value={favoritesStore.favoriteNumber} />{" "}
</code>
)} )}
{loading && <Spinner />}
</Button> </Button>
</SignedIn> </SignedIn>
<SignedOut> <SignedOut>
@ -78,6 +92,28 @@ export function ServerPageButtons({ server }: { server: ServerResponse }) {
)} )}
</Button> </Button>
</SignedOut> </SignedOut>
</> <DropdownMenu>
<DropdownMenuTrigger>
<Button
className="flex items-center"
size="square-md"
variant="secondary"
>
<EllipsisVertical size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="text-red-400 flex items-center gap-2"
onClick={() => {
window.dispatchEvent(new Event("open-report-menu"));
}}
>
<Flag size={16} />
Report
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span>
); );
} }

@ -5,11 +5,43 @@ import { ServerPageTags } from "./server-page-tags";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ServerRows } from "./server-rows"; import { ServerRows } from "./server-rows";
import { ServerPageButtons } from "./server-page-buttons"; import { ServerPageButtons } from "./server-page-buttons";
import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
export function ServerMainPage({
server,
mhsfData,
}: {
server: ServerResponse;
mhsfData: ReturnType<typeof useMHSFServer>;
}) {
useEffect(() => {
if (mhsfData.server?.customizationData.banner !== undefined)
window.dispatchEvent(new Event("force-dark-mode"));
});
export function ServerMainPage({ server }: { server: ServerResponse }) {
return ( return (
<div className="pt-[150px] xl:px-[100px]"> <div
<span className="flex items-center gap-2 w-full"> className={cn(
"xl:px-[100px]",
mhsfData.server?.customizationData.banner === undefined
? "pt-[150px]"
: "pt-[300px]"
)}
>
{mhsfData.server?.customizationData.banner && (
<img
src={mhsfData.server?.customizationData.banner}
alt="User provided banner for server"
className="rounded align-middle block ml-auto mr-auto absolute left-0 z-0 w-full object-fill"
style={{
maskImage: "linear-gradient(to top, transparent, black)",
top: "0",
}}
/>
)}
<span className="flex items-center gap-2 w-full relative">
<div className="bg-secondary p-4 rounded-lg ml-4"> <div className="bg-secondary p-4 rounded-lg ml-4">
<IconDisplay server={server} /> <IconDisplay server={server} />
</div> </div>
@ -17,7 +49,7 @@ export function ServerMainPage({ server }: { server: ServerResponse }) {
<div className="flex justify-between w-full"> <div className="flex justify-between w-full">
<h1 className="text-2xl font-bold">{server.name}</h1> <h1 className="text-2xl font-bold">{server.name}</h1>
<span> <span>
<ServerPageButtons server={server} /> <ServerPageButtons server={server} mhsfData={mhsfData} />
</span> </span>
</div> </div>
<span className="flex items-center gap-2 flex-wrap"> <span className="flex items-center gap-2 flex-wrap">
@ -26,7 +58,7 @@ export function ServerMainPage({ server }: { server: ServerResponse }) {
</p> </p>
</span> </span>
<Separator className="my-6" /> <Separator className="my-6" />
<ServerRows server={server} /> <ServerRows server={server} mhsfData={mhsfData} />
</div> </div>
); );
} }

@ -2,19 +2,20 @@
import { Placeholder } from "@/components/ui/placeholder"; import { Placeholder } from "@/components/ui/placeholder";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useServer } from "@/lib/hooks/use-server"; import { useServer } from "@/lib/hooks/use-server";
import type { OnlineServer } from "@/lib/types/mh-server"; import type { ServerResponse } from "@/lib/types/mh-server";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { ServerMainPage } from "./server-page"; import { ServerMainPage } from "./server-page";
import { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import { AnimatedText } from "@/components/ui/animated-text";
import { useSettingsStore } from "@/lib/hooks/use-settings-store";
import { Button } from "@/components/ui/button";
import { DebugProvider } from "./debug/debug-provider";
import { ReportingProvider } from "./reporting/reporting-provider";
export function ServerProvider({ serverId }: { serverId: string }) { export function ServerProvider({ serverId }: { serverId: string }) {
const { server, error, loading } = useServer({ id: serverId }); const { server, error, loading } = useServer({ id: serverId });
const settings = useSettingsStore();
if (loading) const mhsf = useMHSFServer(serverId);
return (
<div className="absolute top-[50%] left-[50%]">
<Spinner />
</div>
);
if (error !== null) if (error !== null)
return ( return (
@ -33,8 +34,48 @@ export function ServerProvider({ serverId }: { serverId: string }) {
); );
return ( return (
<div className="px-10"> <DebugProvider
<ServerMainPage server={server as OnlineServer} /> debugOptions={{
</div> serverName: (server ?? { name: "" }).name,
serverId: serverId,
mhsfData: mhsf.server,
serverData: server,
onlineServerData: null,
}}
>
{loading || mhsf.loading ? (
<div className="absolute top-[50%] left-[50%] transform -translate-x-1/2 -translate-y-1/2 block justify-center text-center gap-2">
<span className="w-full flex justify-center">
<Spinner />
</span>
<span>
<AnimatedText
text={
loading && mhsf.loading
? "Loading server and MHSF data..."
: loading
? "Loading server data..."
: "Loading MHSF data..."
}
className="text-center w-full mt-2"
/>
</span>
{settings.get("debug-mode") === "true" && (
<Button
onClick={() => window.dispatchEvent(new Event("open-debug-menu"))}
>
Debug Stack
</Button>
)}
</div>
) : (
<div className="px-10">
<ReportingProvider server={mhsf}>
<ServerMainPage server={server as ServerResponse} mhsfData={mhsf} />
</ReportingProvider>
</div>
)}
</DebugProvider>
); );
} }

@ -32,14 +32,15 @@ import type { ServerResponse } from "@/lib/types/mh-server";
import useClipboard from "@/lib/useClipboard"; import useClipboard from "@/lib/useClipboard";
import { MOTDRow } from "./motd/motd-row"; import { MOTDRow } from "./motd/motd-row";
import { StatisticsMainRow } from "./stats/stats-main-row"; import { StatisticsMainRow } from "./stats/stats-main-row";
import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
export function ServerRows({ server }: { server: ServerResponse }) { export function ServerRows({ server, mhsfData }: { server: ServerResponse, mhsfData: ReturnType<typeof useMHSFServer> }) {
const clipboard = useClipboard(); const clipboard = useClipboard();
return ( return (
<span className="lg:grid lg:grid-cols-3 w-full gap-3"> <span className="lg:grid lg:grid-cols-3 w-full gap-3">
<MOTDRow server={server} /> <MOTDRow server={server} />
<StatisticsMainRow server={server} /> <StatisticsMainRow server={server} mhsfData={mhsfData} />
</span> </span>
); );
} }

@ -1,12 +1,170 @@
"use client";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import type { ServerResponse } from "@/lib/types/mh-server"; import type { ServerResponse } from "@/lib/types/mh-server";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import { cn } from "@/lib/utils";
import { useQueryState } from "nuqs";
import { Badge } from "@/components/ui/badge";
import { convert } from "../util";
import { Material } from "@/components/ui/material";
export function StatisticsMainRow({
server,
mhsfData,
}: {
server: ServerResponse;
mhsfData: ReturnType<typeof useMHSFServer>;
}) {
const [statisticType, setStatisticType] = useQueryState("st", {
defaultValue: "playerCount",
});
export function StatisticsMainRow({ server }: { server: ServerResponse }) {
return ( return (
<span className="border rounded-xl p-4 relative col-span-2 min-h-[250px] max-h-[250px]"> <Material
<strong className="text-lg">Statistics</strong> className="relative col-span-2 h-[250px] max-lg:mt-3"
<br /> padding="none"
<Separator className="my-2" /> >
</span> <div className="p-4">
<span className="flex gap-4 mb-2">
<strong className="text-lg">Statistics</strong>
<button
type="button"
className={cn(
"text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-zinc-700/30 transition-all duration-75 disabled:opacity-50 disabled:pointer-events-none",
"rounded-xl px-2 flex items-center gap-2",
statisticType === "playerCount" &&
"bg-slate-100 dark:bg-zinc-700/30 font-medium"
)}
onClick={() => setStatisticType("playerCount")}
>
Player Count
<Badge className="px-1">
<code>{convert(server.joins)}</code>
</Badge>
</button>
<button
type="button"
className={cn(
"text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-zinc-700/30 transition-all duration-75 disabled:opacity-50 disabled:pointer-events-none",
"rounded-xl px-2 flex items-center gap-2",
statisticType === "favorites" &&
"bg-slate-100 dark:bg-zinc-700/30 font-medium"
)}
onClick={() => setStatisticType("favorites")}
>
Favorites
<Badge className="px-1">
<code>
{convert(
mhsfData.server?.favoriteData.favoriteNumber as number
)}
</code>
</Badge>
</button>
</span>
<Separator />
</div>
<div className="mt-2">
{!mhsfData.loading && (
<StatisticsChart
data={
statisticType === "playerCount"
? mhsfData.server?.playerData.historically
: mhsfData.server?.favoriteData.favoriteHistoricalData
}
mainDataPoint={statisticType}
/>
)}
</div>
</Material>
);
}
const chartConfig = {
playerCount: {
label: "Joins",
color: "hsl(var(--chart-1))",
},
favorites: {
label: "Favorites",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export function StatisticsChart({
data,
mainDataPoint,
}: {
data: any;
mainDataPoint: string;
}) {
console.log(data);
return (
<ChartContainer config={chartConfig} className="max-h-[202px] min-w-full">
<AreaChart
accessibilityLayer
data={data.slice(data.length - 30, data.length)}
margin={{
top: 30,
}}
className="rounded-b-xl"
>
<CartesianGrid vertical={false} horizontal={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} tick={false} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px]"
nameKey={mainDataPoint}
indicator="line"
labelFormatter={(value) => {
return `${new Date(value).toLocaleDateString("en-US", {
day: "numeric",
month: "short",
})} ${new Date(value).toLocaleTimeString("en-US", {
timeStyle: "short",
})}`;
}}
/>
}
/>
<defs>
<linearGradient
id={`fill${mainDataPoint}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="25%"
stopColor={`var(--color-${mainDataPoint})`}
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor={`var(--color-${mainDataPoint})`}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
dataKey={mainDataPoint}
type="natural"
fill={`url(#fill${mainDataPoint})`}
fillOpacity={0.4}
stroke={`var(--color-${mainDataPoint})`}
stackId="a"
/>
</AreaChart>
</ChartContainer>
); );
} }

@ -0,0 +1,124 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { version } from "@/config/version";
export function convert(value: number) {
let result: string = value.toString();
if (value >= 1000000) {
result = Math.floor(value / 1000000) + "m";
} else if (value >= 1000) {
result = Math.floor(value / 1000) + "k";
}
return result;
}
export const loadingList = [
"Making gamer's safer",
"Finding why Apple is so expensive",
"Finding why MHSF v" + version + " is so bad",
"Finding why MHSF is so slow",
"Finding why MHSF is loading",
"Finding why MHSF is open-source",
"Changing the license of MHSF",
"Going through the American school system",
"Finding how TypeScript is clearly better than Clojure",
"Convincing Valerie to use a web framework",
"Joining the Minehut Discord server",
"Putting the fries in the bag",
"Finding why Minehut's auto-mod is garbage",
"Convincing Emopedia to travel to America",
"Telling people why Next.js is the best web framework",
"Asking Tim about MHSF compliance",
"Debating Minehut's rules",
"Convincing Valerie that Apple is a light-hearted company",
"Convincing the average 'I use arch btw' person that Macintosh isn't that bad",
"Repeating history",
"Inventing a time machine",
"Reinventing SSH",
"Reinventing MHSF",
"Figuring out why 'emacs' is a good editor",
"Taking a design class",
"Making a passive aggressive comment",
"Supporting transgender",
"Installing hyfetch",
"Upgrading from Apple M2 to M3",
"Watching the newest Minehut live stream",
"Transitioning MHSF from Vercel to self-hosted",
// generic xD
"Loading",
"Opening Spotify",
"Pinging another staff member because of a spam message",
"Clarifying Minehut rules regarding APIs",
"Breaking the Minehut TOS",
"Figuring out why Skript is used in the first place",
"Creating a new Velocity fork",
"Convincing yet another person that Firefox doesn't support all MHSF features",
"Reinventing accounts",
"Redoing MHSF",
"Typing that one Vesktop emoji",
"Talking to my besties",
"Explaining how I'm clearly a 'girly pop'",
"Supporting GamerSafer's company values",
"Welcoming a new staff member",
"v2?",
"Listening to Tyler The Creator",
"Explaining to somebody why we don't need to hear your political ideas",
"Hearing yet another person complaining about their punishment",
"Explaining brainrot to somebody that clearly doesn't need to hear it",
"Telling somebody 'ily' who clearly doesn't know you",
"Figuring out how to get a random item out of a list",
"Watching how your a beautiful person",
"Listening to music",
"Explaining how I wasn't trying to be mean",
"Telling somebody how AI is going to take over the world",
"'tolking 2 my frends on da cmputer'",
"Explaining how I just get it",
"Getting the 'Jamie Fan' role",
"Finding where Minehut's peak was",
"Making a new ticket on Minehut",
"Contacting Minehut",
"My bad man",
"Adding 'use client' to a Next.js component file",
"Blaming being annoying on the depression",
"Explaining why Minehut needs to be PG13",
"Explaining why Minehut needs a 4 hour limit on free servers",
"Explaining how Minehut's API works",
"Sleeping",
"Complaining about how I can't eat lunch early",
"Greeting you",
"Creating a new Paper fork",
"Creating the modern sever list",
"Using all of the buzzwords",
"Chatting in #queen-jamie-chat",
"Asking for a new phone",
"Asking to donate to an open-source project",
];

@ -52,10 +52,12 @@ export function BrowserSettings() {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const [fontFamily, setFontFamily] = useState("inter"); const [fontFamily, setFontFamily] = useState("inter");
const [mcFont, setMcFont] = useState(true); const [mcFont, setMcFont] = useState(true);
const [debugMode, setDebugMode] = useState(false);
useEffect(() => { useEffect(() => {
setFontFamily((settingsStore.get("font-family") ?? "inter") as string); setFontFamily((settingsStore.get("font-family") ?? "inter") as string);
setMcFont((settingsStore.get("mc-font") === "true") as boolean); setMcFont((settingsStore.get("mc-font") === "true") as boolean);
setDebugMode((settingsStore.get("debug-mode") === "true") as boolean);
}, []); }, []);
return ( return (
@ -119,6 +121,22 @@ export function BrowserSettings() {
</Select> </Select>
</SettingContent> </SettingContent>
</Setting> </Setting>
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Debug Mode</SettingTitle>
<SettingDescription>Enable debug mode to show debug options</SettingDescription>
</SettingMeta>
<Switch
checked={debugMode}
onCheckedChange={(c) => {
settingsStore.set("debug-mode", c, false);
window.dispatchEvent(new Event("debug-mode-change"));
setDebugMode(c);
}}
/>
</SettingContent>
</Setting>
</Material> </Material>
); );
} }

@ -28,45 +28,39 @@
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { MongoClient } from "mongodb"; import { Material } from "@/components/ui/material";
import { NextApiRequest, NextApiResponse } from "next"; import { Setting, SettingContent, SettingDescription, SettingMeta, SettingTitle } from "./setting";
import { waitUntil } from "@vercel/functions"; import { Button } from "@/components/ui/button";
import { AnimatedText } from "@/components/ui/animated-text";
import { useState } from "react";
import { loadingList } from "../server-page/util";
export default async function handler( export function DebugSettings() {
req: NextApiRequest, const [randomText, setRandomText] = useState("")
res: NextApiResponse,
) {
const client = new MongoClient(process.env.MONGO_DB as string);
const db = client.db("mhsf").collection("history");
const mh = client.db("mhsf").collection("mh");
const server = req.query.server as string;
const allData = await db.find({ server }).toArray(); return (
const data: any[] = []; <Material className="mt-6 grid gap-4">
if (server === "peww") console.log(allData.slice(-30)); <h2 className="text-xl font-semibold text-inherit">Debug Settings</h2>
<Setting>
for (const d of allData.slice(-30)) { <SettingContent>
const dateOfEntry = new Date(d.date); <SettingMeta>
const result = await mh <SettingTitle>
.find({ Generate loading text
date: { </SettingTitle>
$gte: new Date(dateOfEntry.getTime() - 1000 * 60 * 60), <SettingDescription>
$lt: new Date(dateOfEntry.getTime() + 1000 * 60 * 60), Generate a random loading text
}, </SettingDescription>
}) </SettingMeta>
.toArray(); <div className="block pb-6">
<Button onClick={() => {
if (result.length > 0) { setRandomText(loadingList[Math.floor(Math.random() * loadingList.length)])
const resultedData = result[0]; }}>
data.push({ Generate
relativePrecentage: d.player_count / resultedData.total_players, </Button>
date: dateOfEntry, <AnimatedText className="font-bold" text={randomText + "..."}/>
}); </div>
} </SettingContent>
} </Setting>
</Material>
// Close the database, but don't close this );
// serverless instance until it happens
waitUntil(client.close());
res.send({ data });
} }

@ -37,10 +37,22 @@ import { cn } from "@/lib/utils";
import { SignedIn, SignedOut, useClerk } from "@clerk/nextjs"; import { SignedIn, SignedOut, useClerk } from "@clerk/nextjs";
import { ExternalLink, Globe, TabletSmartphone } from "lucide-react"; import { ExternalLink, Globe, TabletSmartphone } from "lucide-react";
import { BrowserSettings } from "./browser-settings"; import { BrowserSettings } from "./browser-settings";
import { useSettingsStore } from "@/lib/hooks/use-settings-store";
import { useEffect, useState } from "react";
import { DebugSettings } from "./debug-settings";
export function Settings() { export function Settings() {
const settingsStore = useSettingsStore();
const [debugEnabled, setDebugEnabled] = useState(false);
const clerk = useClerk(); const clerk = useClerk();
useEffect(() => {
setDebugEnabled((settingsStore.get("debug-mode") === "true") as boolean);
window.addEventListener("debug-mode-change", () => {
setDebugEnabled((settingsStore.get("debug-mode") === "true") as boolean);
});
});
return ( return (
<main className="lg:px-[10rem] px-4"> <main className="lg:px-[10rem] px-4">
<h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl mb-3"> <h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl mb-3">
@ -62,6 +74,15 @@ export function Settings() {
<TabletSmartphone size={16} /> <TabletSmartphone size={16} />
User stored settings User stored settings
</TabsTrigger> </TabsTrigger>
{debugEnabled && (
<TabsTrigger
value="debug-settings"
className="flex items-center gap-2"
>
Debug settings
</TabsTrigger>
)}
<SignedIn> <SignedIn>
<TabsTrigger <TabsTrigger
value="account-settings" value="account-settings"
@ -75,6 +96,10 @@ export function Settings() {
<TabsContent value="browser-settings"> <TabsContent value="browser-settings">
<BrowserSettings /> <BrowserSettings />
</TabsContent> </TabsContent>
<TabsContent value="debug-settings">
<DebugSettings />
</TabsContent>
<TabsContent value="user-settings"> <TabsContent value="user-settings">
<SignedOut> <SignedOut>
<Material className="mt-6 grid gap-4 py-6"> <Material className="mt-6 grid gap-4 py-6">

@ -48,34 +48,36 @@ function Alert({
<Material <Material
padding="sm" padding="sm"
className={cn( className={cn(
"flex flex-row space-x-2 items-center", "flex flex-row items-center",
variant === "error" variant === "error"
? "bg-[#fdeded_!important] dark:bg-[#160b0b_!important]" ? "bg-[#fdeded_!important] dark:bg-[#160b0b_!important]"
: variant === "warning" : variant === "warning"
? "bg-[#fef4e5_!important] dark:bg-[#191209_!important]" ? "bg-[#fef4e5_!important] dark:bg-[#191209_!important]"
: variant === "info" : variant === "info"
? "bg-[#e5f6fd_!important] dark:bg-[#091418_!important]" ? "bg-[#e5f6fd_!important] dark:bg-[#091418_!important]"
: "", : "",
className className
)} )}
> >
{icon ? ( {icon ? (
icon icon
) : ( ) : (
<CircleAlert <div className="flex items-center justify-center h-full">
size={18} <CircleAlert
className={ size={16}
variant === "error" className={
? "text-[#d76463] dark:text-[#df2317]" variant === "error"
: variant === "warning" ? "text-[#d76463] dark:text-[#df2317]"
? "text-[#eea065] dark:text-[#e3920a]" : variant === "warning"
: variant === "info" ? "text-[#eea065] dark:text-[#e3920a]"
? "text-[#67b1d5] dark:text-[#1a97e3]" : variant === "info"
: "" ? "text-[#67b1d5] dark:text-[#1a97e3]"
} : ""
/> }
)}{" "} />
<p>{children}</p> </div>
)}
<p className="flex-1">{children}</p>
</Material> </Material>
); );
} }

@ -0,0 +1,34 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
interface AnimatedTextProps {
text: string;
className?: string;
}
export function AnimatedText({ text, className }: AnimatedTextProps) {
const [currentText, setCurrentText] = useState(text);
useEffect(() => {
setCurrentText(text);
}, [text]);
return (
<div className="relative h-6 min-w-[200px] text-sm">
<AnimatePresence mode="wait">
<motion.span
key={currentText}
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -20, opacity: 0 }}
transition={{ duration: 0.3 }}
className={cn("absolute left-0", className)}
>
{currentText}
</motion.span>
</AnimatePresence>
</div>
);
}

@ -0,0 +1,353 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

@ -51,7 +51,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay ref={ref} asChild {...props}> <DialogPrimitive.Overlay ref={ref} asChild {...props}>
<motion.div <motion.div
className={cn( className={cn(
"fixed bg-[#ffffff]/50 dark:bg-black/50 inset-0 backdrop-blur-xs data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed bg-[#ffffff]/50 dark:bg-black/50 inset-0 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -95,12 +95,12 @@ const DialogContent = React.forwardRef<
ref={ref} ref={ref}
{...props} {...props}
className={cn( className={cn(
"top-[50%] left-[50%] max-h-[85vh] translate-x-[-50%] translate-y-[-50%] focus:outline-hidden", "top-[50%] left-[50%] max-h-[85vh] translate-x-[-50%] translate-y-[-50%] focus:outline-none",
"w-full border border-slate-200 border-b-slate-300", "w-full border border-slate-200 border-b-slate-300",
"dark:border-zinc-900 dark:border-t-zinc-800 dark:border-b-zinc-900", "dark:border-zinc-900 dark:border-t-zinc-800 dark:border-b-zinc-900",
"rounded-2xl max-w-lg box-border mx-auto overscroll-contain shadow-lg overflow-auto", "rounded-2xl max-w-lg box-border mx-auto overscroll-contain shadow-lg overflow-auto",
"p-5 flex flex-col gap-2 dark:bg-zinc-950 rounded-xl", "p-5 flex flex-col gap-2 dark:bg-zinc-950 rounded-xl",
"bg-white fixed", "bg-white fixed z-9",
className className
)} )}
> >

@ -0,0 +1,132 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

@ -28,7 +28,7 @@
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { useTheme } from "next-themes"; import { useTheme } from "@/lib/hooks/use-theme";
import type { SVGProps } from "react"; import type { SVGProps } from "react";
const Github = (props: SVGProps<SVGSVGElement>) => { const Github = (props: SVGProps<SVGSVGElement>) => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();

@ -44,7 +44,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"flex flex-row items-center gap-1 p-1 rounded-full bg-white/60 dark:bg-zinc-800/60 backdrop-blur-lg border border-slate-200/60 dark:border-zinc-800 shadow-lg border-opacity-50", "flex flex-row items-center gap-1 max-w-full p-1 rounded-full bg-white/60 dark:bg-zinc-800/60 backdrop-blur-lg border border-slate-200/60 dark:border-zinc-800 shadow-lg border-opacity-50 overflow-x-auto",
className className
)} )}
{...props} {...props}

@ -50,7 +50,7 @@ const TooltipContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 overflow-hidden rounded-md bg-shadcn-primary px-3 py-1.5 text-xs text-shadcn-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-120 overflow-hidden rounded-md bg-shadcn-primary px-3 py-1.5 text-xs text-shadcn-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"border dark:border-slate-200 border-zinc-800 dark:border-b-slate-300 border-t-zinc-700", "border dark:border-slate-200 border-zinc-800 dark:border-b-slate-300 border-t-zinc-700",
className className
)} )}

@ -32,7 +32,7 @@
import { ClerkProvider as ImportedClerkProvider } from "@clerk/nextjs"; import { ClerkProvider as ImportedClerkProvider } from "@clerk/nextjs";
import { dark } from "@clerk/themes"; import { dark } from "@clerk/themes";
import { useTheme } from "next-themes"; import { useTheme } from "@/lib/hooks/use-theme";
import { MultisessionAppSupport } from "@clerk/nextjs/internal"; import { MultisessionAppSupport } from "@clerk/nextjs/internal";
export const ClerkProvider = ({ children }: { children: React.ReactNode }) => { export const ClerkProvider = ({ children }: { children: React.ReactNode }) => {

@ -30,7 +30,7 @@
"use client"; "use client";
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "@/lib/hooks/use-theme";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {

@ -33,15 +33,30 @@
import * as React from "react"; import * as React from "react";
import { ThemeProvider as NextThemeProvider } from "next-themes"; import { ThemeProvider as NextThemeProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes"; import type { ThemeProviderProps } from "next-themes";
import { usePathname } from "next/navigation";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const [mounted, setMounted] = React.useState(false); const [mounted, setMounted] = React.useState(false);
const pathname = usePathname();
const [forcedDark, setForcedDark] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
setMounted(true); setMounted(true);
}, []);
window.addEventListener("force-dark-mode", () => {
setForcedDark(true);
});
});
React.useEffect(() => {
setForcedDark(false);
}, [pathname]);
if (!mounted) return null; if (!mounted) return null;
return <NextThemeProvider {...props}>{children}</NextThemeProvider>; return (
<NextThemeProvider forcedTheme={forcedDark ? "dark" : undefined} {...props}>
{children}
</NextThemeProvider>
);
} }

@ -29,7 +29,7 @@
*/ */
import type { BadgeColor } from "@/components/feat/server-list/server-card"; import type { BadgeColor } from "@/components/feat/server-list/server-card";
import { isFavorited } from "@/lib/api"; import { MHSFData } from "@/lib/types/data";
import type { OnlineServer, ServerResponse } from "@/lib/types/mh-server"; import type { OnlineServer, ServerResponse } from "@/lib/types/mh-server";
import { Cake, ServerCog } from "lucide-react"; import { Cake, ServerCog } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@ -56,6 +56,7 @@ export const allTags: Array<{
condition?: (server: { condition?: (server: {
online?: OnlineServer; online?: OnlineServer;
server?: ServerResponse; server?: ServerResponse;
mhsfData?: MHSFData;
}) => Promise<boolean>; }) => Promise<boolean>;
tooltipDesc: string; tooltipDesc: string;
htmlDocs: string; htmlDocs: string;
@ -200,12 +201,9 @@ export const allTags: Array<{
}, },
{ {
name: async (s) => "Favorited", name: async (s) => "Favorited",
condition: async (s) => { condition: async (s) =>
const favorited = await isFavorited( (s.mhsfData ?? { favoriteData: { favoritedByAccount: false } })
(s.online ?? s.server ?? { name: "" }).name .favoriteData.favoritedByAccount ?? false,
);
return favorited;
},
tooltipDesc: "This tag represents that you favorited this server.", tooltipDesc: "This tag represents that you favorited this server.",
docsName: "Favorited", docsName: "Favorited",
htmlDocs: htmlDocs:

@ -168,7 +168,7 @@ export async function isFavorited(server: string): Promise<boolean> {
export async function getAccountFavorites(): Promise<Array<string>> { export async function getAccountFavorites(): Promise<Array<string>> {
try { try {
const response = await fetch( const response = await fetch(
connector(`/favorites/account-favorites`, { version: 0 }), connector(`/user/favorites`, { version: 1 }),
{ {
method: "POST", method: "POST",
headers: { headers: {

@ -28,10 +28,10 @@
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { useTheme } from "next-themes"; import { useTheme } from "@/lib/hooks/use-theme";
export function useDepTheme() { export function useDepTheme() {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
return resolvedTheme === "dark" ? "black" : "white"; return resolvedTheme === "dark" ? "black" : "white";
} }

@ -0,0 +1,29 @@
export async function getServerQuery(selector: string) {
if (selector.length === 24) {
// Server is id;
const res = await fetch(`https://api.minehut.com/server/${selector}`);
const json = await res.json();
if (json.server === null) return null;
return { $or: [{ serverId: selector }, { server: json.server.name }] };
}
// Legacy behavior
return { server: selector };
}
export async function getServerName(selector: string) {
if (selector.length === 24) {
// Server is id
const res = await fetch(`https://api.minehut.com/server/${selector}`);
const json = await res.json();
if (json.server === null) return null;
return json.server.name;
}
return selector;
}

@ -1,13 +1,9 @@
import { useClerk } from "@clerk/nextjs"; import { useClerk } from "@clerk/nextjs";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import { getAccountFavorites } from "../api";
favoriteServer, import { useMHSFServer } from "./use-mhsf-server";
getAccountFavorites,
getCommunityServerFavorites,
isFavorited,
} from "../api";
export function useFavoriteStore(server?: string) { export function useFavoriteStore(server?: ReturnType<typeof useMHSFServer>) {
const [favorites, setFavorites] = useState<string[] | null>(null); const [favorites, setFavorites] = useState<string[] | null>(null);
const [isFavorite, setIsFavorite] = useState<boolean | null>(null); const [isFavorite, setIsFavorite] = useState<boolean | null>(null);
const [favoriteNumber, setFavoriteNumber] = useState<number | null>(null); const [favoriteNumber, setFavoriteNumber] = useState<number | null>(null);
@ -17,12 +13,20 @@ export function useFavoriteStore(server?: string) {
if (isSignedIn) { if (isSignedIn) {
getAccountFavorites().then((favorites) => setFavorites(favorites)); getAccountFavorites().then((favorites) => setFavorites(favorites));
} }
if (server) { if (
getCommunityServerFavorites(server).then((number) => server !== null &&
setFavoriteNumber(number) server?.loading === false &&
); server?.server !== null
) {
setFavoriteNumber(server.server.favoriteData.favoriteNumber);
if (isFavorite === null) { if (isFavorite === null) {
isFavorited(server).then((isFavorite) => setIsFavorite(isFavorite)); server
.reloadServerData()
.then(() =>
setIsFavorite(
server.server?.favoriteData.favoritedByAccount ?? false
)
);
} }
} }
}, [isSignedIn, server, isFavorite]); }, [isSignedIn, server, isFavorite]);
@ -38,12 +42,24 @@ export function useFavoriteStore(server?: string) {
loadingNumber: favoriteNumber === null, loadingNumber: favoriteNumber === null,
favoriteNumber, favoriteNumber,
isFavorite, isFavorite,
toggleFavorite: async (server: string) => { toggleFavorite: async () => {
if (isFavorite === null) throw new Error("Hold up lemme load rq"); if (isFavorite === null) throw new Error("Hold up lemme load rq");
if (favoriteNumber === null) throw new Error("Nah"); if (favoriteNumber === null) throw new Error("Nah");
await favoriteServer(server); const favoriteSync = await server?.favoriteServer();
// Resolve remote differences // Resolve remote differences
await server?.reloadServerData();
if (
favoriteSync?.favorited !==
(server?.server?.favoriteData.favoritedByAccount ?? false)
)
throw new Error(
"Server is not synced between server data & server favorite response."
);
setIsFavorite(server?.server?.favoriteData.favoritedByAccount ?? false);
if (isFavorite === true) { if (isFavorite === true) {
setIsFavorite(false); setIsFavorite(false);
setFavoriteNumber(favoriteNumber - 1); setFavoriteNumber(favoriteNumber - 1);
@ -53,7 +69,5 @@ export function useFavoriteStore(server?: string) {
setFavoriteNumber(favoriteNumber + 1); setFavoriteNumber(favoriteNumber + 1);
} }
}, },
getServerFavoritesNumber: async (server: string) =>
await getCommunityServerFavorites(server),
}; };
} }

@ -0,0 +1,141 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { useEffect, useState } from "react";
import type { MHSFData } from "../types/data";
import type { RouteParams } from "@/pages/api/v1/server/get/[server]";
export function useMHSFServer(id: string[], fast?: boolean) {
const [data, setData] = useState<{
[key: string]: MHSFData & RouteParams;
} | null>(null);
useEffect(() => {
if (id.length === 0) return;
(async () => {
const response = await fetch(
`/api/v1/server/bulk${fast === true ? "?noStatistics=true" : ""}`,
{
body: JSON.stringify({ servers: id }),
headers: { "Content-Type": "application/json" },
method: "POST",
}
);
const json = await response.json();
setData(json.servers);
})();
}, [id.length]);
return {
loading: data === null,
getServer: (id: string) => {
const server = data?.[id];
return {
server,
loading: false,
reloadServerData: async () => {
const response = await fetch("/api/v1/server/get/" + id);
const json = await response.json();
if (data !== null) {
const dataCopy = data;
dataCopy[id] = json.server;
setData(dataCopy);
}
},
favoriteServer: async () => {
if (!server)
throw new Error("Server hasn't initialized, cannot continue.");
const response = await fetch(server?.actions.favorite);
const json: { favorited: boolean } = await response.json();
return json;
},
ownServer: async () => {
if (!server)
throw new Error("Server hasn't initialized, cannot continue.");
const response = await fetch(server?.actions.own);
if (!response.ok)
throw new Error(
"Player doesn't own server or a server error occurred."
);
},
customizeServer: async (customization: {
colorScheme?:
| "zinc"
| "slate"
| "stone"
| "gray"
| "neutral"
| "red"
| "rose"
| "orange"
| "green"
| "blue"
| "yellow"
| "violet";
description?: string;
discord?: string;
banner?: string;
}) => {
if (!server)
throw new Error("Server hasn't initialized, cannot continue.");
const response = await fetch(server?.actions.customize, {
body: JSON.stringify({ customization }),
method: "POST",
});
if (!response.ok) throw new Error("Error while customizing server.");
},
reportServer: async (reason: string) => {
if (!server)
throw new Error("Server hasn't initialized, cannot continue.");
const response = await fetch(server.actions.report, {
body: JSON.stringify({ reason }),
method: "POST",
});
if (!response.ok)
throw new Error(
"Error while reporting server. Please email support@mhsf.app if reporting again breaks."
);
},
};
},
};
}

@ -0,0 +1,121 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { useEffect, useState } from "react";
import type { MHSFData } from "../types/data";
import type { RouteParams } from "@/pages/api/v1/server/get/[server]";
export function useMHSFServer(id: string) {
const [server, setServer] = useState<(MHSFData & RouteParams) | null>(null);
useEffect(() => {
(async () => {
const response = await fetch("/api/v1/server/get/" + id);
const json = await response.json();
console.log(json.server);
setServer(json.server);
})();
}, [id]);
return {
server,
loading: server === null,
reloadServerData: async () => {
const response = await fetch("/api/v1/server/get/" + id);
const json = await response.json();
setServer(json.server);
},
favoriteServer: async () => {
if (!server)
throw new Error("Server hasn't initialized, cannot continue.");
const response = await fetch(server?.actions.favorite);
const json: { favorited: boolean } = await response.json();
return json;
},
ownServer: async () => {
if (!server)
throw new Error("Server hasn't initialized, cannot continue.");
const response = await fetch(server?.actions.own);
if (!response.ok)
throw new Error(
"Player doesn't own server or a server error occurred."
);
},
customizeServer: async (customization: {
colorScheme?:
| "zinc"
| "slate"
| "stone"
| "gray"
| "neutral"
| "red"
| "rose"
| "orange"
| "green"
| "blue"
| "yellow"
| "violet";
description?: string;
discord?: string;
banner?: string;
}) => {
if (!server)
throw new Error("Server hasn't initialized, cannot continue.");
const response = await fetch(server?.actions.customize, {
body: JSON.stringify({ customization }),
method: "POST",
});
if (!response.ok) throw new Error("Error while customizing server.");
},
reportServer: async (reason: string) => {
if (!server)
throw new Error("Server hasn't initialized, cannot continue.");
const response = await fetch(server.actions.report, {
body: JSON.stringify({ reason }),
method: "POST",
});
if (!response.ok)
throw new Error(
"Error while reporting server. Please email support@mhsf.app if reporting again breaks."
);
},
};
}

@ -1,11 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import type { OnlineServer } from "../types/mh-server"; import type { OnlineServer, ServerResponse } from "../types/mh-server";
import { useEffectOnce } from "../useEffectOnce"; import { useEffectOnce } from "../useEffectOnce";
export function useServer(serverSpecifier: { id?: string; name?: string }) { export function useServer(serverSpecifier: { id?: string; name?: string }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [server, setServer] = useState<OnlineServer | null>(null); const [server, setServer] = useState<ServerResponse | null>(null);
useEffectOnce(() => { useEffectOnce(() => {
try { try {

@ -0,0 +1,10 @@
import { useTheme as useLibTheme } from "next-themes";
export function useTheme() {
const theme = useLibTheme();
return {
...theme,
resolvedTheme: theme.forcedTheme ?? theme.resolvedTheme,
};
}

@ -1,127 +0,0 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { NextApiRequest, NextApiResponse } from "next";
import { clerkClient, getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import { OnlineServer } from "@/lib/types/mh-server";
import { waitUntil } from "@vercel/functions";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { userId } = getAuth(req);
const { server } = req.body;
if (server == null) {
res.status(400).send({ message: "Couldn't find data" });
return;
}
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
if (
(await (await clerkClient()).users.getUser(userId)).publicMetadata.player ==
undefined
) {
return res.status(401).json({ error: "Account not linked" });
}
const client = new MongoClient(process.env.MONGO_DB as string);
await client.connect();
const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const collection = db.collection("owned-servers");
if ((await collection.findOne({ server: server })) == undefined) {
const mh = await fetch(
process.env.MHSF_BACKEND_API_LOCATION ??
"https://api.minehut.com/servers",
{
headers: {
accept: "*/*",
"accept-language": Math.random().toString(),
priority: "u=1, i",
"sec-ch-ua": '"Not/A)Brand";v="8", "Chromium";v="126"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "cross-site",
Referer: "http://localhost:3000/",
"Referrer-Policy": "strict-origin-when-cross-origin",
Authentication: `MHSF-Backend-Server ${process.env.MHSF_BACKEND_API_LOCATION ? process.env.MHSF_BACKEND_SECRET : "Sorry Minehut Devs."}`,
},
body: null,
method: "GET",
},
);
const servers: Array<OnlineServer> = (await mh.json()).servers;
servers.forEach(async (c, i) => {
if (c.name === server) {
const MCUsername = (await (await clerkClient()).users.getUser(userId))
.publicMetadata.player;
if (MCUsername === c.author) {
await collection.insertOne({ server, author: userId });
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.send({ message: "Successfully owned server!" });
} else {
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res
.status(400)
.send({ message: "The linked account doesn't own the server." });
}
}
if (i == servers.length) {
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.status(400).send({ message: "The server needs to be online." });
}
});
} else {
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.status(400).send({ message: "This server has already been owned." });
}
}

@ -1,105 +0,0 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import type { NextApiResponse, NextApiRequest } from "next";
import { MongoClient, ObjectId } from "mongodb";
import { getAuth } from "@clerk/nextjs/server";
import { decreaseNum, increaseNum } from "./community-favorites";
import { waitUntil } from "@vercel/functions";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { userId } = getAuth(req);
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
const server = req.query.server as string;
const client = new MongoClient(process.env.MONGO_DB as string);
await client.connect();
const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const collection = db.collection("favorites");
const find = await collection.find({ user: userId }).toArray();
if (find.length === 0) {
collection.insertOne({ user: userId, favorites: [server] });
await increaseNum(client, server);
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.send({ message: "Favorited " + server });
} else {
const collect = find[0];
let existingFavorites: Array<string> = collect.favorites;
console.log(collect);
if (existingFavorites.includes(server)) {
// remove that favorite from the list
const index = existingFavorites.indexOf(server);
await decreaseNum(client, server);
if (index > -1) {
existingFavorites.splice(index, 1);
}
await collection.replaceOne(
{ _id: new ObjectId(collect._id) },
{
user: userId,
favorites: existingFavorites,
},
);
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.send({ message: "Unfavorited " + server });
} else {
existingFavorites.push(server);
await increaseNum(client, server);
await collection.replaceOne(
{ _id: new ObjectId(collect._id) },
{
user: userId,
favorites: existingFavorites,
},
);
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.send({ message: "Favorited " + server });
}
}
}

@ -1,77 +0,0 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { MongoClient } from "mongodb";
import { NextApiRequest, NextApiResponse } from "next";
import { waitUntil } from "@vercel/functions";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const client = new MongoClient(process.env.MONGO_DB as string);
const db = client.db("mhsf").collection("history");
const server = req.query.server as string;
const daysOfWeek = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const result = await Promise.all(
[1, 2, 3, 4, 5, 6, 7].map(async (c) => {
const results = await db
.find({
$and: [{ server }, { $expr: { $eq: [{ $dayOfWeek: "$date" }, c] } }],
})
.toArray();
if (results.length !== 0) {
const averageNums = (results as any as { player_count: number }[]).map(
(x: { player_count: number }) => x.player_count,
);
const average =
averageNums.reduce((sum, val) => sum + val, 0) / averageNums.length;
return { day: daysOfWeek[c - 1], result: Math.floor(average) };
}
return undefined;
}),
);
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.send({ result: result.filter((c) => c !== undefined) });
}

@ -1,90 +0,0 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { MongoClient } from "mongodb";
import { NextApiRequest, NextApiResponse } from "next";
import { waitUntil } from "@vercel/functions";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const client = new MongoClient(process.env.MONGO_DB as string);
const db = client.db("mhsf").collection("history");
const server = req.query.server as string;
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const result = await Promise.all(
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map(async (c) => {
const results = await db
.find({
$and: [
{ server },
{
date: {
$gte: new Date(new Date().getFullYear(), c - 1, 1),
$lt: new Date(new Date().getFullYear(), c, 1),
},
},
],
})
.toArray();
if (results.length !== 0) {
const averageNums = (results as any as { player_count: number }[]).map(
(x: { player_count: number }) => x.player_count,
);
const average =
averageNums.reduce((sum, val) => sum + val, 0) / averageNums.length;
return { month: months[c - 1], result: Math.floor(average) };
}
return undefined;
}),
);
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.send({ result: result.filter((c) => c !== undefined) });
}

@ -31,6 +31,7 @@
import type { MHSFData } from "@/lib/types/data"; import type { MHSFData } from "@/lib/types/data";
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { RouteParams } from "./get/[server]";
// Type definitions for query parameters // Type definitions for query parameters
type QueryParams = { type QueryParams = {
@ -43,6 +44,7 @@ type QueryParams = {
maxAchievementEntries?: string | string[]; maxAchievementEntries?: string | string[];
achievementTimespanStart?: string | string[]; achievementTimespanStart?: string | string[];
achievementTimespanEnd?: string | string[]; achievementTimespanEnd?: string | string[];
noStatistics?: string | string[];
}; };
// Type for customization data // Type for customization data
@ -91,8 +93,10 @@ export default async function handler(
return res.status(400).json({ servers: {} }); return res.status(400).json({ servers: {} });
} }
let serverList = servers;
// Limit the number of servers to prevent abuse (max 25 servers per request) // Limit the number of servers to prevent abuse (max 25 servers per request)
const serverList = servers.slice(0, 25); if (req.query.noStatistics !== "true") serverList = servers.slice(0, 25);
// Extract query parameters // Extract query parameters
const queryOptions: QueryParams = { const queryOptions: QueryParams = {
@ -105,6 +109,7 @@ export default async function handler(
maxAchievementEntries: req.query.maxAchievementEntries, maxAchievementEntries: req.query.maxAchievementEntries,
achievementTimespanStart: req.query.achievementTimespanStart, achievementTimespanStart: req.query.achievementTimespanStart,
achievementTimespanEnd: req.query.achievementTimespanEnd, achievementTimespanEnd: req.query.achievementTimespanEnd,
noStatistics: req.query.noStatistics,
}; };
// Determine which data to fetch based on options // Determine which data to fetch based on options
@ -158,7 +163,7 @@ export default async function handler(
); );
} }
if (fetchOptions.players) { if (fetchOptions.players && queryOptions.noStatistics !== "true") {
promises.push( promises.push(
findPlayerData(serverData.name, db, queryOptions).then( findPlayerData(serverData.name, db, queryOptions).then(
(data: PlayerData) => { (data: PlayerData) => {
@ -168,7 +173,10 @@ export default async function handler(
); );
} }
if (fetchOptions.achievements) { if (
fetchOptions.achievements &&
queryOptions.noStatistics !== "true"
) {
promises.push( promises.push(
findAchievements(serverData.name, db, queryOptions).then( findAchievements(serverData.name, db, queryOptions).then(
(data: AchievementsData) => { (data: AchievementsData) => {
@ -182,7 +190,7 @@ export default async function handler(
await Promise.all(promises); await Promise.all(promises);
// Create default values for any missing data // Create default values for any missing data
const serverResult: MHSFData = { const serverResult: MHSFData & RouteParams = {
favoriteData: promiseResults.favoriteData || { favoriteData: promiseResults.favoriteData || {
favoritedByAccount: null, favoritedByAccount: null,
favoriteNumber: 0, favoriteNumber: 0,
@ -205,6 +213,18 @@ export default async function handler(
historically: [], historically: [],
currently: [], currently: [],
}, },
actions: {
history: {
dailyData: `/api/v1/server/get/${server}/history/daily-data`,
monthlyData: `/api/v1/server/get/${server}/history/monthly-data`,
relativeData: `/api/v1/server/get/${server}/history/relative-data`,
historicalData: `/api/v1/server/get/${server}/history/historical-data`,
},
favorite: `/api/v1/server/get/${server}/favorite-server`,
customize: `/api/v1/server/get/${server}/customize`,
own: `/api/v1/server/get/${server}/own-server`,
report: `/api/v1/server/get/${server}/report-server`,
},
}; };
result[server] = serverResult; result[server] = serverResult;
@ -287,7 +307,9 @@ async function findFavoriteData(
const [userFavorites, metaData, historyData] = await Promise.all([ const [userFavorites, metaData, historyData] = await Promise.all([
userId ? db.collection("favorites").findOne({ user: userId }) : null, userId ? db.collection("favorites").findOne({ user: userId }) : null,
db.collection("meta").findOne({ server: serverName }), db.collection("meta").findOne({ server: serverName }),
fetchHistoryData(db, serverName, query), query.noStatistics !== "true"
? fetchHistoryData(db, serverName, query)
: [],
]); ]);
// Process user favorites // Process user favorites

@ -0,0 +1,121 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import type { NextApiResponse, NextApiRequest } from "next";
import { MongoClient } from "mongodb";
import { getAuth } from "@clerk/nextjs/server";
import { waitUntil } from "@vercel/functions";
import { getServerName } from "@/lib/history-util";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { userId } = getAuth(req);
if (!userId) return res.status(401).json({ error: "Unauthorized" });
const server = await getServerName(req.query.server as string);
const client = new MongoClient(process.env.MONGO_DB as string);
if (!server) {
return res.status(400).json({ error: "Server not provided" });
}
try {
await client.connect();
const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const favoritesCollection = db.collection("favorites");
// Use findOne instead of find().toArray() since we only need one document
const userFavorites = await favoritesCollection.findOne({ user: userId });
if (!userFavorites) {
// Use insertOne with { w: 1 } for write acknowledgment
await favoritesCollection.insertOne(
{ user: userId, favorites: [server] },
{ w: 1 } as any
);
await increaseNum(client, server as string);
return res.send({ favorited: true });
}
const existingFavorites = userFavorites.favorites;
const isFavorite = existingFavorites.includes(server);
// Update favorites array
const updatedFavorites = isFavorite
? existingFavorites.filter((fav: any) => fav !== server)
: [...existingFavorites, server];
// Use updateOne instead of replaceOne for better performance
await favoritesCollection.updateOne(
{ _id: userFavorites._id },
{ $set: { favorites: updatedFavorites } }
);
// Update favorite count
isFavorite
? await decreaseNum(client, server as string)
: await increaseNum(client, server as string);
res.send({ favorited: !isFavorite });
} catch (error) {
console.error(error);
res.status(500).json({ error: "Internal Server Error" });
} finally {
// Ensure client is always closed
waitUntil(client.close());
}
}
// Optimized helper functions
export async function increaseNum(client: MongoClient, server: string) {
const db = client.db("mhsf");
const collection = db.collection("meta");
// Use $inc operator for atomic increment
await collection.updateOne(
{ server },
{ $inc: { favorites: 1 }, $set: { date: new Date() } },
{ upsert: true }
);
}
export async function decreaseNum(client: MongoClient, server: string) {
const db = client.db("mhsf");
const collection = db.collection("meta");
// Use $inc operator for atomic decrement
await collection.updateOne(
{ server },
{ $inc: { favorites: -1 } },
{ upsert: true }
);
}

@ -0,0 +1,103 @@
/*
* MHSF, Minehut Server List
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import type { NextApiRequest, NextApiResponse } from "next";
import { MongoClient as MongoClientImpl } from "mongodb";
import { getServerQuery } from "@/lib/history-util";
interface DailyAverage {
day: string;
result: number;
}
interface ResponseData {
result: DailyAverage[];
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData | { message: string }>
) {
const client = new MongoClientImpl(process.env.MONGO_DB as string);
try {
const db = client.db("mhsf").collection("history");
const server = await getServerQuery(req.query.server as string);
const daysOfWeek = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
if (server === null)
return res.status(400).json({ message: "Invalid server query" });
// Convert $or query to separate find operations if needed
const matchStage = server.$or
? {
$match: {
$or: server.$or,
},
}
: { $match: server };
// Use MongoDB aggregation pipeline for better performance
const dailyAverages = (await db
.aggregate([
matchStage,
{
$group: {
_id: { $dayOfWeek: "$date" },
averagePlayerCount: { $avg: "$player_count" },
},
},
{
$project: {
_id: 0,
day: { $arrayElemAt: [daysOfWeek, { $subtract: ["$_id", 1] }] },
result: { $floor: "$averagePlayerCount" },
},
},
{
$sort: { _id: 1 },
},
])
.toArray()) as DailyAverage[];
res.send({ result: dailyAverages });
} catch (error) {
console.log(error);
res.status(500).json({ message: "An error occurred while fetching data" });
} finally {
await client.close();
}
}

@ -0,0 +1,93 @@
/*
* MHSF, Minehut Server List
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import type { NextApiRequest, NextApiResponse } from "next";
import { MongoClient as MongoClientImpl } from "mongodb";
import { getServerQuery } from "@/lib/history-util";
// Define types for our data
interface ServerHistoricalRecord {
server: string;
[key: string]: unknown;
}
interface ResponseData {
data: Record<string, unknown>[];
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData | { message: string }>
) {
const client = new MongoClientImpl(process.env.MONGO_DB as string);
try {
const db = client.db("mhsf").collection("historical");
const server = await getServerQuery(req.query.server as string);
const scopes: string[] = checkForInfoOrLeave(res, req.body.scopes);
if (server === null)
return res.status(400).json({ message: "Invalid server query" });
// Only fetch the fields we need using projection
const projection: Record<string, 1> = { server: 1 };
for (const scope of scopes) {
projection[scope] = 1;
}
const allData = await db
.find<ServerHistoricalRecord>({ server }, { projection })
.toArray();
// Use map instead of forEach for better performance
const data = allData.map((d) => {
const result: Record<string, unknown> = {};
for (const scope of scopes) {
result[scope] = d[scope];
}
return result;
});
res.send({ data });
} catch (error) {
res.status(500).json({ message: "An error occurred while fetching data" });
} finally {
await client.close();
}
}
function checkForInfoOrLeave(
res: NextApiResponse,
info: string[] | undefined
): string[] {
if (info === undefined) {
res.status(400).json({ message: "Information wasn't supplied" });
return [];
}
return info;
}

@ -0,0 +1,119 @@
/*
* MHSF, Minehut Server List
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import type { NextApiRequest, NextApiResponse } from "next";
import { MongoClient as MongoClientImpl } from "mongodb";
import { getServerQuery } from "@/lib/history-util";
interface MonthlyAverage {
month: string;
result: number;
}
interface ResponseData {
result: MonthlyAverage[];
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData | { message: string }>
) {
const client = new MongoClientImpl(process.env.MONGO_DB as string);
try {
const db = client.db("mhsf").collection("history");
const server = await getServerQuery(req.query.server as string);
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const currentYear = new Date().getFullYear();
if (server === null)
return res.status(400).json({ message: "Invalid server query" });
// Convert $or query to separate find operations if needed
const matchStage = server.$or
? {
$match: {
$or: server.$or,
date: {
$gte: new Date(currentYear, 0, 1),
$lt: new Date(currentYear + 1, 0, 1),
},
},
}
: {
$match: server,
date: {
$gte: new Date(currentYear, 0, 1),
$lt: new Date(currentYear + 1, 0, 1),
},
};
// Use MongoDB aggregation pipeline for better performance
const monthlyAverages = (await db
.aggregate([
matchStage,
{
$group: {
_id: { $month: "$date" },
averagePlayerCount: { $avg: "$player_count" },
},
},
{
$project: {
_id: 0,
month: { $arrayElemAt: [months, { $subtract: ["$_id", 1] }] },
result: { $floor: "$averagePlayerCount" },
},
},
{
$sort: { _id: 1 },
},
])
.toArray()) as MonthlyAverage[];
res.send({ result: monthlyAverages });
} catch (error) {
res.status(500).json({ message: "An error occurred while fetching data" });
} finally {
await client.close();
}
}

@ -0,0 +1,122 @@
/*
* MHSF, Minehut Server List
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import type { NextApiRequest, NextApiResponse } from "next";
import { MongoClient as MongoClientImpl } from "mongodb";
import { getServerQuery } from "@/lib/history-util";
interface ServerHistoryRecord {
server: string;
player_count: number;
date: Date;
}
interface MHRecord {
total_players: number;
date: Date;
}
interface RelativeData {
relativePrecentage: number;
date: Date;
}
interface ResponseData {
data: RelativeData[];
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData | { message: string }>
) {
const client = new MongoClientImpl(process.env.MONGO_DB as string);
try {
const db = client.db("mhsf").collection("history");
const mh = client.db("mhsf").collection("mh");
const server = await getServerQuery(req.query.server as string);
if (server === undefined)
return res.status(400).json({ message: "Invalid server query" });
// Handle both $or and simple server queries
const findQuery = server?.$or ? { $or: server.$or } : server;
// Get only the last 30 records with needed fields
const recentData = await db
.find<ServerHistoryRecord>(findQuery ?? {}, {
projection: { player_count: 1, date: 1 },
sort: { date: -1 },
limit: 30,
})
.toArray();
const data: RelativeData[] = [];
// Process in batches to reduce the number of database queries
const batchSize = 5;
for (let i = 0; i < recentData.length; i += batchSize) {
const batch = recentData.slice(i, i + batchSize);
const batchQueries = batch.map(async (d) => {
const dateOfEntry = new Date(d.date);
const hourBefore = new Date(dateOfEntry.getTime() - 1000 * 60 * 60);
const hourAfter = new Date(dateOfEntry.getTime() + 1000 * 60 * 60);
const result = await mh.findOne<MHRecord>(
{
date: {
$gte: hourBefore,
$lt: hourAfter,
},
},
{ projection: { total_players: 1, date: 1 } }
);
if (result) {
return {
relativePrecentage: d.player_count / result.total_players,
date: dateOfEntry,
};
}
return null;
});
const batchResults = await Promise.all(batchQueries);
data.push(
...batchResults.filter((item): item is RelativeData => item !== null)
);
}
res.send({ data });
} catch (error) {
console.log(error);
res.status(500).json({ message: "An error occurred while fetching data" });
} finally {
await client.close();
}
}

@ -32,9 +32,24 @@ import type { MHSFData } from "@/lib/types/data";
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
export type RouteParams = {
actions: {
favorite: string;
customize: string;
own: string;
report: string;
history: {
dailyData: string;
monthlyData: string;
relativeData: string;
historicalData: string;
};
};
};
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse<{ server: MHSFData | null }> res: NextApiResponse<{ server: (MHSFData & RouteParams) | null }>
) { ) {
const { const {
server, server,
@ -81,13 +96,24 @@ export default async function handler(
}), }),
]); ]);
// Ignore the linter error as requested
res.send({ res.send({
server: { server: {
favoriteData, favoriteData,
customizationData, customizationData,
playerData, playerData,
achievements, achievements,
actions: {
history: {
dailyData: `/api/v1/server/get/${server}/history/daily-data`,
monthlyData: `/api/v1/server/get/${server}/history/monthly-data`,
relativeData: `/api/v1/server/get/${server}/history/relative-data`,
historicalData: `/api/v1/server/get/${server}/history/historical-data`,
},
favorite: `/api/v1/server/get/${server}/favorite-server`,
customize: `/api/v1/server/get/${server}/customize`,
own: `/api/v1/server/get/${server}/own-server`,
report: `/api/v1/server/get/${server}/report-server`,
},
}, },
}); });
} catch (error) { } catch (error) {

@ -0,0 +1,127 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { NextApiRequest, NextApiResponse } from "next";
import { clerkClient, getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import { OnlineServer } from "@/lib/types/mh-server";
import { waitUntil } from "@vercel/functions";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { userId } = getAuth(req);
const { server } = req.query;
if (server == null) {
res.status(400).send({ message: "Couldn't find data" });
return;
}
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
if (
(await (await clerkClient()).users.getUser(userId)).publicMetadata.player ==
undefined
) {
return res.status(401).json({ error: "Account not linked" });
}
const client = new MongoClient(process.env.MONGO_DB as string);
await client.connect();
const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const collection = db.collection("owned-servers");
if ((await collection.findOne({ server: server })) == undefined) {
const mh = await fetch(
process.env.MHSF_BACKEND_API_LOCATION ??
"https://api.minehut.com/servers",
{
headers: {
accept: "*/*",
"accept-language": Math.random().toString(),
priority: "u=1, i",
"sec-ch-ua": '"Not/A)Brand";v="8", "Chromium";v="126"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "cross-site",
Referer: "http://localhost:3000/",
"Referrer-Policy": "strict-origin-when-cross-origin",
Authentication: `MHSF-Backend-Server ${process.env.MHSF_BACKEND_API_LOCATION ? process.env.MHSF_BACKEND_SECRET : "Sorry Minehut Devs."}`,
},
body: null,
method: "GET",
}
);
const servers: Array<OnlineServer> = (await mh.json()).servers;
servers.forEach(async (c, i) => {
if (c.name === server) {
const MCUsername = (await (await clerkClient()).users.getUser(userId))
.publicMetadata.player;
if (MCUsername === c.author) {
await collection.insertOne({ server, author: userId });
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.send({ message: "Successfully owned server!" });
} else {
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res
.status(400)
.send({ message: "The linked account doesn't own the server." });
}
}
if (i == servers.length) {
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.status(400).send({ message: "The server needs to be online." });
}
});
} else {
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.status(400).send({ message: "This server has already been owned." });
}
}

@ -31,53 +31,53 @@
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { getAuth } from "@clerk/nextjs/server"; import { getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import { inngest } from "../inngest"; import { inngest } from "@/pages/api/inngest";
import { waitUntil } from "@vercel/functions"; import { waitUntil } from "@vercel/functions";
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse, res: NextApiResponse
) { ) {
const { userId } = getAuth(req); const { userId } = getAuth(req);
const { server } = req.body; const { server } = req.query;
if (server == null) { if (server == null) {
res.status(400).send({ message: "Couldn't find data" }); res.status(400).send({ message: "Couldn't find data" });
return; return;
} }
const { reason } = req.body; const { reason } = req.body;
if (reason == null) { if (reason == null) {
res.status(400).send({ message: "Couldn't find data" }); res.status(400).send({ message: "Couldn't find data" });
return; return;
} }
if (!userId) { if (!userId) {
return res.status(401).json({ error: "Unauthorized" }); return res.status(401).json({ error: "Unauthorized" });
} }
const client = new MongoClient(process.env.MONGO_DB as string); const client = new MongoClient(process.env.MONGO_DB as string);
await client.connect(); await client.connect();
const db = client.db("mhsf"); const db = client.db("mhsf");
const collection = db.collection("reports"); const collection = db.collection("reports");
const entry = await collection.insertOne({ const entry = await collection.insertOne({
server: server, server: server,
reason: reason, reason: reason,
userId: userId, userId: userId,
}); });
// Don't wait for this to finish, just continue anyway // Don't wait for this to finish, just continue anyway
inngest.send({ inngest.send({
name: "report-server", name: "report-server",
data: { data: {
_id: entry.insertedId.toString(), _id: entry.insertedId.toString(),
server, server,
reason, reason,
userId, userId,
}, },
}); });
// Close the database, but don't close this // Close the database, but don't close this
// serverless instance until it happens // serverless instance until it happens
waitUntil(client.close()); waitUntil(client.close());
res.send({ msg: "Successfully reported server!" }); res.send({ msg: "Successfully reported server!" });
} }

@ -34,28 +34,28 @@ import { getAuth } from "@clerk/nextjs/server";
import { waitUntil } from "@vercel/functions"; import { waitUntil } from "@vercel/functions";
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse, res: NextApiResponse
) { ) {
const { userId } = getAuth(req); const { userId } = getAuth(req);
if (!userId) { if (!userId) {
return res.status(401).json({ error: "Unauthorized" }); return res.status(401).json({ error: "Unauthorized" });
} }
const client = new MongoClient(process.env.MONGO_DB as string); const client = new MongoClient(process.env.MONGO_DB as string);
await client.connect(); await client.connect();
const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf"); const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const collection = db.collection("favorites"); const collection = db.collection("favorites");
const find = await collection.find({ user: userId }).toArray(); const find = await collection.find({ user: userId }).toArray();
// Close the database, but don't close this // Close the database, but don't close this
// serverless instance until it happens // serverless instance until it happens
waitUntil(client.close()); waitUntil(client.close());
if (find.length == 0) { if (find.length == 0) {
res.send({ result: [] }); res.send({ favorites: [] });
} else { } else {
res.send({ result: find[0].favorites }); res.send({ favorites: find[0].favorites });
} }
} }

@ -1741,6 +1741,14 @@
resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e" resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e"
integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==
"@number-flow/react@^0.5.7":
version "0.5.7"
resolved "https://registry.yarnpkg.com/@number-flow/react/-/react-0.5.7.tgz#4e4bd997e5cf434e749ec1763d1723f4cd7fda5b"
integrity sha512-Fm1ZTUx5SYFZcJ3NjNKzC513Sq0XbU//X1yIEJ33MLBNyff+S16akGvez1zD1EjBHbgRGUyyNKQP6U8u0cszvA==
dependencies:
esm-env "^1.1.4"
number-flow "0.5.5"
"@opentelemetry/api-logs@0.39.1": "@opentelemetry/api-logs@0.39.1":
version "0.39.1" version "0.39.1"
resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.39.1.tgz#3ea1e9dda11c35f993cb60dc5e52780b8175e702" resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.39.1.tgz#3ea1e9dda11c35f993cb60dc5e52780b8175e702"
@ -2181,7 +2189,7 @@
aria-hidden "^1.1.1" aria-hidden "^1.1.1"
react-remove-scroll "2.5.5" react-remove-scroll "2.5.5"
"@radix-ui/react-dialog@^1.1.1", "@radix-ui/react-dialog@^1.1.2": "@radix-ui/react-dialog@^1.1.1", "@radix-ui/react-dialog@^1.1.2", "@radix-ui/react-dialog@^1.1.6":
version "1.1.6" version "1.1.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz#65b4465e99ad900f28a98eed9a94bb21ec644bf7" resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz#65b4465e99ad900f28a98eed9a94bb21ec644bf7"
integrity sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw== integrity sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==
@ -5919,6 +5927,11 @@ eslint@^9:
natural-compare "^1.4.0" natural-compare "^1.4.0"
optionator "^0.9.3" optionator "^0.9.3"
esm-env@^1.1.4:
version "1.2.2"
resolved "https://registry.yarnpkg.com/esm-env/-/esm-env-1.2.2.tgz#263c9455c55861f41618df31b20cb571fc20b75e"
integrity sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==
espree@^10.0.1, espree@^10.3.0: espree@^10.0.1, espree@^10.3.0:
version "10.3.0" version "10.3.0"
resolved "https://registry.yarnpkg.com/espree/-/espree-10.3.0.tgz#29267cf5b0cb98735b65e64ba07e0ed49d1eed8a" resolved "https://registry.yarnpkg.com/espree/-/espree-10.3.0.tgz#29267cf5b0cb98735b65e64ba07e0ed49d1eed8a"
@ -8095,16 +8108,16 @@ lru-cache@^7.14.1:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
lucide-react@^0.454.0:
version "0.454.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.454.0.tgz#a81b9c482018720f07ead0503ae502d94d528444"
integrity sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==
lucide-react@^0.474.0: lucide-react@^0.474.0:
version "0.474.0" version "0.474.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.474.0.tgz#9fcaa96250fa2de0b3e2803d4ad744eaea572247" resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.474.0.tgz#9fcaa96250fa2de0b3e2803d4ad744eaea572247"
integrity sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA== integrity sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==
lucide-react@^0.479.0:
version "0.479.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.479.0.tgz#7321f979a389ec5dd86747b2deb6444cf0922f8d"
integrity sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ==
luxon@~3.5.0: luxon@~3.5.0:
version "3.5.0" version "3.5.0"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20" resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20"
@ -9611,6 +9624,13 @@ nprogress@^0.2.0:
resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1" resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1"
integrity sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA== integrity sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==
number-flow@0.5.5:
version "0.5.5"
resolved "https://registry.yarnpkg.com/number-flow/-/number-flow-0.5.5.tgz#955dc8b98d0e5a3a6367c019c347f577e462a612"
integrity sha512-oE+gyA3S0ar8un2dg80TlEi3hjvi/UnTewHl2bu9dGKMxU7nT8VTUdIf1X7NbRLslqyyTyxdSmIAv/QJhaq1pw==
dependencies:
esm-env "^1.1.4"
nuqs@^2.4.1: nuqs@^2.4.1:
version "2.4.1" version "2.4.1"
resolved "https://registry.yarnpkg.com/nuqs/-/nuqs-2.4.1.tgz#dfc7ac4eb0f2d3fa55e5b922d02e08612c59cf2f" resolved "https://registry.yarnpkg.com/nuqs/-/nuqs-2.4.1.tgz#dfc7ac4eb0f2d3fa55e5b922d02e08612c59cf2f"
@ -10512,7 +10532,7 @@ recharts-scale@^0.4.4:
dependencies: dependencies:
decimal.js-light "^2.4.1" decimal.js-light "^2.4.1"
recharts@^2.12.7, recharts@^2.15.1: recharts@^2.15.1:
version "2.15.1" version "2.15.1"
resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.15.1.tgz#0941adf0402528d54f6d81997eb15840c893aa3c" resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.15.1.tgz#0941adf0402528d54f6d81997eb15840c893aa3c"
integrity sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q== integrity sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==
@ -12495,13 +12515,6 @@ vary@^1, vary@~1.1.2:
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
vaul@^0.9.1:
version "0.9.9"
resolved "https://registry.yarnpkg.com/vaul/-/vaul-0.9.9.tgz#ff075c3cba6193d4859bb6f1b09efcce049cf812"
integrity sha512-7afKg48srluhZwIkaU+lgGtFCUsYBSGOl8vcc8N/M3YQlZFlynHD15AE+pwrYdc826o7nrIND4lL9Y6b9WWZZQ==
dependencies:
"@radix-ui/react-dialog" "^1.1.1"
vaul@^1.1.2: vaul@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.2.tgz#c959f8b9dc2ed4f7d99366caee433fbef91f5ba9" resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.2.tgz#c959f8b9dc2ed4f7d99366caee433fbef91f5ba9"