Compare commits

..

3 Commits

Author SHA1 Message Date
dvelo
7f77a4273c fix: discord name if no global 2025-05-21 21:17:52 -05:00
dvelo
166cca6931 feat: bump version 2025-05-21 20:56:33 -05:00
dvelo
3e1f94bf78 feat: v2 beta 2025-05-21 20:45:21 -05:00
41 changed files with 1404 additions and 356 deletions

@ -1,3 +1,6 @@
{
"copyright-header-injector.copyrightText": "/*\n * MHSF, Minehut Server List\n * All external content is rather licensed under the ECA Agreement\n * located here: https://mhsf.app/docs/legal/external-content-agreement\n *\n * All code under MHSF is licensed under the MIT License\n * by open source contributors\n *\n * Copyright (c) 2025 dvelo\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to\n * deal in the Software without restriction, including without limitation the\n * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n * sell copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\n * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\n * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\n * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\n * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\n * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\n * OTHER DEALINGS IN THE SOFTWARE.\n */"
"copyright-header-injector.copyrightText": "/*\n * MHSF, Minehut Server List\n * All external content is rather licensed under the ECA Agreement\n * located here: https://mhsf.app/docs/legal/external-content-agreement\n *\n * All code under MHSF is licensed under the MIT License\n * by open source contributors\n *\n * Copyright (c) 2025 dvelo\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to\n * deal in the Software without restriction, including without limitation the\n * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n * sell copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\n * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\n * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\n * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\n * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\n * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\n * OTHER DEALINGS IN THE SOFTWARE.\n */",
"cSpell.words": [
"MHSF"
]
}

@ -51,6 +51,10 @@ const nextConfig = {
hostname: "exh89c9lva.ufs.sh",
pathname: "/f/*",
},
{
protocol: "https",
hostname: "api.mineatar.io"
}
],
},
async redirects() {

@ -68,7 +68,7 @@
"framer-motion": "^12.7.4",
"github-slugger": "^2.0.0",
"inngest": "^3.21.2",
"input-otp": "^1.2.4",
"input-otp": "^1.4.2",
"json-beautify": "^1.1.1",
"lodash": "^4.17.21",
"lucide-react": "^0.487.0",

@ -44,7 +44,6 @@ export default function RootLayout({
}: {
children: React.ReactNode;
}) {
return (
<>
<ThemeProvider
@ -56,6 +55,7 @@ export default function RootLayout({
<ClerkProvider>
<IsScript>
<NuqsAdapter>
<div vaul-drawer-wrapper="">
<FontBoundary>
<TooltipProvider>
<Toaster richColors position="top-center" />
@ -66,6 +66,7 @@ export default function RootLayout({
</ClerkProvider>
</TooltipProvider>
</FontBoundary>
</div>
</NuqsAdapter>
</IsScript>
</ClerkProvider>

@ -50,12 +50,12 @@ export default function ModificationPage({
defaultValue: "/servers/embedded/sl-modification-frame",
});
const categoryObj = serverModDB.find(
(c) => c.displayTitle === atob(decodeURIComponent(category))
(c) => c.displayTitle === atob(decodeURIComponent(category)),
);
let modObj = null;
if (categoryObj !== undefined)
modObj = categoryObj?.entries.find(
(c) => c.name === atob(decodeURIComponent(mod))
(c) => c.name === atob(decodeURIComponent(mod)),
);
return (
@ -71,7 +71,9 @@ export default function ModificationPage({
<span className="p-4">
<h1 className="text-xl font-bold w-full">{modObj?.name}</h1>
<Markdown className="text-wrap pt-2">{modObj?.description}</Markdown>
<div className="text-wrap pt-2">
<Markdown>{modObj?.description}</Markdown>
</div>
<ModificationAction value={modObj?.value} />
</span>
</main>

@ -115,10 +115,12 @@ export default function ModificationPage({
<span className="p-4">
<h1 className="text-xl font-bold w-full">{modObj?.friendlyName}</h1>
<Markdown className="text-wrap pt-2">
This is a custom modification. Enable it! (or not) It's your own! (are
you proud?)
<div className="text-wrap pt-2">
<Markdown>
This is a custom modification. Enable it! (or not) It's your own!
(are you proud?)
</Markdown>
</div>
<div className="flex justify-between items-center">
<Button
className="mt-2"

@ -49,11 +49,9 @@ export default function ServerListModificationFrame() {
<main className=" p-4">
<h1 className="text-xl font-bold w-full">Filters & Sorting</h1>
<div className="flex items-center gap-2 my-2">
<Button size="sm">Active modifications</Button>
<Link href="/servers/embedded/sl-modification-frame/files">
<Button size="sm">Custom files</Button>
</Link>
<Button size="sm">Settings</Button>
</div>
<span className="text-wrap pt-2">
Pick out different filters & sorting systems to customize your server

@ -208,11 +208,7 @@
--crepe-shadow-2:
0px 1px 2px 0px rgba(255, 255, 255, 0.3),
0px 2px 6px 2px rgba(255, 255, 255, 0.15) !important;
*,
::before,
::after {
@apply border-zinc-800;
}
.milkdown-icon {
fill: white !important;
}

@ -34,6 +34,7 @@ import { Inter } from "next/font/google";
import { X } from "lucide-react";
import { Link } from "@/components/util/link";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const inter = Inter({ subsets: ["latin"] });
@ -44,7 +45,7 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body className={inter.className}>
<body className={cn(inter.className, "bg-background font-sans")}>
<noscript>
<main className="flex justify-center items-center text-center min-h-screen h-max">
<Placeholder

@ -167,7 +167,7 @@ export default function Embed({ params }: { params: { server: string } }) {
<div
className={cn(
"flex items-center transition-all duration-300 ease-in-out",
staticMode ? "ml-0" : "group-hover:ml-[42px]"
staticMode ? "ml-[42px]" : "group-hover:ml-[42px]"
)}
>
{serverObject && (

@ -15,7 +15,7 @@ export function Footer() {
if (!hideFooterPages.includes(pathname ?? ""))
return (
<footer className="w-full mt-15 border-t border-neutral-500/20 bg-neutral-100 dark:border-neutral-700/50 dark:bg-neutral-900 text-muted-foreground">
<footer className="w-full my-15 border-t border-neutral-500/20 bg-neutral-100 dark:border-neutral-700/50 dark:bg-neutral-900 text-muted-foreground">
<div className="flex justify-between items-start p-[20px]">
<span className="flex items-center gap-4">
<Link href="Special:Root">
@ -89,7 +89,7 @@ export function Footer() {
</div>
</div>
<span className="block px-4 lg:-translate-y-12">
<span className="block px-4">
<small className="text-[0.75rem]">
MHSF is an open-source project licensed under the MIT license. MHSF is
not officially affiliated with with Minehut, Super League Enterprise,

@ -103,14 +103,7 @@ export function ServerList() {
</h1>
<div className="flex items-center justify-between">
<span className="flex items-center">
<Tooltip>
<TooltipTrigger>
<ModificationButton disabled={testModeEnabled} />
</TooltipTrigger>
<TooltipContent side="bottom">
{filterCount} modification(s) enabled
</TooltipContent>
</Tooltip>
<ServerTestModeSelector
testModeStatus={testModeStatus}
testModeEnabled={testModeEnabled}

@ -0,0 +1,25 @@
import { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import { useTheme } from "@/lib/hooks/use-theme";
import { ServerResponse } from "@/lib/types/mh-server";
import { useUser } from "@clerk/nextjs";
export function ServerDiscordRow({
server,
mhsfData,
}: { server: ServerResponse; mhsfData: ReturnType<typeof useMHSFServer> }) {
const { user, isSignedIn } = useUser();
const { resolvedTheme } = useTheme()
return (
<iframe
src={`https://discord.com/widget?id=${mhsfData.server?.customizationData.discord}&theme=${resolvedTheme ?? "dark"}${isSignedIn ? `&username=${user.username}` : ""}`}
height={250}
// @ts-ignore bro idk what react is on :sob:
allowtransparency={true}
frameBorder={0}
title="Discord Embed"
className="w-full relative max-lg:mt-3 rounded-lg"
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
/>
);
}

@ -126,7 +126,7 @@ export function MOTDRow({
code: CodeHighlight,
}}
>
{mhsfData.server?.customizationData.description}
{mhsfData.server?.customizationData.description?.replaceAll("<br />", "\n")}
</Markdown>
</div>
)}

@ -0,0 +1,115 @@
/*
* 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Material } from "@/components/ui/material";
import { useEffect, useState } from "react";
import ShikiHighlighter from "react-shiki";
import { toast } from "sonner";
export function ServerDiscordBox({
mhsfServer,
defaultDiscord,
}: { mhsfServer: string; defaultDiscord: string }) {
const [serverId, setServerId] = useState(defaultDiscord);
const [allowed, setAllowed] = useState(false);
useEffect(() => {
(async () => {
setAllowed(false);
if (
serverId.length <= 25 &&
serverId.length > 3 &&
/^\d+$/.test(serverId as string)
) {
const { ok, body } = await fetch(
`https://discord.com/api/guilds/${serverId}/widget.json`,
);
if (ok) setAllowed(true);
}
})();
}, [serverId]);
return (
<Material className="grid gap-1 max-h-[700px] mt-2">
<strong className="flex items-center gap-2">Discord Embed</strong>
<div className="grid grid-cols-2">
<div className="border-r p-4">
<p className="my-2">
Enable Discord widgets in your server settings (Settings -{">"}{" "}
Engagement -{">"} Enable Server Widget) to use this. <br />
Note: We'll handle all of the query variables on the URL for you
(like theming and usernames).
</p>
<Input
placeholder="Server ID"
value={serverId}
onChange={(e) => setServerId(e.target.value)}
label="Enter your server ID shown in your engagement tab"
/>
<Button
className="w-full my-2"
disabled={!allowed}
onClick={async () => {
toast.promise(
fetch(
`/api/v1/server/get/${mhsfServer}/settings/change-discord?discordServerId=${serverId}`,
),
{
success: "Discord widget enabled",
error: "An error occurred",
loading: "Enabling Discord widget",
},
);
}}
>
Submit
</Button>
</div>
<div className="p-4">
{serverId !== "" && (
<iframe
src={`https://discord.com/widget?id=${serverId}&theme=dark`}
width={350}
height={300}
// @ts-ignore bro idk what react is on :sob:
allowtransparency={true}
frameBorder={0}
title="Discord Embed"
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
/>
)}
</div>
</div>
</Material>
);
}

@ -0,0 +1,126 @@
/*
* 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 {
Setting,
SettingContent,
SettingDescription,
SettingMeta,
SettingTitle,
} from "@/components/feat/settings/setting";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Material } from "@/components/ui/material";
import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import type { ServerResponse } from "@/lib/types/mh-server";
import { useState } from "react";
import { toast } from "sonner";
export function ServerUnownBox({
mhsfData,
serverData,
reset,
}: {
mhsfData: ReturnType<typeof useMHSFServer>;
serverData: ServerResponse;
reset: () => void;
}) {
const [input, setInput] = useState("");
return (
<Material className="flex items-center p-2 mt-2">
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Unlink server</SettingTitle>
<SettingDescription>This cannot be undone.</SettingDescription>
</SettingMeta>
<Drawer>
<DrawerTrigger asChild>
<Button variant="danger">Unlink</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DialogTitle>Are you sure?</DialogTitle>
<p className="text-sm">
Unlinking a server will remove all customizations of it and
you will not be able to customize your server again until you
link the server again.
</p>
<Input
label="Server name"
className="mt-2 w-full mb-2"
placeholder="Type the name of the server"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<DialogTrigger>
<Button
variant="danger"
className="w-full mb-4"
disabled={
input.toLocaleLowerCase() !==
serverData.name.toLocaleLowerCase()
}
onClick={async () => {
toast.promise(
fetch(
`/api/v1/server/get/${serverData._id}/settings/unlink-server`,
)
.then((c) => c.json())
.then(() => setInput(""))
.then(() => reset()),
{
loading: "Unlinking server",
success: "Successfully unlinked server",
error: "Failed to unlink server",
},
);
}}
>
Unlink
</Button>
</DialogTrigger>
</div>
</DrawerContent>
</Drawer>
</SettingContent>
</Setting>
</Material>
);
}

@ -29,6 +29,9 @@ import { ServerDescriptionBox } from "./customizations/server-description-box";
import { ServerBannerBox } from "./customizations/server-banner-box";
import { ServerMigrationBox } from "./customizations/server-migration-box";
import { ServerColorModeBox } from "./customizations/server-color-mode-box";
import { MHSFUser, useUser } from "@/lib/hooks/use-user";
import { ServerUnownBox } from "./customizations/server-unown-box";
import { ServerDiscordBox } from "./customizations/server-discord-box";
const successClasses =
"bg-green-200 border-green-400 dark:bg-green-800 dark:border-green-600";
@ -39,15 +42,16 @@ export function ServerEditorProvider({
children,
serverData,
minehutData,
mhsfUser
}: {
children: ReactNode | ReactNode[];
serverData: ReturnType<typeof useMHSFServer>;
minehutData: ServerResponse;
mhsfUser: {user: MHSFUser | null};
}) {
const [open, setOpen] = useState(false);
const [onlineData, setOnlineData] = useState<OnlineServer>();
const { servers, loading } = useServers();
const [claimedUser, setClaimedUser] = useState<string>();
useEffect(() => {
window.addEventListener("open-server-editor", () => {
@ -64,28 +68,19 @@ export function ServerEditorProvider({
}
}, [open, loading, servers, minehutData.name]);
useEffect(() => {
(async () => {
const response = await fetch("/api/v1/user/claimed-user");
const json = await response.json();
setClaimedUser(json.player ?? null);
})();
});
const requirementOne = minehutData.online;
const requirementTwo = onlineData !== null;
const requirementThree = claimedUser === onlineData?.author;
const requirementFour = claimedUser !== null;
const UploadDropzone = generateUploadDropzone<BannerUploaderRouter>({
url: `/api/v1/server/get/${minehutData._id}/settings/upload-banner`,
});
const requirementThree = mhsfUser !== null && mhsfUser.user?.claimedUser !== null && mhsfUser.user?.claimedUser?.name === onlineData?.author;
const requirementFour = mhsfUser !== null && mhsfUser.user?.claimedUser !== null;
return (
<>
{children}
<MilkdownProvider>
<Drawer open={open} onOpenChange={setOpen}>
<Drawer open={open} onOpenChange={(c) => {
serverData.refresh();
setOpen(c);
}}>
<DrawerContent className="p-4 !max-h-[700px] !h-[700px]">
<br />
{!serverData.server?.customizationData.isOwned ? (
@ -207,14 +202,20 @@ export function ServerEditorProvider({
</div>
<DrawerFooter>
<Button
onClick={() =>
toast.promise(serverData.ownServer(), {
onClick={async () => {
toast.promise(
async () => {
await serverData.ownServer();
await serverData.refresh();
},
{
success: "Successfully owned server",
error:
"There was an error while linking this server. Please contact support.",
loading: "Linking server...",
})
}
},
);
}}
disabled={
!(
requirementOne &&
@ -250,6 +251,12 @@ export function ServerEditorProvider({
serverData={serverData}
minehutData={minehutData}
/>
<ServerDiscordBox mhsfServer={minehutData._id} defaultDiscord={serverData.server.customizationData.discord ?? ""} />
<ServerUnownBox
mhsfData={serverData}
serverData={minehutData}
reset={() => {setOpen(false); serverData.refresh();}}
/>
</div>
) : (
<ServerMigrationBox

@ -12,11 +12,13 @@ import { Button } from "@/components/ui/button";
import { DebugProvider } from "./debug/debug-provider";
import { ReportingProvider } from "./reporting/reporting-provider";
import { ServerEditorProvider } from "./server-editor/server-editor-provider";
import { useUser } from "@/lib/hooks/use-user";
export function ServerProvider({ serverId }: { serverId: string }) {
const { server, error, loading, onlineServer } = useServer({ id: serverId });
const settings = useSettingsStore();
const mhsf = useMHSFServer(serverId);
const mhsfUser = useUser();
if (error !== null)
return (
@ -72,7 +74,7 @@ export function ServerProvider({ serverId }: { serverId: string }) {
</div>
) : (
<div className="px-10">
<ServerEditorProvider serverData={mhsf} minehutData={server as ServerResponse}>
<ServerEditorProvider serverData={mhsf} minehutData={server as ServerResponse} mhsfUser={mhsfUser}>
<ReportingProvider server={mhsf}>
<ServerMainPage
server={server as ServerResponse}

@ -39,6 +39,7 @@ import { IconsRow } from "./icons/icons-row";
import { affiliates } from "./util";
import { AffiliateRow } from "./afilliate/affilliate-row";
import { EmbedCreatorRow } from "./embeds/embed-creator";
import { ServerDiscordRow } from "./discord/server-discord-row";
export function ServerRows({ server, mhsfData }: { server: ServerResponse, mhsfData: ReturnType<typeof useMHSFServer> }) {
const clipboard = useClipboard();
@ -48,6 +49,7 @@ export function ServerRows({ server, mhsfData }: { server: ServerResponse, mhsfD
{affiliates.includes(server.name) && <AffiliateRow />}
<MOTDRow server={server} mhsfData={mhsfData} />
<StatisticsMainRow server={server} mhsfData={mhsfData} />
{mhsfData.server?.customizationData.discord !== undefined && <ServerDiscordRow server={server} mhsfData={mhsfData} />}
<GeneralInfo server={server} mhsfData={mhsfData} />
<AchievementsView server={server} mhsfData={mhsfData} />
<IconsRow server={server} mhsfData={mhsfData} />

@ -0,0 +1,235 @@
/*
* 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 { Material } from "@/components/ui/material";
import {
Setting,
SettingContent,
SettingDescription,
SettingMeta,
SettingTitle,
} from "./setting";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { LinkingDialog } from "./linking-dialog";
import { useUser } from "@/lib/hooks/use-user";
import { toast } from "sonner";
import { useMinecraftHead } from "@/lib/hooks/use-minecraft-head";
import Image from "next/image";
import { Placeholder } from "@/components/ui/placeholder";
import { EllipsisVertical, Star, StarOff } from "lucide-react";
import { useRouter } from "@/lib/useRouter";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ServerResponse } from "@/lib/types/mh-server";
import { useEffect, useState } from "react";
import { useEffectOnce } from "@/lib/useEffectOnce";
export function AccountSettings() {
const { user, refresh } = useUser();
const router = useRouter();
if (user !== null)
return (
<Material className="mt-6 grid gap-4">
<h2 className="text-xl font-semibold text-inherit">Link Account</h2>
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Link Account</SettingTitle>
<SettingDescription>
Link a Minecraft account to confirm you own a server and
customize it with certain information to make it look better to
other MHSF users.
</SettingDescription>
</SettingMeta>
<LinkingDialog refresh={refresh}>
<Button disabled={user?.claimedUser !== null}>
{user?.claimedUser === null
? "Link Account"
: "(account already linked)"}
</Button>
</LinkingDialog>
</SettingContent>
</Setting>
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Unlink Account</SettingTitle>
</SettingMeta>
<Button
variant="danger"
disabled={user?.claimedUser === null}
onClick={() => {
toast.promise(
fetch(user?.actions.unlinkAccount as string).then(() =>
refresh(),
),
{
loading: "Unlinking...",
success: "Unlinked!",
error: "Failed to unlink",
},
);
}}
>
Unlink
</Button>
</SettingContent>
</Setting>
{user.claimedUser !== null && (
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Account Name</SettingTitle>
</SettingMeta>
<div className="flex items-center gap-2">
<Image
src={`https://api.mineatar.io/face/${user.claimedUser?.uuid}`}
alt=""
className="rounded"
width={16}
height={16}
/>
{user.claimedUser?.name}
</div>
</SettingContent>
</Setting>
)}
<h2 className="text-xl font-semibold text-inherit">Favorite Servers</h2>
{user.favorites !== null && user.favorites.favorites.length > 0 ? (
<div>
{user.favorites.favorites.map((server, i) => (
<Setting key={i}>
<SettingContent>
<SettingMeta>
<SettingTitle>{server}</SettingTitle>
</SettingMeta>
<div className="flex items-center gap-2">
<Button onClick={() => router.push(`/server/${server}`)}>
Open Server
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="flex items-center"
size="square-md"
variant="secondary"
>
<EllipsisVertical size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="flex items-center gap-2"
onClick={async () => {
const minehut: { server: ServerResponse } =
await fetch(
`https://api.minehut.com/server/${server}?byName=true`,
).then((c) => c.json());
toast.promise(
fetch(
`/api/v1/server/get/${minehut.server._id}/favorite-server`,
).then(() => refresh()),
{
loading: "Removing favorite...",
success: "Removed favorite!",
error: "Failed to unfavorite",
},
);
}}
>
<Star size={16} />
Unfavorite
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</SettingContent>
</Setting>
))}
</div>
) : (
<Placeholder
title="You have no favorite servers"
icon={<StarOff />}
/>
)}
<h2 className="text-xl font-semibold text-inherit">Owned Servers</h2>
{user.ownedServers !== null && user.ownedServers.length > 0 ? (
<div>{user.ownedServers.map((server, i) => <OwnedServer server={server} key={i} />)}</div>
) : (
<Placeholder
title="You have no favorite servers"
icon={<StarOff />}
/>
)}
</Material>
);
return <Spinner />;
}
const OwnedServer = ({server}: {server: any}) => {
const [loading, setLoading] = useState(true);
const [name, setName] = useState("");
const [joins, setJoins] = useState(0);
const router = useRouter();
useEffectOnce(() => {
fetch(`https://api.minehut.com/server/${server.serverId}`)
.then((c) => c.json())
.then((d: {server: ServerResponse}) => {
setLoading(false);
setName(d.server.name);
setJoins(d.server.joins);
});
});
if (loading) return null;
return (
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>{name}</SettingTitle>
<SettingDescription>{joins} joins</SettingDescription>
</SettingMeta>
<Button onClick={() => router.push(`/server/v2/minehut/${server.serverId}`)}>Open Server</Button>
</SettingContent>
</Setting>
);
};

@ -47,12 +47,16 @@ import {
import { useEffect, useState } from "react";
import { useSettingsStore } from "@/lib/hooks/use-settings-store";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Heart } from "lucide-react";
import { useTheme } from "@/lib/hooks/use-theme";
export function BrowserSettings() {
const settingsStore = useSettingsStore();
const [fontFamily, setFontFamily] = useState("inter");
const [mcFont, setMcFont] = useState(true);
const [debugMode, setDebugMode] = useState(false);
const { resolvedTheme } = useTheme();
useEffect(() => {
setFontFamily((settingsStore.get("font-family") ?? "inter") as string);
@ -62,6 +66,26 @@ export function BrowserSettings() {
return (
<Material className="mt-6 grid gap-4">
<h2 className="text-xl font-semibold text-inherit">Support</h2>
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Donate</SettingTitle>
<SettingDescription>
Please consider supporting me if you think this project is useful
to you, this project is completely open-source and I do not get
any money from it.
</SettingDescription>
</SettingMeta>
<a
className="px-3 py-1.5 rounded-lg shadow-none flex items-center gap-2 text-sm transition-all font-medium cursor-pointer duration-75 disabled:opacity-50 disabled:pointer-events-none origin-center dark:bg-pink-400 bg-pink-600 text-white dark:text-black"
href="https://buymeacoffee.com/dvelo"
>
<Heart fill={resolvedTheme === "dark" ? "black" : "white"} size={16} /> Donate
</a>
</SettingContent>
</Setting>
<Separator />
<h2 className="text-xl font-semibold text-inherit">Appearance</h2>
<Setting>
<SettingContent>
@ -125,7 +149,9 @@ export function BrowserSettings() {
<SettingContent>
<SettingMeta>
<SettingTitle>Debug Mode</SettingTitle>
<SettingDescription>Enable debug mode to show debug options</SettingDescription>
<SettingDescription>
Enable debug mode to show debug options
</SettingDescription>
</SettingMeta>
<Switch
checked={debugMode}

@ -0,0 +1,248 @@
/*
* 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.
*/
"use client";
import Link from "next/link";
import { ChevronLeft, ServerOff } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { ReactNode, useState } from "react";
import { DialogTitle } from "@radix-ui/react-dialog";
import { Button } from "@/components/ui/button";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { useUser } from "@clerk/nextjs";
import Image from "next/image";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { cn } from "@/lib/utils";
import { FormSpinner } from "@/components/ui/form-spinner";
export function LinkingDialog({ children, refresh }: { children: ReactNode, refresh: () => Promise<void> }) {
const [step, setStep] = useState(0);
const { user } = useUser();
const [error, setError] = useState(false);
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [username, setUserName] = useState("");
const onSubmit = async (code: string) => {
setLoading(true);
const fetchRes = await fetch(
`/api/v1/user/claim-account-code?code=${code}`,
);
const json = await fetchRes.json();
if (!fetchRes.ok) {
setError(true);
setLoading(false);
return;
}
setUserName(json.player);
setStep(2);
setLoading(false);
refresh();
};
return (
<Dialog
onOpenChange={(c) => {
if (c) setStep(0);
if (c) setLoading(false);
if (c) setError(false);
if (c) setCode("");
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="absolute h-[427px]">
<div className="relative overflow-hidden min-h-full">
<div
className={`transition-transform duration-300 ease-in-out ${step === 0 ? "translate-x-0" : "-translate-x-full"}`}
>
<div className="flex flex-col h-[427px]">
<div className="flex-grow">
<DialogTitle className="font-bold text-2xl">
Linking your account
</DialogTitle>
<p className="my-1 text-sm">
In order to link your account, you must have a Minecraft: Java
Edition account and be logged into your MHSF account.{" "}
<strong>
Linking MHSF does NOT involve Microsoft authentication.
</strong>
</p>
<p className="my-1 mb-4 text-sm">
<Link href="/server/CoreBoxx" className="underline">
CoreBoxx
</Link>{" "}
has partnered with us to have an integrated account linking
feature, which is also open all day.
</p>
<p className="py-1">
<code className="border rounded-full bg-muted h-[1.75rem] w-[1.75rem] absolute inline-flex items-center justify-center">
1
</code>
<span className="ml-[2.25rem] pt-0.5 grid grid-rows-2">
<span>Join CoreBoxx</span>
<code className="border rounded p-2">
CoreBoxx.minehut.gg
</code>
</span>
</p>
<p className="py-1">
<code className="border rounded-full bg-muted h-[1.75rem] w-[1.75rem] absolute inline-flex items-center justify-center">
2
</code>
<span className="ml-[2.25rem] pt-0.5 grid">
<span>
Link your account using <code>/mhsf</code>
</span>
</span>
</p>
<p className="py-1">
<code className="border rounded-full bg-muted h-[1.75rem] w-[1.75rem] absolute inline-flex items-center justify-center">
3
</code>
<span className="ml-[2.25rem] pt-0.5 grid">
<span>Input the code returned</span>
</span>
</p>
</div>
<Button
className="w-full flex items-center mb-12"
onClick={() => setStep(1)}
>
Continue
</Button>
</div>
</div>
<div
className={`absolute h-full top-0 left-0 w-full transition-transform duration-300 ease-in-out ${step === 1 ? "translate-x-0" : "translate-x-full"}`}
>
<span className="flex p-4 items-center gap-2 text-sm w-full justify-center">
<Image
alt="Clerk Image"
src={
user?.imageUrl === undefined
? "https://img.clerk.com/preview.png?size=144&seed=seed&initials=AD&isSquare=true&bgType=marble&bgColor=6c47ff&fgType=silhouette&fgColor=FFFFFF&type=user&w=48&q=75"
: user?.imageUrl
}
width={16}
height={16}
className="rounded-full"
/>
<p>Signed in as @{user?.username}</p>
</span>
<strong className="text-center w-full flex items-center justify-center">
Enter the code provided on the server:
</strong>
<div className="p-4 w-full flex items-center justify-center">
<div>
<InputOTP
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
onComplete={onSubmit}
value={code}
onChange={(c) => {
setCode(c);
setError(false);
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
</div>
{error && (
<span className="text-red-400 text-sm">
We couldn't find that code being used. Please try again later or
double check the code provided to you.
</span>
)}
<div
className={cn(
"flex items-center gap-1 w-full",
error ? "pt-auto h-[calc(100%-3rem)]" : "mt-6.5 pt-auto h-full",
)}
>
<Button
className="h-[34px]"
onClick={() => setStep(0)}
disabled={loading}
>
<ChevronLeft size={16} />
</Button>
<Button
className="w-full"
onClick={() => onSubmit(code)}
disabled={loading}
>
{loading ? <FormSpinner /> : "Continue"}
</Button>
</div>
</div>
<div
className={`absolute h-full top-0 left-0 w-full transition-transform duration-300 ease-in-out ${step === 2 ? "translate-x-0" : "translate-x-full"}`}
>
<div className="flex flex-col h-[427px]">
<div className="flex-grow">
<DialogTitle className="font-bold text-2xl">
You've linked your account!
</DialogTitle>
<p className="my-1 text-sm">
Congratulations! You've successfully linked your account to{" "}
{username}!
</p>
</div>
<DialogTrigger asChild>
<Button
className="w-full flex items-center mb-12"
onClick={() => setStep(1)}
>
Continue
</Button>
</DialogTrigger>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

@ -40,6 +40,7 @@ import { BrowserSettings } from "./browser-settings";
import { useSettingsStore } from "@/lib/hooks/use-settings-store";
import { useEffect, useState } from "react";
import { DebugSettings } from "./debug-settings";
import { AccountSettings } from "./account-settings";
export function Settings() {
const settingsStore = useSettingsStore();
@ -101,6 +102,7 @@ export function Settings() {
<DebugSettings />
</TabsContent>
<TabsContent value="user-settings">
<SignedIn><AccountSettings /></SignedIn>
<SignedOut>
<Material className="mt-6 grid gap-4 py-6">
<h3

@ -53,7 +53,7 @@ export function WaitlistPage() {
<h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl mb-3">
v2 private beta
</h1>
<p className="mb-3">
<p className="mb-3 text-sm">
Hello there! MHSF has an exclusive beta that you may have been invited{" "}
<br /> to. Please sign into your account below or follow the
instructions.
@ -67,8 +67,8 @@ export function WaitlistPage() {
<SignedOut>
<p>
You must be signed in to check for eligibility for this beta. Please
make sure you use the Discord connection so we can check if you
eligibile for the beta.
make sure you use the Discord connection so we can check if you are
eligible for the beta.
</p>
<span className="flex items-center gap-2">
<Button onClick={() => clerk.openSignIn()} variant="secondary">
@ -131,7 +131,7 @@ export function UserInformation({ discordPage }: { discordPage?: boolean }) {
{discordData !== undefined && discordData !== null && (
<p className="group cursor-pointer flex items-center gap-1">
Discord linked as {discordData.global_name}
Discord linked as {discordData.global_name ?? discordData.username}
<span className="text-muted-foreground hidden group-hover:block">
@{discordData.username}
</span>

@ -100,7 +100,7 @@ const DialogContent = React.forwardRef<
"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",
"p-5 flex flex-col gap-2 dark:bg-zinc-950 rounded-xl",
"bg-white fixed z-9",
"bg-white fixed z-100",
className
)}
>

@ -0,0 +1,77 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

@ -77,6 +77,8 @@ export function FontBoundary({
})() as string,
"overflow-x-hidden",
className,
"bg-background",
"font-sans"
] as string[];
document.body.classList.add(...classes);

@ -29,4 +29,4 @@
*/
"use client";
export const version = "2.0";
export const version = "pb1-2.0";

@ -35,7 +35,7 @@
*/
//
import { Achievement } from "./types/achievement";
import type { Achievement } from "./types/achievement";
const connector = (
endpoint: string,
@ -168,7 +168,7 @@ export async function isFavorited(server: string): Promise<boolean> {
export async function getAccountFavorites(): Promise<Array<string>> {
try {
const response = await fetch(
connector(`/user/favorites`, { version: 1 }),
connector(`/user/get`, { version: 1 }),
{
method: "POST",
headers: {
@ -177,7 +177,7 @@ export async function getAccountFavorites(): Promise<Array<string>> {
}
);
return (await response.json()).result;
return (await response.json()).favorites.favorites;
} catch {
throw Error("Not authenticated with a user.");
}

@ -110,7 +110,7 @@ export async function checkOwnedServerMetadata(
{ server: serverSelector.name ?? serverData.name },
],
},
{ $set: changes },
{ $set: { serverId: serverSelector.id, customizationVersion: 2, ...changes } },
{ upsert: true },
);
},

@ -36,10 +36,10 @@ import { tryCatch } from "../try-catch";
import { transpileTypeScript } from "@/app/(sl-modification-frame)/servers/embedded/sl-modification-frame/file/[filename]/page";
import { useUser } from "@clerk/nextjs";
import type { ClerkCustomActivatedModification } from "@/components/feat/server-list/modification/modification-file-creation-dialog";
import { ClerkEmbeddedFilter } from "@/components/feat/server-list/modification/modification-action";
import type { ClerkEmbeddedFilter } from "@/components/feat/server-list/modification/modification-action";
import { supportedFilters } from "../types/supportedFilters";
type EmbeddedFilter = {
export type EmbeddedFilter = {
identifier: string;
functionFilter: (server: OnlineServer) => (boolean | Promise<boolean>);
};
@ -77,13 +77,13 @@ export function useFilters(data: OnlineServer[]) {
if (filteredData.length === 0 || data.length === 0) {
window.dispatchEvent(new Event("update-modification-stack"));
} else setLoading(false);
}, [data, filteredData, loading]);
}, [data, filteredData]);
useEffect(() => {
if (data.length === 0) {
window.dispatchEvent(new Event("update-modification-stack"));
} else setLoading(false);
}, [data, filteredData, loading]);
}, [data, filteredData]);
const testModeInit = (type: "filter" | "sort") => {
window.dispatchEvent(new Event("test-mode.enabled"));

@ -28,27 +28,28 @@
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { NextApiRequest, NextApiResponse } from "next";
import { MongoClient } from "mongodb";
import { waitUntil } from "@vercel/functions";
import { useEffect, useState } from "react";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const client = new MongoClient(process.env.MONGO_DB as string);
await client.connect();
export function useMinecraftHead(username: string) {
const [loading, setLoading] = useState(true);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [uuid, setUUID] = useState<string | null>(null);
const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const collection = db.collection("meta");
useEffect(() => {
if (username !== "")
fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`)
.then((c) => c.json())
.then((d) => {
setUUID(d.id);
});
}, [username]);
const all = await collection.find().toArray();
const sorted = all.sort((a, b) => a.favorites - b.favorites);
sorted.reverse();
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.send({ results: sorted });
useEffect(() => {
if (uuid !== null) {
setImageUrl(`https://api.mineatar.io/face/${uuid}`);
setLoading(false);
}
}, [uuid])
return { loading, imageUrl, uuid };
}

@ -28,34 +28,51 @@
* 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 type { WithId } from "mongodb";
import { useEffect, useState } from "react";
import { NullLiteral } from "typescript";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { userId } = getAuth(req);
export type MHSFUser = {
favorites: WithId<{
/** @note Not important */
user: string;
/** TODO: should be as a Id */
favorites: string[];
}> | null;
ownedServers: {
serverId: string;
/** @deprecated use `serverId` instead */
server: string;
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
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();
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
if (find.length == 0) {
res.send({ favorites: [] });
} else {
res.send({ favorites: find[0].favorites });
}
author: string;
}[];
claimedUser: { uuid: string; name: string } | null;
actions: {
linkAccount: string;
unlinkAccount: string;
};
};
export function useUser(): {
user: MHSFUser | null;
refresh: () => Promise<void>;
} {
const [user, setUser] = useState<MHSFUser | null>(null);
useEffect(() => {
(async () => {
const user = await fetch("/api/v1/user/get");
const json = await user.json();
setUser(json);
})();
}, []);
return {
user,
refresh: async () => {
const user = await fetch("/api/v1/user/get");
const json = await user.json();
setUser(json);
},
};
}

@ -29,9 +29,10 @@
*/
import { getBackendProcedure } from "@/lib/backend-procedure";
import type { MHSFData } from "@/lib/types/data";
import { clerkClient, getAuth, User } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import type { Achievement } from "@/lib/types/achievement";
import type { ActualCustomization, MHSFData } from "@/lib/types/data";
import { clerkClient, getAuth, type User } from "@clerk/nextjs/server";
import { type Db, type Filter, type Document, MongoClient, type WithId } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
export type RouteParams = {
@ -114,6 +115,7 @@ export default async function handler(
res.send({
server: {
favoriteData,
// @ts-ignore Also don't care what you think.
customizationData,
playerData,
achievements,
@ -143,15 +145,18 @@ async function findCustomizationData(
serverName: string,
serverId: string,
userId: string | undefined,
db: any,
db: Db,
): Promise<{
description: string | undefined;
banner: string | undefined;
discord: string | undefined;
colorScheme: string | undefined;
colorMode: "dark" | "light" | null;
customizationVersion: number | undefined;
userProfilePicture: string | undefined;
isOwned: boolean;
isOwnedByUser: boolean;
banner: string | undefined;
_deletionId: string | undefined;
}> {
const clerk = await clerkClient();
// Run queries in parallel
@ -167,18 +172,33 @@ async function findCustomizationData(
]);
let user: User | undefined = undefined;
if (ownedServerData) {
try {
user = await clerk.users.getUser(ownedServerData?.author);
} catch (e) {
console.warn(e);
if (customizationData || ownedServerData) {
return {
...(customizationData as any),
const baseData: {
description?: string;
discord?: string;
colorScheme?: string;
colorMode: "dark" | "light" | null;
customizationVersion?: number;
banner?: string;
_deletionId?: string | undefined;
isOwned: boolean;
isOwnedByUser: boolean;
userProfilePicture: string | undefined;
} = {
...(customizationData as WithId<ActualCustomization> | null) ?? {},
isOwned: true,
isOwnedByUser: ownedServerData?.author === userId,
userProfilePicture: null,
userProfilePicture: undefined,
colorMode: null,
customizationVersion: undefined,
_deletionId: undefined,
};
// @ts-ignore L
return baseData
}
return {
isOwned: false,
@ -187,17 +207,51 @@ async function findCustomizationData(
banner: undefined,
discord: undefined,
colorScheme: undefined,
colorMode: null,
customizationVersion: undefined,
userProfilePicture: undefined,
_deletionId: undefined,
};
}
}
if (customizationData || ownedServerData) {
return {
...(customizationData as any),
const baseData: {
description?: string;
discord?: string;
colorScheme?: string;
colorMode: "dark" | "light" | null;
customizationVersion?: number;
banner?: string;
_deletionId?: string | undefined;
isOwned: boolean;
isOwnedByUser: boolean;
userProfilePicture: string | undefined;
} = {
...(customizationData as WithId<ActualCustomization> | null) ?? {},
isOwned: true,
isOwnedByUser: ownedServerData?.author === userId,
userProfilePicture: userId ? user?.imageUrl : "no user",
customizationVersion: customizationData === null ? 2 : customizationData?.customizationVersion,
userProfilePicture: undefined,
colorMode: customizationData?.colorMode === undefined ? null : customizationData?.colorMode,
_deletionId: undefined,
};
// @ts-ignore L
return {
description: baseData.description,
discord: baseData.discord,
colorScheme: baseData.colorScheme,
colorMode: baseData.colorMode,
customizationVersion: baseData.customizationVersion,
userProfilePicture: baseData.userProfilePicture,
isOwned: baseData.isOwned,
isOwnedByUser: baseData.isOwnedByUser,
...(baseData.banner ? {
banner: baseData.banner as string,
_deletionId: (baseData._deletionId || "") as string,
} : {
banner: undefined,
}),
};
}
@ -208,14 +262,17 @@ async function findCustomizationData(
banner: undefined,
discord: undefined,
colorScheme: undefined,
colorMode: null,
customizationVersion: undefined,
userProfilePicture: undefined,
_deletionId: undefined,
};
}
async function findFavoriteData(
serverName: string,
userId: string | undefined,
db: any,
db: Db,
query: {
maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[];
@ -246,16 +303,16 @@ async function findFavoriteData(
}
async function fetchHistoryData(
db: any,
db: Db,
serverName: string,
query: {
maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[];
favoriteTimespanEnd?: string | string[];
},
) {
): Promise<{ date: string; favorites: number }[]> {
// Build query filter
const filter: any = { server: serverName };
const filter: { server: string; date?: { $gte: Date; $lte: Date } } = { server: serverName };
// Add date range filter if provided
if (query.favoriteTimespanStart && query.favoriteTimespanEnd) {
@ -279,14 +336,18 @@ async function fetchHistoryData(
cursor.limit(limit);
}
return await cursor.toArray();
const results = await cursor.toArray();
return results.map(doc => ({
date: doc.date.toISOString(),
favorites: doc.favorites || 0
}));
}
export async function findServerData(
server: string,
): Promise<{ exists: boolean; name: string }> {
try {
const response = await fetch("https://api.minehut.com/server/" + server);
const response = await fetch(`https://api.minehut.com/server/${server}`);
// Check if the response is ok before parsing JSON
if (!response.ok) {
@ -305,7 +366,7 @@ export async function findServerData(
async function findPlayerData(
serverName: string,
db: any,
db: Db,
query: {
maxPlayerEntries?: string | string[];
playerTimespanStart?: string | string[];
@ -316,7 +377,7 @@ async function findPlayerData(
const historyCollection = db.collection("history");
// Build query filter
const filter: any = { server: serverName };
const filter: Filter<Document> = { server: serverName };
// Add date range filter if provided
if (query.playerTimespanStart && query.playerTimespanEnd) {
@ -348,10 +409,11 @@ async function findPlayerData(
}
// Format the data to match the expected structure
type HistoryDocument = { date: Date; player_count?: number };
const formattedHistory = historically.map(
(item: { date: string; player_count?: number }) => ({
date: item.date,
playerCount: item.player_count || 0,
(item) => ({
date: (item as HistoryDocument).date.toISOString(),
playerCount: (item as HistoryDocument).player_count || 0,
}),
);
@ -362,7 +424,7 @@ async function findPlayerData(
async function findAchievements(
serverName: string,
db: any,
db: Db,
query: {
maxAchievementEntries?: string | string[];
achievementTimespanStart?: string | string[];
@ -373,12 +435,10 @@ async function findAchievements(
const achievementsCollection = db.collection("achievements");
// Build query filter
const filter: any = { name: serverName };
const filter: Filter<Document> = { name: serverName };
// Add date range filter if provided
if (query.achievementTimespanStart && query.achievementTimespanEnd) {
// Assuming there's a timestamp or date field in the achievements collection
// If it's stored in _id, we might need a different approach
filter.timestamp = {
$gte: new Date(Number(query.achievementTimespanStart)),
$lte: new Date(Number(query.achievementTimespanEnd)),
@ -393,11 +453,17 @@ async function findAchievements(
historically = historically.slice(0, Number(query.maxAchievementEntries));
}
const currently: any[] = [];
for (const a of historically)
a.achievements.forEach((item: any, interval: number) =>
currently.push({ interval, ...item }),
);
// Transform the data to match the expected shape
const transformedHistorically = historically.map(doc => ({
_id: doc._id.toString(),
name: doc.name,
achievements: doc.achievements || []
}));
return { historically, currently };
const currently: Achievement[] = [];
for (const a of historically)
for (const item of a.achievements)
currently.push(item);
return { historically: transformedHistorically, currently };
}

@ -28,43 +28,48 @@
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { checkOwnedServerMetadata } from "@/lib/check-owned-server";
import { getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
import { clerkClient, getAuth } from "@clerk/nextjs/server";
import z from "zod";
const obj = z.object({
// Use padding on the sides of only the servers, not the whole server list
srv: z.boolean(),
// Items per row (4-6 rows)
ipr: z.number().min(4).max(6),
// Padding of server list (0-120px)
pad: z.number().min(0).max(120),
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { userId } = getAuth(req);
try {
const { server: serverId, discordServerId } = req.query;
const mongo = new MongoClient(process.env.MONGO_DB as string);
if (!discordServerId)
return res.status(400).send({ error: "No description provided" });
if (
!(
discordServerId.length <= 25 &&
discordServerId.length > 3 &&
/^\d+$/.test(discordServerId as string)
)
)
return res.status(400).send({ error: "Invalid value" });
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
const { data } = req.body;
const { ok } = await fetch(
`https://discord.com/api/guilds/${discordServerId}/widget.json`,
);
if (data === undefined) {
res.status(400).send({ message: "Couldn't find data" });
return;
}
if (!ok) return res.status(400).send({ error: "Invalid value" });
const v = obj.parse(data);
for (const [key, value] of Object.entries(v)) {
(await clerkClient()).users.updateUserMetadata(userId, {
publicMetadata: {
[key]: typeof value === "number" ? value.toString() : value,
const { changeServer } = await checkOwnedServerMetadata(
getAuth(req).userId ?? null,
mongo,
{
id: serverId as string,
},
});
}
);
res.status(200).send({ message: "Success" });
await changeServer({
discord: discordServerId as string,
});
} catch (error) {
return res.status(400).send({ error: error });
}
return res.send({ message: "Success" });
}

@ -28,25 +28,34 @@
* OTHER DEALINGS IN THE SOFTWARE.
*/
import type { NextApiRequest, NextApiResponse } from "next";
import { getAuth, clerkClient } from "@clerk/nextjs/server";
import { checkOwnedServerMetadata } from "@/lib/check-owned-server";
import { getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import { waitUntil } from "@vercel/functions";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { userId } = getAuth(req);
try {
const { server: serverId } = req.query;
const mongo = new MongoClient(process.env.MONGO_DB as string);
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
const { ownedServer, customizedServer, changeServer } =
await checkOwnedServerMetadata(getAuth(req).userId ?? null, mongo, {
id: serverId as string,
});
const db = mongo.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
await db.collection("customization").findOneAndDelete({
$or: [{ serverId: serverId }],
});
await db.collection("owned-servers").findOneAndDelete({
$or: [{ serverId: serverId }],
});
return res.send({ message: "Success" });
} catch (error) {
return res.status(400).send({ error: error });
}
const client = new MongoClient(process.env.MONGO_DB as string);
await client.connect();
const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const users = db.collection("claimed-users");
return res.send((await users.findOne({ userId })) ?? {player: null});
}

@ -28,17 +28,16 @@
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { NextApiRequest, NextApiResponse } from "next";
import type { NextApiRequest, NextApiResponse } from "next";
import { getAuth, clerkClient } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import { waitUntil } from "@vercel/functions";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { userId } = getAuth(req);
const { code } = req.body;
const { code } = req.query;
if (code == null) {
res.status(400).send({ message: "Couldn't find data" });
@ -59,7 +58,7 @@ export default async function handler(
res.status(400).send({ message: "Couldn't find code" });
return;
}
collection.findOneAndDelete({ code });
await collection.findOneAndDelete({ code });
const users = db.collection("claimed-users");
await users.insertOne({ player: entry.player, userId });
@ -69,9 +68,5 @@ export default async function handler(
},
});
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.send({ player: entry.player });
}

@ -0,0 +1,88 @@
/*
* 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 { MHSFUser } from "@/lib/hooks/use-user";
import { getAuth } from "@clerk/nextjs/server";
import { MongoClient, type WithId } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<MHSFUser | { error: string }>,
) {
const { userId } = getAuth(req);
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
const client = new MongoClient(process.env.MONGO_DB as string);
await client.connect();
const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const favoriteCollection = db.collection("favorites");
const favorites = (await favoriteCollection.findOne({
user: userId,
})) as WithId<{ user: string; favorites: string[] }> | null;
const ownedServersCollection = db.collection("owned-servers");
const ownedServers = (await ownedServersCollection
.find({ author: userId })
.toArray()) as WithId<{
serverId: string;
author: string;
server: string;
}>[];
const claimedUsers = db.collection("claimed-users");
const claimedUser = await claimedUsers.findOne({ userId });
let uuid = "";
if (claimedUser?.player !== undefined)
uuid = await fetch(
`https://api.mojang.com/users/profiles/minecraft/${claimedUser?.player ?? ""}`,
)
.then((c) => c.json())
.then((d) => d.id);
return res.send({
favorites,
ownedServers,
claimedUser:
claimedUser === null
? null
: { name: (claimedUser ?? { player: undefined }).player, uuid },
actions: {
unlinkAccount: "/api/v1/user/unlink-account",
linkAccount: "/api/v1/user/claim-account-code",
},
});
}

@ -9380,9 +9380,9 @@ inngest@^3.21.2:
ulidx "^2.4.1"
zod "~3.22.3"
input-otp@^1.2.4, input-otp@^1.4.2:
input-otp@^1.4.2:
version "1.4.2"
resolved "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz"
resolved "https://registry.yarnpkg.com/input-otp/-/input-otp-1.4.2.tgz#f4d3d587d0f641729e55029b3b8c4870847f4f07"
integrity sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==
inquirer@^12.3.0: