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", hostname: "exh89c9lva.ufs.sh",
pathname: "/f/*", pathname: "/f/*",
}, },
{
protocol: "https",
hostname: "api.mineatar.io"
}
], ],
}, },
async redirects() { async redirects() {

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

@ -40,36 +40,37 @@ import { Footer } from "@/components/feat/footer/footer";
import { NuqsAdapter } from "nuqs/adapters/next/app"; import { NuqsAdapter } from "nuqs/adapters/next/app";
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return (
return ( <>
<> <ThemeProvider
<ThemeProvider attribute="class"
attribute="class" defaultTheme="system"
defaultTheme="system" enableSystem
enableSystem disableTransitionOnChange
disableTransitionOnChange >
> <ClerkProvider>
<ClerkProvider> <IsScript>
<IsScript> <NuqsAdapter>
<NuqsAdapter> <div vaul-drawer-wrapper="">
<FontBoundary> <FontBoundary>
<TooltipProvider> <TooltipProvider>
<Toaster richColors position="top-center" /> <Toaster richColors position="top-center" />
<ClerkProvider> <ClerkProvider>
<NavBar /> <NavBar />
<div className="pt-16 min-h-screen">{children}</div> <div className="pt-16 min-h-screen">{children}</div>
<Footer /> <Footer />
</ClerkProvider> </ClerkProvider>
</TooltipProvider> </TooltipProvider>
</FontBoundary> </FontBoundary>
</NuqsAdapter> </div>
</IsScript> </NuqsAdapter>
</ClerkProvider> </IsScript>
</ThemeProvider> </ClerkProvider>
</> </ThemeProvider>
); </>
);
} }

@ -61,7 +61,7 @@ export default function RootLayout({
<Toaster richColors position="bottom-center" /> <Toaster richColors position="bottom-center" />
<NextTopLoader showSpinner={false} /> <NextTopLoader showSpinner={false} />
<div className="overflow-x-hidden">{children}</div> <div className="overflow-x-hidden" >{children}</div>
</TooltipProvider> </TooltipProvider>
</IframeProtector> </IframeProtector>
</FontBoundary> </FontBoundary>

@ -41,39 +41,41 @@ import Markdown from "react-markdown";
import { invertHex } from "../../page"; import { invertHex } from "../../page";
export default function ModificationPage({ export default function ModificationPage({
params, params,
}: { }: {
params: Promise<{ category: string; mod: string }>; params: Promise<{ category: string; mod: string }>;
}) { }) {
const { category, mod } = use(params); const { category, mod } = use(params);
const [backRoute] = useQueryState("b", { const [backRoute] = useQueryState("b", {
defaultValue: "/servers/embedded/sl-modification-frame", defaultValue: "/servers/embedded/sl-modification-frame",
}); });
const categoryObj = serverModDB.find( const categoryObj = serverModDB.find(
(c) => c.displayTitle === atob(decodeURIComponent(category)) (c) => c.displayTitle === atob(decodeURIComponent(category)),
); );
let modObj = null; let modObj = null;
if (categoryObj !== undefined) if (categoryObj !== undefined)
modObj = categoryObj?.entries.find( modObj = categoryObj?.entries.find(
(c) => c.name === atob(decodeURIComponent(mod)) (c) => c.name === atob(decodeURIComponent(mod)),
); );
return ( return (
<main className="p-4"> <main className="p-4">
<div <div
className="h-[150px] w-full rounded-xl p-2" className="h-[150px] w-full rounded-xl p-2"
style={{ backgroundColor: modObj?.color }} style={{ backgroundColor: modObj?.color }}
> >
<Link href={backRoute}> <Link href={backRoute}>
<ArrowLeft style={{color: invertHex(modObj?.color ?? "")}} /> <ArrowLeft style={{ color: invertHex(modObj?.color ?? "") }} />
</Link> </Link>
</div> </div>
<span className="p-4"> <span className="p-4">
<h1 className="text-xl font-bold w-full">{modObj?.name}</h1> <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">
<ModificationAction value={modObj?.value} /> <Markdown>{modObj?.description}</Markdown>
</span> </div>
</main> <ModificationAction value={modObj?.value} />
); </span>
</main>
);
} }

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

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

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

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

@ -167,7 +167,7 @@ export default function Embed({ params }: { params: { server: string } }) {
<div <div
className={cn( className={cn(
"flex items-center transition-all duration-300 ease-in-out", "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 && ( {serverObject && (

@ -15,7 +15,7 @@ export function Footer() {
if (!hideFooterPages.includes(pathname ?? "")) if (!hideFooterPages.includes(pathname ?? ""))
return ( 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]"> <div className="flex justify-between items-start p-[20px]">
<span className="flex items-center gap-4"> <span className="flex items-center gap-4">
<Link href="Special:Root"> <Link href="Special:Root">
@ -89,7 +89,7 @@ export function Footer() {
</div> </div>
</div> </div>
<span className="block px-4 lg:-translate-y-12"> <span className="block px-4">
<small className="text-[0.75rem]"> <small className="text-[0.75rem]">
MHSF is an open-source project licensed under the MIT license. MHSF is MHSF is an open-source project licensed under the MIT license. MHSF is
not officially affiliated with with Minehut, Super League Enterprise, not officially affiliated with with Minehut, Super League Enterprise,

@ -103,14 +103,7 @@ export function ServerList() {
</h1> </h1>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="flex items-center"> <span className="flex items-center">
<Tooltip> <ModificationButton disabled={testModeEnabled} />
<TooltipTrigger>
<ModificationButton disabled={testModeEnabled} />
</TooltipTrigger>
<TooltipContent side="bottom">
{filterCount} modification(s) enabled
</TooltipContent>
</Tooltip>
<ServerTestModeSelector <ServerTestModeSelector
testModeStatus={testModeStatus} testModeStatus={testModeStatus}
testModeEnabled={testModeEnabled} 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, code: CodeHighlight,
}} }}
> >
{mhsfData.server?.customizationData.description} {mhsfData.server?.customizationData.description?.replaceAll("<br />", "\n")}
</Markdown> </Markdown>
</div> </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 { ServerBannerBox } from "./customizations/server-banner-box";
import { ServerMigrationBox } from "./customizations/server-migration-box"; import { ServerMigrationBox } from "./customizations/server-migration-box";
import { ServerColorModeBox } from "./customizations/server-color-mode-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 = const successClasses =
"bg-green-200 border-green-400 dark:bg-green-800 dark:border-green-600"; "bg-green-200 border-green-400 dark:bg-green-800 dark:border-green-600";
@ -39,15 +42,16 @@ export function ServerEditorProvider({
children, children,
serverData, serverData,
minehutData, minehutData,
mhsfUser
}: { }: {
children: ReactNode | ReactNode[]; children: ReactNode | ReactNode[];
serverData: ReturnType<typeof useMHSFServer>; serverData: ReturnType<typeof useMHSFServer>;
minehutData: ServerResponse; minehutData: ServerResponse;
mhsfUser: {user: MHSFUser | null};
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [onlineData, setOnlineData] = useState<OnlineServer>(); const [onlineData, setOnlineData] = useState<OnlineServer>();
const { servers, loading } = useServers(); const { servers, loading } = useServers();
const [claimedUser, setClaimedUser] = useState<string>();
useEffect(() => { useEffect(() => {
window.addEventListener("open-server-editor", () => { window.addEventListener("open-server-editor", () => {
@ -64,28 +68,19 @@ export function ServerEditorProvider({
} }
}, [open, loading, servers, minehutData.name]); }, [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 requirementOne = minehutData.online;
const requirementTwo = onlineData !== null; const requirementTwo = onlineData !== null;
const requirementThree = claimedUser === onlineData?.author; const requirementThree = mhsfUser !== null && mhsfUser.user?.claimedUser !== null && mhsfUser.user?.claimedUser?.name === onlineData?.author;
const requirementFour = claimedUser !== null; const requirementFour = mhsfUser !== null && mhsfUser.user?.claimedUser !== null;
const UploadDropzone = generateUploadDropzone<BannerUploaderRouter>({
url: `/api/v1/server/get/${minehutData._id}/settings/upload-banner`,
});
return ( return (
<> <>
{children} {children}
<MilkdownProvider> <MilkdownProvider>
<Drawer open={open} onOpenChange={setOpen}> <Drawer open={open} onOpenChange={(c) => {
serverData.refresh();
setOpen(c);
}}>
<DrawerContent className="p-4 !max-h-[700px] !h-[700px]"> <DrawerContent className="p-4 !max-h-[700px] !h-[700px]">
<br /> <br />
{!serverData.server?.customizationData.isOwned ? ( {!serverData.server?.customizationData.isOwned ? (
@ -207,14 +202,20 @@ export function ServerEditorProvider({
</div> </div>
<DrawerFooter> <DrawerFooter>
<Button <Button
onClick={() => onClick={async () => {
toast.promise(serverData.ownServer(), { toast.promise(
success: "Successfully owned server", async () => {
error: await serverData.ownServer();
"There was an error while linking this server. Please contact support.", await serverData.refresh();
loading: "Linking server...", },
}) {
} success: "Successfully owned server",
error:
"There was an error while linking this server. Please contact support.",
loading: "Linking server...",
},
);
}}
disabled={ disabled={
!( !(
requirementOne && requirementOne &&
@ -250,6 +251,12 @@ export function ServerEditorProvider({
serverData={serverData} serverData={serverData}
minehutData={minehutData} minehutData={minehutData}
/> />
<ServerDiscordBox mhsfServer={minehutData._id} defaultDiscord={serverData.server.customizationData.discord ?? ""} />
<ServerUnownBox
mhsfData={serverData}
serverData={minehutData}
reset={() => {setOpen(false); serverData.refresh();}}
/>
</div> </div>
) : ( ) : (
<ServerMigrationBox <ServerMigrationBox

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

@ -39,6 +39,7 @@ import { IconsRow } from "./icons/icons-row";
import { affiliates } from "./util"; import { affiliates } from "./util";
import { AffiliateRow } from "./afilliate/affilliate-row"; import { AffiliateRow } from "./afilliate/affilliate-row";
import { EmbedCreatorRow } from "./embeds/embed-creator"; import { EmbedCreatorRow } from "./embeds/embed-creator";
import { ServerDiscordRow } from "./discord/server-discord-row";
export function ServerRows({ server, mhsfData }: { server: ServerResponse, mhsfData: ReturnType<typeof useMHSFServer> }) { export function ServerRows({ server, mhsfData }: { server: ServerResponse, mhsfData: ReturnType<typeof useMHSFServer> }) {
const clipboard = useClipboard(); const clipboard = useClipboard();
@ -46,8 +47,9 @@ export function ServerRows({ server, mhsfData }: { server: ServerResponse, mhsfD
return ( return (
<span className="lg:grid lg:grid-cols-2 w-full gap-3"> <span className="lg:grid lg:grid-cols-2 w-full gap-3">
{affiliates.includes(server.name) && <AffiliateRow />} {affiliates.includes(server.name) && <AffiliateRow />}
<MOTDRow server={server} mhsfData={mhsfData}/> <MOTDRow server={server} mhsfData={mhsfData} />
<StatisticsMainRow server={server} mhsfData={mhsfData} /> <StatisticsMainRow server={server} mhsfData={mhsfData} />
{mhsfData.server?.customizationData.discord !== undefined && <ServerDiscordRow server={server} mhsfData={mhsfData} />}
<GeneralInfo server={server} mhsfData={mhsfData} /> <GeneralInfo server={server} mhsfData={mhsfData} />
<AchievementsView server={server} mhsfData={mhsfData} /> <AchievementsView server={server} mhsfData={mhsfData} />
<IconsRow 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>
);
};

@ -30,113 +30,139 @@
import { Material } from "@/components/ui/material"; import { Material } from "@/components/ui/material";
import { import {
Setting, Setting,
SettingContent, SettingContent,
SettingDescription, SettingDescription,
SettingMeta, SettingMeta,
SettingTitle, SettingTitle,
} from "./setting"; } from "./setting";
import { ModeToggle } from "@/components/util/mode-toggle"; import { ModeToggle } from "@/components/util/mode-toggle";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSettingsStore } from "@/lib/hooks/use-settings-store"; import { useSettingsStore } from "@/lib/hooks/use-settings-store";
import { Switch } from "@/components/ui/switch"; 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() { 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); const [debugMode, setDebugMode] = useState(false);
const { resolvedTheme } = useTheme();
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); setDebugMode((settingsStore.get("debug-mode") === "true") as boolean);
}, []); }, []);
return ( return (
<Material className="mt-6 grid gap-4"> <Material className="mt-6 grid gap-4">
<h2 className="text-xl font-semibold text-inherit">Appearance</h2> <h2 className="text-xl font-semibold text-inherit">Support</h2>
<Setting> <Setting>
<SettingContent> <SettingContent>
<SettingMeta> <SettingMeta>
<SettingTitle>Color Scheme</SettingTitle> <SettingTitle>Donate</SettingTitle>
<SettingDescription> <SettingDescription>
Change the MHSF color scheme Please consider supporting me if you think this project is useful
</SettingDescription> to you, this project is completely open-source and I do not get
</SettingMeta> any money from it.
<ModeToggle /> </SettingDescription>
</SettingContent> </SettingMeta>
</Setting> <a
<Setting> 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"
<SettingContent> href="https://buymeacoffee.com/dvelo"
<SettingMeta> >
<SettingTitle>Use Minecraft font</SettingTitle> <Heart fill={resolvedTheme === "dark" ? "black" : "white"} size={16} /> Donate
<SettingDescription> </a>
Use Minecraft font for MOTD. Turning this off restores font </SettingContent>
settings for MOTD's to a v1-like state. </Setting>
</SettingDescription> <Separator />
</SettingMeta> <h2 className="text-xl font-semibold text-inherit">Appearance</h2>
<Switch <Setting>
checked={mcFont} <SettingContent>
onCheckedChange={(c) => { <SettingMeta>
settingsStore.set("mc-font", c, false); <SettingTitle>Color Scheme</SettingTitle>
setMcFont(c); <SettingDescription>
}} Change the MHSF color scheme
/> </SettingDescription>
</SettingContent> </SettingMeta>
</Setting> <ModeToggle />
<Setting> </SettingContent>
<SettingContent> </Setting>
<SettingMeta> <Setting>
<SettingTitle>Font</SettingTitle> <SettingContent>
<SettingDescription> <SettingMeta>
Change the default font used in the interface. <SettingTitle>Use Minecraft font</SettingTitle>
</SettingDescription> <SettingDescription>
</SettingMeta> Use Minecraft font for MOTD. Turning this off restores font
<Select settings for MOTD's to a v1-like state.
defaultValue="inter" </SettingDescription>
value={fontFamily} </SettingMeta>
onValueChange={(c) => { <Switch
settingsStore.set("font-family", c, false); checked={mcFont}
window.dispatchEvent(new Event("font-family-change")); onCheckedChange={(c) => {
setFontFamily(c); settingsStore.set("mc-font", c, false);
}} setMcFont(c);
> }}
<SelectTrigger className="max-w-[180px]"> />
<SelectValue /> </SettingContent>
</SelectTrigger> </Setting>
<SelectContent> <Setting>
<SelectItem value="inter">Inter</SelectItem> <SettingContent>
<SelectItem value="geist-sans">Geist Sans</SelectItem> <SettingMeta>
<SelectItem value="system-ui">System UI</SelectItem> <SettingTitle>Font</SettingTitle>
<SelectItem value="roboto">Roboto</SelectItem> <SettingDescription>
</SelectContent> Change the default font used in the interface.
</Select> </SettingDescription>
</SettingContent> </SettingMeta>
</Setting> <Select
<Setting> defaultValue="inter"
<SettingContent> value={fontFamily}
<SettingMeta> onValueChange={(c) => {
<SettingTitle>Debug Mode</SettingTitle> settingsStore.set("font-family", c, false);
<SettingDescription>Enable debug mode to show debug options</SettingDescription> window.dispatchEvent(new Event("font-family-change"));
</SettingMeta> setFontFamily(c);
<Switch }}
checked={debugMode} >
onCheckedChange={(c) => { <SelectTrigger className="max-w-[180px]">
settingsStore.set("debug-mode", c, false); <SelectValue />
window.dispatchEvent(new Event("debug-mode-change")); </SelectTrigger>
setDebugMode(c); <SelectContent>
}} <SelectItem value="inter">Inter</SelectItem>
/> <SelectItem value="geist-sans">Geist Sans</SelectItem>
</SettingContent> <SelectItem value="system-ui">System UI</SelectItem>
</Setting> <SelectItem value="roboto">Roboto</SelectItem>
</Material> </SelectContent>
); </Select>
</SettingContent>
</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>
);
} }

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

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

@ -100,7 +100,7 @@ const DialogContent = React.forwardRef<
"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 z-9", "bg-white fixed z-100",
className 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, })() as string,
"overflow-x-hidden", "overflow-x-hidden",
className, className,
"bg-background",
"font-sans"
] as string[]; ] as string[];
document.body.classList.add(...classes); document.body.classList.add(...classes);

@ -29,4 +29,4 @@
*/ */
"use client"; "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 = ( const connector = (
endpoint: string, endpoint: string,
@ -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(`/user/favorites`, { version: 1 }), connector(`/user/get`, { version: 1 }),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -177,7 +177,7 @@ export async function getAccountFavorites(): Promise<Array<string>> {
} }
); );
return (await response.json()).result; return (await response.json()).favorites.favorites;
} catch { } catch {
throw Error("Not authenticated with a user."); throw Error("Not authenticated with a user.");
} }

@ -110,7 +110,7 @@ export async function checkOwnedServerMetadata(
{ server: serverSelector.name ?? serverData.name }, { server: serverSelector.name ?? serverData.name },
], ],
}, },
{ $set: changes }, { $set: { serverId: serverSelector.id, customizationVersion: 2, ...changes } },
{ upsert: true }, { 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 { transpileTypeScript } from "@/app/(sl-modification-frame)/servers/embedded/sl-modification-frame/file/[filename]/page";
import { useUser } from "@clerk/nextjs"; import { useUser } from "@clerk/nextjs";
import type { ClerkCustomActivatedModification } from "@/components/feat/server-list/modification/modification-file-creation-dialog"; 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"; import { supportedFilters } from "../types/supportedFilters";
type EmbeddedFilter = { export type EmbeddedFilter = {
identifier: string; identifier: string;
functionFilter: (server: OnlineServer) => (boolean | Promise<boolean>); functionFilter: (server: OnlineServer) => (boolean | Promise<boolean>);
}; };
@ -77,13 +77,13 @@ export function useFilters(data: OnlineServer[]) {
if (filteredData.length === 0 || data.length === 0) { if (filteredData.length === 0 || data.length === 0) {
window.dispatchEvent(new Event("update-modification-stack")); window.dispatchEvent(new Event("update-modification-stack"));
} else setLoading(false); } else setLoading(false);
}, [data, filteredData, loading]); }, [data, filteredData]);
useEffect(() => { useEffect(() => {
if (data.length === 0) { if (data.length === 0) {
window.dispatchEvent(new Event("update-modification-stack")); window.dispatchEvent(new Event("update-modification-stack"));
} else setLoading(false); } else setLoading(false);
}, [data, filteredData, loading]); }, [data, filteredData]);
const testModeInit = (type: "filter" | "sort") => { const testModeInit = (type: "filter" | "sort") => {
window.dispatchEvent(new Event("test-mode.enabled")); window.dispatchEvent(new Event("test-mode.enabled"));

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

@ -28,34 +28,51 @@
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import type { NextApiResponse, NextApiRequest } from "next"; import type { WithId } from "mongodb";
import { MongoClient } from "mongodb"; import { useEffect, useState } from "react";
import { getAuth } from "@clerk/nextjs/server"; import { NullLiteral } from "typescript";
import { waitUntil } from "@vercel/functions";
export default async function handler( export type MHSFUser = {
req: NextApiRequest, favorites: WithId<{
res: NextApiResponse /** @note Not important */
) { user: string;
const { userId } = getAuth(req); /** TODO: should be as a Id */
favorites: string[];
}> | null;
ownedServers: {
serverId: string;
/** @deprecated use `serverId` instead */
server: string;
if (!userId) { author: string;
return res.status(401).json({ error: "Unauthorized" }); }[];
} claimedUser: { uuid: string; name: string } | null;
const client = new MongoClient(process.env.MONGO_DB as string); actions: {
await client.connect(); linkAccount: string;
unlinkAccount: string;
};
};
const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf"); export function useUser(): {
const collection = db.collection("favorites"); user: MHSFUser | null;
const find = await collection.find({ user: userId }).toArray(); refresh: () => Promise<void>;
} {
const [user, setUser] = useState<MHSFUser | null>(null);
// Close the database, but don't close this useEffect(() => {
// serverless instance until it happens (async () => {
waitUntil(client.close()); const user = await fetch("/api/v1/user/get");
const json = await user.json();
setUser(json);
})();
}, []);
if (find.length == 0) { return {
res.send({ favorites: [] }); user,
} else { refresh: async () => {
res.send({ favorites: find[0].favorites }); 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 { getBackendProcedure } from "@/lib/backend-procedure";
import type { MHSFData } from "@/lib/types/data"; import type { Achievement } from "@/lib/types/achievement";
import { clerkClient, getAuth, User } from "@clerk/nextjs/server"; import type { ActualCustomization, MHSFData } from "@/lib/types/data";
import { MongoClient } from "mongodb"; 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"; import type { NextApiRequest, NextApiResponse } from "next";
export type RouteParams = { export type RouteParams = {
@ -114,6 +115,7 @@ export default async function handler(
res.send({ res.send({
server: { server: {
favoriteData, favoriteData,
// @ts-ignore Also don't care what you think.
customizationData, customizationData,
playerData, playerData,
achievements, achievements,
@ -143,15 +145,18 @@ async function findCustomizationData(
serverName: string, serverName: string,
serverId: string, serverId: string,
userId: string | undefined, userId: string | undefined,
db: any, db: Db,
): Promise<{ ): Promise<{
description: string | undefined; description: string | undefined;
banner: string | undefined;
discord: string | undefined; discord: string | undefined;
colorScheme: string | undefined; colorScheme: string | undefined;
colorMode: "dark" | "light" | null;
customizationVersion: number | undefined;
userProfilePicture: string | undefined; userProfilePicture: string | undefined;
isOwned: boolean; isOwned: boolean;
isOwnedByUser: boolean; isOwnedByUser: boolean;
banner: string | undefined;
_deletionId: string | undefined;
}> { }> {
const clerk = await clerkClient(); const clerk = await clerkClient();
// Run queries in parallel // Run queries in parallel
@ -167,18 +172,33 @@ async function findCustomizationData(
]); ]);
let user: User | undefined = undefined; let user: User | undefined = undefined;
if (ownedServerData) { if (ownedServerData) {
try { try {
user = await clerk.users.getUser(ownedServerData?.author); user = await clerk.users.getUser(ownedServerData?.author);
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
if (customizationData || ownedServerData) { if (customizationData || ownedServerData) {
return { const baseData: {
...(customizationData as any), 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, isOwned: true,
isOwnedByUser: ownedServerData?.author === userId, isOwnedByUser: ownedServerData?.author === userId,
userProfilePicture: null, userProfilePicture: undefined,
colorMode: null,
customizationVersion: undefined,
_deletionId: undefined,
}; };
// @ts-ignore L
return baseData
} }
return { return {
isOwned: false, isOwned: false,
@ -187,17 +207,51 @@ async function findCustomizationData(
banner: undefined, banner: undefined,
discord: undefined, discord: undefined,
colorScheme: undefined, colorScheme: undefined,
colorMode: null,
customizationVersion: undefined,
userProfilePicture: undefined, userProfilePicture: undefined,
_deletionId: undefined,
}; };
} }
} }
if (customizationData || ownedServerData) { if (customizationData || ownedServerData) {
return { const baseData: {
...(customizationData as any), 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, isOwned: true,
isOwnedByUser: ownedServerData?.author === userId, 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, banner: undefined,
discord: undefined, discord: undefined,
colorScheme: undefined, colorScheme: undefined,
colorMode: null,
customizationVersion: undefined,
userProfilePicture: undefined, userProfilePicture: undefined,
_deletionId: undefined,
}; };
} }
async function findFavoriteData( async function findFavoriteData(
serverName: string, serverName: string,
userId: string | undefined, userId: string | undefined,
db: any, db: Db,
query: { query: {
maxFavoriteEntries?: string | string[]; maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[]; favoriteTimespanStart?: string | string[];
@ -246,16 +303,16 @@ async function findFavoriteData(
} }
async function fetchHistoryData( async function fetchHistoryData(
db: any, db: Db,
serverName: string, serverName: string,
query: { query: {
maxFavoriteEntries?: string | string[]; maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[]; favoriteTimespanStart?: string | string[];
favoriteTimespanEnd?: string | string[]; favoriteTimespanEnd?: string | string[];
}, },
) { ): Promise<{ date: string; favorites: number }[]> {
// Build query filter // 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 // Add date range filter if provided
if (query.favoriteTimespanStart && query.favoriteTimespanEnd) { if (query.favoriteTimespanStart && query.favoriteTimespanEnd) {
@ -279,14 +336,18 @@ async function fetchHistoryData(
cursor.limit(limit); 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( export async function findServerData(
server: string, server: string,
): Promise<{ exists: boolean; name: string }> { ): Promise<{ exists: boolean; name: string }> {
try { 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 // Check if the response is ok before parsing JSON
if (!response.ok) { if (!response.ok) {
@ -305,7 +366,7 @@ export async function findServerData(
async function findPlayerData( async function findPlayerData(
serverName: string, serverName: string,
db: any, db: Db,
query: { query: {
maxPlayerEntries?: string | string[]; maxPlayerEntries?: string | string[];
playerTimespanStart?: string | string[]; playerTimespanStart?: string | string[];
@ -316,7 +377,7 @@ async function findPlayerData(
const historyCollection = db.collection("history"); const historyCollection = db.collection("history");
// Build query filter // Build query filter
const filter: any = { server: serverName }; const filter: Filter<Document> = { server: serverName };
// Add date range filter if provided // Add date range filter if provided
if (query.playerTimespanStart && query.playerTimespanEnd) { if (query.playerTimespanStart && query.playerTimespanEnd) {
@ -348,10 +409,11 @@ async function findPlayerData(
} }
// Format the data to match the expected structure // Format the data to match the expected structure
type HistoryDocument = { date: Date; player_count?: number };
const formattedHistory = historically.map( const formattedHistory = historically.map(
(item: { date: string; player_count?: number }) => ({ (item) => ({
date: item.date, date: (item as HistoryDocument).date.toISOString(),
playerCount: item.player_count || 0, playerCount: (item as HistoryDocument).player_count || 0,
}), }),
); );
@ -362,7 +424,7 @@ async function findPlayerData(
async function findAchievements( async function findAchievements(
serverName: string, serverName: string,
db: any, db: Db,
query: { query: {
maxAchievementEntries?: string | string[]; maxAchievementEntries?: string | string[];
achievementTimespanStart?: string | string[]; achievementTimespanStart?: string | string[];
@ -373,12 +435,10 @@ async function findAchievements(
const achievementsCollection = db.collection("achievements"); const achievementsCollection = db.collection("achievements");
// Build query filter // Build query filter
const filter: any = { name: serverName }; const filter: Filter<Document> = { name: serverName };
// Add date range filter if provided // Add date range filter if provided
if (query.achievementTimespanStart && query.achievementTimespanEnd) { 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 = { filter.timestamp = {
$gte: new Date(Number(query.achievementTimespanStart)), $gte: new Date(Number(query.achievementTimespanStart)),
$lte: new Date(Number(query.achievementTimespanEnd)), $lte: new Date(Number(query.achievementTimespanEnd)),
@ -393,11 +453,17 @@ async function findAchievements(
historically = historically.slice(0, Number(query.maxAchievementEntries)); historically = historically.slice(0, Number(query.maxAchievementEntries));
} }
const currently: any[] = []; // Transform the data to match the expected shape
for (const a of historically) const transformedHistorically = historically.map(doc => ({
a.achievements.forEach((item: any, interval: number) => _id: doc._id.toString(),
currently.push({ interval, ...item }), 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 };
} }

@ -104,7 +104,7 @@ export default async function handler(
await collection.insertOne({ serverId: server, author: userId }); await collection.insertOne({ serverId: server, author: 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({ message: "Successfully owned server!" }); res.send({ message: "Successfully owned server!" });

@ -28,43 +28,48 @@
* OTHER DEALINGS IN THE SOFTWARE. * 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 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( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse, 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) { const { ok } = await fetch(
return res.status(401).json({ error: "Unauthorized" }); `https://discord.com/api/guilds/${discordServerId}/widget.json`,
} );
const { data } = req.body;
if (data === undefined) { if (!ok) return res.status(400).send({ error: "Invalid value" });
res.status(400).send({ message: "Couldn't find data" });
return;
}
const v = obj.parse(data); const { changeServer } = await checkOwnedServerMetadata(
for (const [key, value] of Object.entries(v)) { getAuth(req).userId ?? null,
(await clerkClient()).users.updateUserMetadata(userId, { mongo,
publicMetadata: { {
[key]: typeof value === "number" ? value.toString() : value, 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. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import type { NextApiRequest, NextApiResponse } from "next"; import { checkOwnedServerMetadata } from "@/lib/check-owned-server";
import { getAuth, clerkClient } from "@clerk/nextjs/server"; import { getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import { waitUntil } from "@vercel/functions"; import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse, res: NextApiResponse,
) { ) {
const { userId } = getAuth(req); try {
const { server: serverId } = req.query;
const mongo = new MongoClient(process.env.MONGO_DB as string);
if (!userId) { const { ownedServer, customizedServer, changeServer } =
return res.status(401).json({ error: "Unauthorized" }); 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. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getAuth, clerkClient } from "@clerk/nextjs/server"; import { getAuth, clerkClient } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
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 { code } = req.body; const { code } = req.query;
if (code == null) { if (code == null) {
res.status(400).send({ message: "Couldn't find data" }); 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" }); res.status(400).send({ message: "Couldn't find code" });
return; return;
} }
collection.findOneAndDelete({ code }); await collection.findOneAndDelete({ code });
const users = db.collection("claimed-users"); const users = db.collection("claimed-users");
await users.insertOne({ player: entry.player, userId }); 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 }); 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" ulidx "^2.4.1"
zod "~3.22.3" zod "~3.22.3"
input-otp@^1.2.4, input-otp@^1.4.2: input-otp@^1.4.2:
version "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== integrity sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==
inquirer@^12.3.0: inquirer@^12.3.0: