Compare commits

..

No commits in common. "7f77a4273ca17b708b27cd892457c362e7698da9" and "21d66742c64093462de20985ba7ada2e236ee544" have entirely different histories.

41 changed files with 358 additions and 1406 deletions

@ -1,6 +1,3 @@
{
"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"
]
"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 */"
}

@ -51,10 +51,6 @@ 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.4.2",
"input-otp": "^1.2.4",
"json-beautify": "^1.1.1",
"lodash": "^4.17.21",
"lucide-react": "^0.487.0",

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

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

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

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

@ -49,9 +49,11 @@ 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,7 +208,11 @@
--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,7 +34,6 @@ 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"] });
@ -45,7 +44,7 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body className={cn(inter.className, "bg-background font-sans")}>
<body className={inter.className}>
<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-[42px]" : "group-hover:ml-[42px]"
staticMode ? "ml-0" : "group-hover:ml-[42px]"
)}
>
{serverObject && (

@ -15,7 +15,7 @@ export function Footer() {
if (!hideFooterPages.includes(pathname ?? ""))
return (
<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">
<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">
<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">
<span className="block px-4 lg:-translate-y-12">
<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,7 +103,14 @@ export function ServerList() {
</h1>
<div className="flex items-center justify-between">
<span className="flex items-center">
<ModificationButton disabled={testModeEnabled} />
<Tooltip>
<TooltipTrigger>
<ModificationButton disabled={testModeEnabled} />
</TooltipTrigger>
<TooltipContent side="bottom">
{filterCount} modification(s) enabled
</TooltipContent>
</Tooltip>
<ServerTestModeSelector
testModeStatus={testModeStatus}
testModeEnabled={testModeEnabled}

@ -1,25 +0,0 @@
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?.replaceAll("<br />", "\n")}
{mhsfData.server?.customizationData.description}
</Markdown>
</div>
)}

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

@ -1,126 +0,0 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import {
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,9 +29,6 @@ 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";
@ -42,16 +39,15 @@ 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", () => {
@ -68,19 +64,28 @@ 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 = mhsfUser !== null && mhsfUser.user?.claimedUser !== null && mhsfUser.user?.claimedUser?.name === onlineData?.author;
const requirementFour = mhsfUser !== null && mhsfUser.user?.claimedUser !== null;
const requirementThree = claimedUser === onlineData?.author;
const requirementFour = claimedUser !== null;
const UploadDropzone = generateUploadDropzone<BannerUploaderRouter>({
url: `/api/v1/server/get/${minehutData._id}/settings/upload-banner`,
});
return (
<>
{children}
<MilkdownProvider>
<Drawer open={open} onOpenChange={(c) => {
serverData.refresh();
setOpen(c);
}}>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="p-4 !max-h-[700px] !h-[700px]">
<br />
{!serverData.server?.customizationData.isOwned ? (
@ -202,20 +207,14 @@ export function ServerEditorProvider({
</div>
<DrawerFooter>
<Button
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...",
},
);
}}
onClick={() =>
toast.promise(serverData.ownServer(), {
success: "Successfully owned server",
error:
"There was an error while linking this server. Please contact support.",
loading: "Linking server...",
})
}
disabled={
!(
requirementOne &&
@ -251,12 +250,6 @@ 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,13 +12,11 @@ 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 (
@ -74,7 +72,7 @@ export function ServerProvider({ serverId }: { serverId: string }) {
</div>
) : (
<div className="px-10">
<ServerEditorProvider serverData={mhsf} minehutData={server as ServerResponse} mhsfUser={mhsfUser}>
<ServerEditorProvider serverData={mhsf} minehutData={server as ServerResponse}>
<ReportingProvider server={mhsf}>
<ServerMainPage
server={server as ServerResponse}

@ -39,7 +39,6 @@ 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();
@ -47,9 +46,8 @@ export function ServerRows({ server, mhsfData }: { server: ServerResponse, mhsfD
return (
<span className="lg:grid lg:grid-cols-2 w-full gap-3">
{affiliates.includes(server.name) && <AffiliateRow />}
<MOTDRow server={server} mhsfData={mhsfData} />
<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} />

@ -1,235 +0,0 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { 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,139 +30,113 @@
import { Material } from "@/components/ui/material";
import {
Setting,
SettingContent,
SettingDescription,
SettingMeta,
SettingTitle,
Setting,
SettingContent,
SettingDescription,
SettingMeta,
SettingTitle,
} from "./setting";
import { ModeToggle } from "@/components/util/mode-toggle";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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();
const settingsStore = useSettingsStore();
const [fontFamily, setFontFamily] = useState("inter");
const [mcFont, setMcFont] = useState(true);
const [debugMode, setDebugMode] = useState(false);
useEffect(() => {
setFontFamily((settingsStore.get("font-family") ?? "inter") as string);
setMcFont((settingsStore.get("mc-font") === "true") as boolean);
setDebugMode((settingsStore.get("debug-mode") === "true") as boolean);
}, []);
useEffect(() => {
setFontFamily((settingsStore.get("font-family") ?? "inter") as string);
setMcFont((settingsStore.get("mc-font") === "true") as boolean);
setDebugMode((settingsStore.get("debug-mode") === "true") as boolean);
}, []);
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>
<SettingMeta>
<SettingTitle>Color Scheme</SettingTitle>
<SettingDescription>
Change the MHSF color scheme
</SettingDescription>
</SettingMeta>
<ModeToggle />
</SettingContent>
</Setting>
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Use Minecraft font</SettingTitle>
<SettingDescription>
Use Minecraft font for MOTD. Turning this off restores font
settings for MOTD's to a v1-like state.
</SettingDescription>
</SettingMeta>
<Switch
checked={mcFont}
onCheckedChange={(c) => {
settingsStore.set("mc-font", c, false);
setMcFont(c);
}}
/>
</SettingContent>
</Setting>
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Font</SettingTitle>
<SettingDescription>
Change the default font used in the interface.
</SettingDescription>
</SettingMeta>
<Select
defaultValue="inter"
value={fontFamily}
onValueChange={(c) => {
settingsStore.set("font-family", c, false);
window.dispatchEvent(new Event("font-family-change"));
setFontFamily(c);
}}
>
<SelectTrigger className="max-w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inter">Inter</SelectItem>
<SelectItem value="geist-sans">Geist Sans</SelectItem>
<SelectItem value="system-ui">System UI</SelectItem>
<SelectItem value="roboto">Roboto</SelectItem>
</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>
);
return (
<Material className="mt-6 grid gap-4">
<h2 className="text-xl font-semibold text-inherit">Appearance</h2>
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Color Scheme</SettingTitle>
<SettingDescription>
Change the MHSF color scheme
</SettingDescription>
</SettingMeta>
<ModeToggle />
</SettingContent>
</Setting>
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Use Minecraft font</SettingTitle>
<SettingDescription>
Use Minecraft font for MOTD. Turning this off restores font
settings for MOTD's to a v1-like state.
</SettingDescription>
</SettingMeta>
<Switch
checked={mcFont}
onCheckedChange={(c) => {
settingsStore.set("mc-font", c, false);
setMcFont(c);
}}
/>
</SettingContent>
</Setting>
<Setting>
<SettingContent>
<SettingMeta>
<SettingTitle>Font</SettingTitle>
<SettingDescription>
Change the default font used in the interface.
</SettingDescription>
</SettingMeta>
<Select
defaultValue="inter"
value={fontFamily}
onValueChange={(c) => {
settingsStore.set("font-family", c, false);
window.dispatchEvent(new Event("font-family-change"));
setFontFamily(c);
}}
>
<SelectTrigger className="max-w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inter">Inter</SelectItem>
<SelectItem value="geist-sans">Geist Sans</SelectItem>
<SelectItem value="system-ui">System UI</SelectItem>
<SelectItem value="roboto">Roboto</SelectItem>
</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>
);
}

@ -1,248 +0,0 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
"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,7 +40,6 @@ 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();
@ -102,7 +101,6 @@ 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 text-sm">
<p className="mb-3">
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 are
eligible for the beta.
make sure you use the Discord connection so we can check if you
eligibile 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 ?? discordData.username}
Discord linked as {discordData.global_name}
<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-100",
"bg-white fixed z-9",
className
)}
>

@ -1,77 +0,0 @@
"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,8 +77,6 @@ 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 = "pb1-2.0";
export const version = "2.0";

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

@ -110,7 +110,7 @@ export async function checkOwnedServerMetadata(
{ server: serverSelector.name ?? serverData.name },
],
},
{ $set: { serverId: serverSelector.id, customizationVersion: 2, ...changes } },
{ $set: 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 type { ClerkEmbeddedFilter } from "@/components/feat/server-list/modification/modification-action";
import { ClerkEmbeddedFilter } from "@/components/feat/server-list/modification/modification-action";
import { supportedFilters } from "../types/supportedFilters";
export type EmbeddedFilter = {
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]);
}, [data, filteredData, loading]);
useEffect(() => {
if (data.length === 0) {
window.dispatchEvent(new Event("update-modification-stack"));
} else setLoading(false);
}, [data, filteredData]);
}, [data, filteredData, loading]);
const testModeInit = (type: "filter" | "sort") => {
window.dispatchEvent(new Event("test-mode.enabled"));

@ -28,16 +28,17 @@
* OTHER DEALINGS IN THE SOFTWARE.
*/
import type { NextApiRequest, NextApiResponse } from "next";
import { 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.query;
const { code } = req.body;
if (code == null) {
res.status(400).send({ message: "Couldn't find data" });
@ -58,7 +59,7 @@ export default async function handler(
res.status(400).send({ message: "Couldn't find code" });
return;
}
await collection.findOneAndDelete({ code });
collection.findOneAndDelete({ code });
const users = db.collection("claimed-users");
await users.insertOne({ player: entry.player, userId });
@ -68,5 +69,9 @@ 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 });
}

@ -28,48 +28,43 @@
* 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,
) {
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" });
const { userId } = getAuth(req);
const { ok } = await fetch(
`https://discord.com/api/guilds/${discordServerId}/widget.json`,
);
if (!ok) return res.status(400).send({ error: "Invalid value" });
const { changeServer } = await checkOwnedServerMetadata(
getAuth(req).userId ?? null,
mongo,
{
id: serverId as string,
},
);
await changeServer({
discord: discordServerId as string,
});
} catch (error) {
return res.status(400).send({ error: error });
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
return res.send({ message: "Success" });
const { data } = req.body;
if (data === undefined) {
res.status(400).send({ message: "Couldn't find data" });
return;
}
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,
},
});
}
res.status(200).send({ message: "Success" });
}

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

@ -29,10 +29,9 @@
*/
import { getBackendProcedure } from "@/lib/backend-procedure";
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 { MHSFData } from "@/lib/types/data";
import { clerkClient, getAuth, User } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
export type RouteParams = {
@ -115,7 +114,6 @@ export default async function handler(
res.send({
server: {
favoriteData,
// @ts-ignore Also don't care what you think.
customizationData,
playerData,
achievements,
@ -145,18 +143,15 @@ async function findCustomizationData(
serverName: string,
serverId: string,
userId: string | undefined,
db: Db,
db: any,
): 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
@ -172,33 +167,18 @@ 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) {
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) ?? {},
return {
...(customizationData as any),
isOwned: true,
isOwnedByUser: ownedServerData?.author === userId,
userProfilePicture: undefined,
colorMode: null,
customizationVersion: undefined,
_deletionId: undefined,
userProfilePicture: null,
};
// @ts-ignore L
return baseData
}
return {
isOwned: false,
@ -207,51 +187,17 @@ async function findCustomizationData(
banner: undefined,
discord: undefined,
colorScheme: undefined,
colorMode: null,
customizationVersion: undefined,
userProfilePicture: undefined,
_deletionId: undefined,
};
}
}
if (customizationData || ownedServerData) {
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) ?? {},
return {
...(customizationData as any),
isOwned: true,
isOwnedByUser: ownedServerData?.author === userId,
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,
}),
userProfilePicture: userId ? user?.imageUrl : "no user",
};
}
@ -262,17 +208,14 @@ 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: Db,
db: any,
query: {
maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[];
@ -303,16 +246,16 @@ async function findFavoriteData(
}
async function fetchHistoryData(
db: Db,
db: any,
serverName: string,
query: {
maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[];
favoriteTimespanEnd?: string | string[];
},
): Promise<{ date: string; favorites: number }[]> {
) {
// Build query filter
const filter: { server: string; date?: { $gte: Date; $lte: Date } } = { server: serverName };
const filter: any = { server: serverName };
// Add date range filter if provided
if (query.favoriteTimespanStart && query.favoriteTimespanEnd) {
@ -336,18 +279,14 @@ async function fetchHistoryData(
cursor.limit(limit);
}
const results = await cursor.toArray();
return results.map(doc => ({
date: doc.date.toISOString(),
favorites: doc.favorites || 0
}));
return await cursor.toArray();
}
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) {
@ -366,7 +305,7 @@ export async function findServerData(
async function findPlayerData(
serverName: string,
db: Db,
db: any,
query: {
maxPlayerEntries?: string | string[];
playerTimespanStart?: string | string[];
@ -377,7 +316,7 @@ async function findPlayerData(
const historyCollection = db.collection("history");
// Build query filter
const filter: Filter<Document> = { server: serverName };
const filter: any = { server: serverName };
// Add date range filter if provided
if (query.playerTimespanStart && query.playerTimespanEnd) {
@ -409,11 +348,10 @@ async function findPlayerData(
}
// Format the data to match the expected structure
type HistoryDocument = { date: Date; player_count?: number };
const formattedHistory = historically.map(
(item) => ({
date: (item as HistoryDocument).date.toISOString(),
playerCount: (item as HistoryDocument).player_count || 0,
(item: { date: string; player_count?: number }) => ({
date: item.date,
playerCount: item.player_count || 0,
}),
);
@ -424,7 +362,7 @@ async function findPlayerData(
async function findAchievements(
serverName: string,
db: Db,
db: any,
query: {
maxAchievementEntries?: string | string[];
achievementTimespanStart?: string | string[];
@ -435,10 +373,12 @@ async function findAchievements(
const achievementsCollection = db.collection("achievements");
// Build query filter
const filter: Filter<Document> = { name: serverName };
const filter: any = { 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)),
@ -453,17 +393,11 @@ async function findAchievements(
historically = historically.slice(0, Number(query.maxAchievementEntries));
}
// Transform the data to match the expected shape
const transformedHistorically = historically.map(doc => ({
_id: doc._id.toString(),
name: doc.name,
achievements: doc.achievements || []
}));
const currently: Achievement[] = [];
const currently: any[] = [];
for (const a of historically)
for (const item of a.achievements)
currently.push(item);
a.achievements.forEach((item: any, interval: number) =>
currently.push({ interval, ...item }),
);
return { historically: transformedHistorically, currently };
return { historically, currently };
}

@ -104,7 +104,7 @@ export default async function handler(
await collection.insertOne({ serverId: server, author: userId });
// Close the database, but don't close this
// serverless instance until it happens
// serverless instance until it happens
waitUntil(client.close());
res.send({ message: "Successfully owned server!" });

@ -28,34 +28,25 @@
* 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 { getAuth, clerkClient } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import { waitUntil } from "@vercel/functions";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
try {
const { server: serverId } = req.query;
const mongo = new MongoClient(process.env.MONGO_DB as string);
const { userId } = getAuth(req);
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 });
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 users = db.collection("claimed-users");
return res.send((await users.findOne({ userId })) ?? {player: null});
}

@ -28,51 +28,34 @@
* OTHER DEALINGS IN THE SOFTWARE.
*/
import type { WithId } from "mongodb";
import { useEffect, useState } from "react";
import { NullLiteral } from "typescript";
import type { NextApiResponse, NextApiRequest } from "next";
import { MongoClient } from "mongodb";
import { getAuth } from "@clerk/nextjs/server";
import { waitUntil } from "@vercel/functions";
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;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { userId } = getAuth(req);
author: string;
}[];
claimedUser: { uuid: string; name: string } | null;
actions: {
linkAccount: string;
unlinkAccount: string;
};
};
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
const client = new MongoClient(process.env.MONGO_DB as string);
await client.connect();
export function useUser(): {
user: MHSFUser | null;
refresh: () => Promise<void>;
} {
const [user, setUser] = useState<MHSFUser | null>(null);
const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const collection = db.collection("favorites");
const find = await collection.find({ user: userId }).toArray();
useEffect(() => {
(async () => {
const user = await fetch("/api/v1/user/get");
const json = await user.json();
setUser(json);
})();
}, []);
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
return {
user,
refresh: async () => {
const user = await fetch("/api/v1/user/get");
const json = await user.json();
setUser(json);
},
};
if (find.length == 0) {
res.send({ favorites: [] });
} else {
res.send({ favorites: find[0].favorites });
}
}

@ -1,88 +0,0 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import type { 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.4.2:
input-otp@^1.2.4, input-otp@^1.4.2:
version "1.4.2"
resolved "https://registry.yarnpkg.com/input-otp/-/input-otp-1.4.2.tgz#f4d3d587d0f641729e55029b3b8c4870847f4f07"
resolved "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz"
integrity sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==
inquirer@^12.3.0: