feat: add waitlist and other stuffs

This commit is contained in:
dvelo 2025-05-04 22:43:15 -05:00
parent 7d0bb44568
commit 3ca0cadfbc
76 changed files with 4177 additions and 1140 deletions

@ -41,6 +41,10 @@ const nextConfig = {
{ {
protocol: "https", protocol: "https",
hostname: "avatars.githubusercontent.com" hostname: "avatars.githubusercontent.com"
},
{
protocol: "https",
hostname: "cdn.discordapp.com"
} }
], ],
}, },

@ -21,6 +21,11 @@
"@clerk/nextjs": "^6.9.2", "@clerk/nextjs": "^6.9.2",
"@emotion/is-prop-valid": "^1.3.0", "@emotion/is-prop-valid": "^1.3.0",
"@linear/sdk": "^31.0.0", "@linear/sdk": "^31.0.0",
"@milkdown/plugin-history": "^7.9.0",
"@milkdown/plugin-listener": "^7.9.0",
"@milkdown/preset-commonmark": "^7.9.0",
"@milkdown/react": "^7.9.0",
"@milkdown/theme-nord": "^7.9.0",
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@number-flow/react": "^0.5.7", "@number-flow/react": "^0.5.7",
"@radix-ui/react-aspect-ratio": "1.1.1", "@radix-ui/react-aspect-ratio": "1.1.1",

@ -28,15 +28,8 @@
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { SVGProps } from "react"; import { WaitlistDiscordNeeded } from "@/components/feat/waitlist/waitlist-discord-needed";
export const affiliates: { export default function OAuthNeedDiscord() {
name: string; return <WaitlistDiscordNeeded />
shop: string; }
line: string;
mode: string[];
otherLinks: {
name: string;
icon: (props: SVGProps<SVGSVGElement>) => JSX.Element;
}[];
}[] = [];

@ -0,0 +1,35 @@
/*
* 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 { WaitlistPage } from "@/components/feat/waitlist/waitlist-page";
export default function Waitlist() {
return <WaitlistPage />
}

@ -0,0 +1,35 @@
/*
* 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 { WaitlistReferralBeta } from "@/components/feat/waitlist/waitlist-referral-beta";
export default function ReferralBeta() {
return <WaitlistReferralBeta />
}

@ -38,6 +38,7 @@ import { ArrowLeft } from "lucide-react";
import { useQueryState } from "nuqs"; import { useQueryState } from "nuqs";
import { use } from "react"; import { use } from "react";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { invertHex } from "../../page";
export default function ModificationPage({ export default function ModificationPage({
params, params,
@ -48,7 +49,6 @@ export default function ModificationPage({
const [backRoute] = useQueryState("b", { const [backRoute] = useQueryState("b", {
defaultValue: "/servers/embedded/sl-modification-frame", defaultValue: "/servers/embedded/sl-modification-frame",
}); });
console.log(mod);
const categoryObj = serverModDB.find( const categoryObj = serverModDB.find(
(c) => c.displayTitle === atob(decodeURIComponent(category)) (c) => c.displayTitle === atob(decodeURIComponent(category))
); );
@ -65,7 +65,7 @@ export default function ModificationPage({
style={{ backgroundColor: modObj?.color }} style={{ backgroundColor: modObj?.color }}
> >
<Link href={backRoute}> <Link href={backRoute}>
<ArrowLeft /> <ArrowLeft style={{color: invertHex(modObj?.color ?? "")}} />
</Link> </Link>
</div> </div>

@ -65,6 +65,7 @@ import { useQueryState } from "nuqs";
import { use } from "react"; import { use } from "react";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { toast } from "sonner"; import { toast } from "sonner";
import { invertHex } from "../../../page";
export default function ModificationPage({ export default function ModificationPage({
params, params,
@ -78,7 +79,6 @@ export default function ModificationPage({
const [backRoute] = useQueryState("b", { const [backRoute] = useQueryState("b", {
defaultValue: "/servers/embedded/sl-modification-frame", defaultValue: "/servers/embedded/sl-modification-frame",
}); });
console.log(mod);
const modIndex = ( const modIndex = (
(user?.unsafeMetadata (user?.unsafeMetadata
.activatedModifications as ClerkCustomActivatedModification[]) ?? [] .activatedModifications as ClerkCustomActivatedModification[]) ?? []
@ -109,7 +109,7 @@ export default function ModificationPage({
style={{ backgroundColor: modObj?.color }} style={{ backgroundColor: modObj?.color }}
> >
<Link href={backRoute}> <Link href={backRoute}>
<ArrowLeft /> <ArrowLeft color={invertHex(modObj?.color)} />
</Link> </Link>
</div> </div>
@ -120,22 +120,27 @@ export default function ModificationPage({
you proud?) you proud?)
</Markdown> </Markdown>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Button className="mt-2" onClick={async () => { <Button
const newModObj = { className="mt-2"
...modObj, onClick={async () => {
active: !modObj.active const newModObj = {
} ...modObj,
const modificationArray = (user?.unsafeMetadata active: !modObj.active,
.activatedModifications as ClerkCustomActivatedModification[]) ?? []; };
modificationArray[modIndex] = newModObj; const modificationArray =
await user?.update({ (user?.unsafeMetadata
unsafeMetadata: { .activatedModifications as ClerkCustomActivatedModification[]) ??
...user.unsafeMetadata, [];
activatedModifications: modificationArray modificationArray[modIndex] = newModObj;
} await user?.update({
}); unsafeMetadata: {
communicator.send("rerender-servers", {}); ...user.unsafeMetadata,
}}> activatedModifications: modificationArray,
},
});
communicator.send("rerender-servers", {});
}}
>
{modObj?.active ? "Disable" : "Enable"} {modObj?.active ? "Disable" : "Enable"}
</Button> </Button>
<DropdownMenu> <DropdownMenu>

@ -51,7 +51,6 @@ export default async function ServerListCategoryFrame({
const categoryObj = serverModDB.find( const categoryObj = serverModDB.find(
(c) => c.displayTitle === atob(decodeURIComponent(category)), (c) => c.displayTitle === atob(decodeURIComponent(category)),
); );
``
return ( return (
<main className=" p-4"> <main className=" p-4">
<h1 className="text-xl font-bold w-full flex items-center gap-2"> <h1 className="text-xl font-bold w-full flex items-center gap-2">
@ -78,7 +77,10 @@ export default async function ServerListCategoryFrame({
)} )}
style={{ backgroundColor: m.color }} style={{ backgroundColor: m.color }}
> >
<m.icon className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center" /> <m.icon
className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center"
color={invertHex(m.color)}
/>
</div> </div>
<span className="text-sm text-center w-full flex items-center justify-center"> <span className="text-sm text-center w-full flex items-center justify-center">
{m.name} {m.name}
@ -95,3 +97,27 @@ export default async function ServerListCategoryFrame({
</main> </main>
); );
} }
export function invertHex(hex: string) {
if (hex.indexOf("#") === 0) {
hex = hex.slice(1);
}
// convert 3-digit hex to 6-digits.
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
if (hex.length !== 6) {
throw new Error("Invalid HEX color.");
}
// invert color components
const r = (255 - parseInt(hex.slice(0, 2), 16)).toString(16),
g = (255 - parseInt(hex.slice(2, 4), 16)).toString(16),
b = (255 - parseInt(hex.slice(4, 6), 16)).toString(16);
// pad each with zeros and return
return "#" + padZero(r) + padZero(g) + padZero(b);
}
function padZero(str: string, len: number) {
len = len || 2;
const zeros = new Array(len).join("0");
return (zeros + str).slice(-len);
}

@ -39,6 +39,7 @@ import { Button } from "@/components/ui/button";
import { useRouter } from "@/lib/useRouter"; import { useRouter } from "@/lib/useRouter";
import { SignedIn, useUser } from "@clerk/nextjs"; import { SignedIn, useUser } from "@clerk/nextjs";
import { ClerkCustomActivatedModification } from "@/components/feat/server-list/modification/modification-file-creation-dialog"; import { ClerkCustomActivatedModification } from "@/components/feat/server-list/modification/modification-file-creation-dialog";
import { invertHex } from "./category/[category]/page";
export default function ServerListModificationFrame() { export default function ServerListModificationFrame() {
const router = useRouter(); const router = useRouter();
@ -81,7 +82,7 @@ export default function ServerListModificationFrame() {
</Link> </Link>
</h2> </h2>
<div className="grid grid-cols-6 lg:grid-cols-3 gap-2"> <div className="grid grid-cols-6 lg:grid-cols-3 gap-2">
{c.entries.map((m) => ( {c.entries.slice(0, 6).map((m) => (
<Material <Material
elevation="high" elevation="high"
className="p-2 hover:drop-shadow-card-hover cursor-pointer" className="p-2 hover:drop-shadow-card-hover cursor-pointer"
@ -96,7 +97,10 @@ export default function ServerListModificationFrame() {
className="w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center" className="w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center"
style={{ backgroundColor: m.color }} style={{ backgroundColor: m.color }}
> >
<m.icon className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center" /> <m.icon
className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center"
color={invertHex(m.color)}
/>
</div> </div>
<span className="text-sm text-center w-full flex items-center justify-center"> <span className="text-sm text-center w-full flex items-center justify-center">
{m.name} {m.name}
@ -124,7 +128,10 @@ export default function ServerListModificationFrame() {
className="w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center" className="w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center"
style={{ backgroundColor: m.color }} style={{ backgroundColor: m.color }}
> >
<Binary className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center" /> <Binary
className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center"
color={invertHex(m.color)}
/>
</div> </div>
<span className="text-sm text-center w-full flex items-center justify-center"> <span className="text-sm text-center w-full flex items-center justify-center">
{m.friendlyName} {m.friendlyName}

@ -72,13 +72,6 @@
--sidebar-border: 220 13% 91%; --sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
*,
::before,
::after {
/* Workaround for Tailwind being stupid */
border-color: hsl(214.3 31.8% 91.4%);
}
--sidebar: hsl(0 0% 98%); --sidebar: hsl(0 0% 98%);
} }
@ -241,6 +234,19 @@
} }
} }
::selection {
background: #7f600060
}
.rainbow-tag {
background: linear-gradient(45deg, rgba(255, 0, 0, 1) 0%, rgba(255, 154, 0, 1) 10%, rgba(208, 222, 33, 1) 20%, rgba(79, 220, 74, 1) 30%, rgba(63, 218, 216, 1) 40%, rgba(47, 201, 226, 1) 50%, rgba(28, 127, 238, 1) 60%, rgba(95, 21, 242, 1) 70%, rgba(186, 12, 248, 1) 80%, rgba(251, 7, 217, 1) 90%, rgba(255, 0, 0, 1) 100%)
}
.shiki {
@apply min-w-full
}
.loading-shimmer { .loading-shimmer {
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
animation-duration: 2s; animation-duration: 2s;

@ -52,7 +52,7 @@ export default function RootLayout({
title="JavaScript is required for MHSF" title="JavaScript is required for MHSF"
description="MHSF cannot grab servers or do other external requests without JavaScript." description="MHSF cannot grab servers or do other external requests without JavaScript."
> >
<Link href="https://www.enable-javascript.com/" noExtraIcons> <Link href="https://www.enable-javascript.com/" noextraicons>
<Button>Here's how</Button> <Button>Here's how</Button>
</Link> </Link>
</Placeholder> </Placeholder>

@ -99,9 +99,7 @@ export default function Embed({ params }: { params: { server: string } }) {
<div className="px-4 pt-2 flex items-center group overflow-hidden"> <div className="px-4 pt-2 flex items-center group overflow-hidden">
<div <div
className={cn( className={cn(
staticMode ? "block" : "opacity-0 group-hover:opacity-100", staticMode ? "block" : "opacity-0 group-hover:opacity-100 transform transition-all duration-300 ease-in-out group-hover:translate-x-0 -translate-x-full",
"transform transition-all duration-300 ease-in-out",
"group-hover:translate-x-0 -translate-x-full",
"absolute left-[10px] w-0 group-hover:w-[64px]" "absolute left-[10px] w-0 group-hover:w-[64px]"
)} )}
> >

@ -58,7 +58,7 @@ export function Footer() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<Link href="https://t.mhsf.app/d/m" noExtraIcons> <Link href="https://t.mhsf.app/d/m" noextraicons>
<DropdownMenuItem className="py-2 flex items-center gap-2"> <DropdownMenuItem className="py-2 flex items-center gap-2">
<Image className="max-w-[30px] max-h-[30px] rounded border border-muted-foreground" src="https://avatars.githubusercontent.com/u/16529253?s=200&v=4" alt="Minehut" width={30} height={30} /> <Image className="max-w-[30px] max-h-[30px] rounded border border-muted-foreground" src="https://avatars.githubusercontent.com/u/16529253?s=200&v=4" alt="Minehut" width={30} height={30} />
<span className="block"> <span className="block">
@ -67,7 +67,7 @@ export function Footer() {
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
<Link href="https://t.mhsf.app/d/u" noExtraIcons> <Link href="https://t.mhsf.app/d/u" noextraicons>
<DropdownMenuItem className="py-2 flex items-center gap-2"> <DropdownMenuItem className="py-2 flex items-center gap-2">
<BrandingGenericIcon className="max-w-[30px] max-h-[30px] rounded border border-muted-foreground" width={30} height={30} /> <BrandingGenericIcon className="max-w-[30px] max-h-[30px] rounded border border-muted-foreground" width={30} height={30} />
<span className="block"> <span className="block">
@ -78,7 +78,7 @@ export function Footer() {
</Link> </Link>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Link href="https://github.com/DeveloLongScript/MHSF" noExtraIcons> <Link href="https://github.com/DeveloLongScript/MHSF" noextraicons>
<Button variant="tertiary" size="square-md" className="flex items-center"> <Button variant="tertiary" size="square-md" className="flex items-center">
<Github className="w-[1.25em] h-[1.25em]" /> <Github className="w-[1.25em] h-[1.25em]" />
</Button> </Button>
@ -89,7 +89,7 @@ export function Footer() {
</div> </div>
</div> </div>
<span className="block px-4 -translate-y-12"> <span className="block px-4 lg:-translate-y-12">
<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,

@ -27,7 +27,7 @@ export function FooterStatus() {
return ( return (
<Link <Link
href={`https://${statusURL as string}${determineIfOutage() ? `/incident/${determineWhatOutage()?.id}` : ""}`} href={`https://${statusURL as string}${determineIfOutage() ? `/incident/${determineWhatOutage()?.id}` : ""}`}
noExtraIcons noextraicons
target="_blank" target="_blank"
> >
<Button variant="tertiary"> <Button variant="tertiary">

@ -57,62 +57,61 @@ export const brandingIconClipboard = `<svg width="266" height="265" viewBox="0 0
* @returns A JSX element representing the colorful branding icon. * @returns A JSX element representing the colorful branding icon.
*/ */
export function BrandingColorfulIcon(props: SVGProps<SVGSVGElement>) { export function BrandingColorfulIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
width="266" xmlns="http://www.w3.org/2000/svg"
height="265" width="266"
viewBox="0 0 266 265" height="265"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 266 265"
{...props} >
> <rect
<rect width="264.939"
x="0.524048" height="264.939"
width="264.939" x="0.524"
height="264.939" fill="url(#paint0_linear_1_19)"
rx="66" rx="66"
fill="url(#paint0_linear_1_19)" />
/> <path
<path stroke="#fff"
d="M104.513 123.27H94.8717C92.3148 123.27 89.8626 122.254 88.0546 120.446C86.2466 118.638 85.2309 116.186 85.2309 113.629V94.3476C85.2309 91.7907 86.2466 89.3385 88.0546 87.5305C89.8626 85.7225 92.3148 84.7068 94.8717 84.7068H171.998C174.555 84.7068 177.007 85.7225 178.815 87.5305C180.623 89.3385 181.639 91.7907 181.639 94.3476V113.629C181.639 116.186 180.623 118.638 178.815 120.446C177.007 122.254 174.555 123.27 171.998 123.27H162.357M104.513 142.552H94.8717C92.3148 142.552 89.8626 143.567 88.0546 145.376C86.2466 147.184 85.2309 149.636 85.2309 152.193V171.474C85.2309 174.031 86.2466 176.483 88.0546 178.291C89.8626 180.099 92.3148 181.115 94.8717 181.115H171.998C174.555 181.115 177.007 180.099 178.815 178.291C180.623 176.483 181.639 174.031 181.639 171.474V152.193C181.639 149.636 180.623 147.184 178.815 145.376C177.007 143.567 174.555 142.552 171.998 142.552H162.357M104.513 103.988H104.561M104.513 161.833H104.561M138.255 103.988L118.974 132.911H147.896L128.615 161.833" strokeLinecap="round"
stroke="white" strokeLinejoin="round"
stroke-width="10" strokeWidth="10"
stroke-linecap="round" d="M104.513 123.27h-9.641a9.64 9.64 0 0 1-9.641-9.641V94.348a9.64 9.64 0 0 1 9.64-9.641h77.127a9.64 9.64 0 0 1 9.641 9.64v19.282a9.64 9.64 0 0 1-9.641 9.641h-9.641m-57.844 19.282h-9.641a9.64 9.64 0 0 0-9.64 9.641v19.281a9.64 9.64 0 0 0 9.64 9.641h77.126a9.64 9.64 0 0 0 9.641-9.641v-19.281a9.64 9.64 0 0 0-9.641-9.641h-9.641m-57.844-38.564h.048m-.048 57.845h.048m33.694-57.845-19.281 28.923h28.922l-19.281 28.922"
stroke-linejoin="round" />
/> <circle
<circle cx="132.993"
cx="132.993" cy="132.469"
cy="132.469" r="91.378"
r="91.3779" stroke="url(#paint1_linear_1_19)"
stroke="url(#paint1_linear_1_19)" strokeWidth="8"
stroke-width="8" />
/> <defs>
<defs> <linearGradient
<linearGradient id="paint0_linear_1_19"
id="paint0_linear_1_19" x1="107.824"
x1="107.824" x2="230.579"
y1="54.754" y1="54.754"
x2="230.579" y2="225.198"
y2="225.198" gradientUnits="userSpaceOnUse"
gradientUnits="userSpaceOnUse" >
> <stop stopColor="#007BFF"/>
<stop stop-color="#007BFF" /> <stop offset="1" stopColor="#BF00FF" stopOpacity="0.5"/>
<stop offset="1" stop-color="#BF00FF" stop-opacity="0.5" /> </linearGradient>
</linearGradient> <linearGradient
<linearGradient id="paint1_linear_1_19"
id="paint1_linear_1_19" x1="132.993"
x1="132.993" x2="132.993"
y1="37.0914" y1="37.091"
x2="132.993" y2="227.847"
y2="227.847" gradientUnits="userSpaceOnUse"
gradientUnits="userSpaceOnUse" >
> <stop stopColor="#EFEC32"/>
<stop stop-color="#EFEC32" /> <stop offset="1" stopColor="#98FF60"/>
<stop offset="1" stop-color="#98FF60" /> </linearGradient>
</linearGradient> </defs>
</defs> </svg>
</svg> );
);
} }
/** /**
* Returns the optional Pride icon * Returns the optional Pride icon
@ -123,71 +122,71 @@ export function BrandingColorfulIcon(props: SVGProps<SVGSVGElement>) {
* @returns A JSX element representing the branding icon. * @returns A JSX element representing the branding icon.
*/ */
export function BrandingPrideIcon(props: SVGProps<SVGSVGElement>) { export function BrandingPrideIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
width="265" xmlns="http://www.w3.org/2000/svg"
height="265" width="265"
viewBox="0 0 265 265" height="265"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 265 265"
{...props} {...props}
> >
<rect <rect
width="264.939" width="264.939"
height="264.939" height="264.939"
rx="66" fill="url(#paint0_linear_1_30)"
fill="url(#paint0_linear_1_30)" rx="66"
/> />
<path <path
d="M103.988 123.27H94.3476C91.7907 123.27 89.3385 122.254 87.5305 120.446C85.7225 118.638 84.7068 116.186 84.7068 113.629V94.3476C84.7068 91.7907 85.7225 89.3385 87.5305 87.5305C89.3385 85.7225 91.7907 84.7068 94.3476 84.7068H171.474C174.031 84.7068 176.483 85.7225 178.291 87.5305C180.099 89.3385 181.115 91.7907 181.115 94.3476V113.629C181.115 116.186 180.099 118.638 178.291 120.446C176.483 122.254 174.031 123.27 171.474 123.27H161.833M103.988 142.552H94.3476C91.7907 142.552 89.3385 143.567 87.5305 145.376C85.7225 147.184 84.7068 149.636 84.7068 152.193V171.474C84.7068 174.031 85.7225 176.483 87.5305 178.291C89.3385 180.099 91.7907 181.115 94.3476 181.115H171.474C174.031 181.115 176.483 180.099 178.291 178.291C180.099 176.483 181.115 174.031 181.115 171.474V152.193C181.115 149.636 180.099 147.184 178.291 145.376C176.483 143.567 174.031 142.552 171.474 142.552H161.833M103.988 103.988H104.037M103.988 161.833H104.037M137.731 103.988L118.45 132.911H147.372L128.091 161.833" stroke="#fff"
stroke="white" strokeLinecap="round"
stroke-width="10" strokeLinejoin="round"
stroke-linecap="round" strokeWidth="10"
stroke-linejoin="round" d="M103.988 123.27h-9.64a9.64 9.64 0 0 1-9.641-9.641V94.348a9.64 9.64 0 0 1 9.64-9.641h77.127a9.64 9.64 0 0 1 9.641 9.64v19.282a9.64 9.64 0 0 1-9.641 9.641h-9.641m-57.845 19.282h-9.64a9.64 9.64 0 0 0-9.64 9.641v19.281a9.64 9.64 0 0 0 9.64 9.641h77.126a9.64 9.64 0 0 0 9.641-9.641v-19.281a9.64 9.64 0 0 0-9.641-9.641h-9.641m-57.845-38.564h.049m-.049 57.845h.049m33.694-57.845-19.281 28.923h28.922l-19.281 28.922"
/> />
<circle <circle
cx="132.469" cx="132.469"
cy="132.469" cy="132.469"
r="91.3779" r="91.378"
stroke="url(#paint1_linear_1_30)" stroke="url(#paint1_linear_1_30)"
stroke-width="8" strokeWidth="8"
/> />
<defs> <defs>
<linearGradient <linearGradient
id="paint0_linear_1_30" id="paint0_linear_1_30"
x1="51.6631" x1="51.663"
y1="26.9354" x2="222.549"
x2="222.549" y1="26.935"
y2="213.717" y2="213.717"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#FF0000" /> <stop stopColor="red" />
<stop offset="0.110405" stop-color="#FF6200" /> <stop offset="0.11" stopColor="#FF6200" />
<stop offset="0.225785" stop-color="#FFAE00" /> <stop offset="0.226" stopColor="#FFAE00" />
<stop offset="0.326294" stop-color="#FFD500" /> <stop offset="0.326" stopColor="#FFD500" />
<stop offset="0.422381" stop-color="#99EA00" /> <stop offset="0.422" stopColor="#99EA00" />
<stop offset="0.498373" stop-color="#4DF457" /> <stop offset="0.498" stopColor="#4DF457" />
<stop offset="0.593491" stop-color="#26D3AB" /> <stop offset="0.593" stopColor="#26D3AB" />
<stop offset="0.699814" stop-color="#13A9D5" /> <stop offset="0.7" stopColor="#13A9D5" />
<stop offset="0.805673" stop-color="#A200FF" /> <stop offset="0.806" stopColor="#A200FF" />
<stop offset="0.884464" stop-color="#C62AEB" /> <stop offset="0.884" stopColor="#C62AEB" />
<stop offset="0.957056" stop-color="white" /> <stop offset="0.957" stopColor="#fff" />
<stop offset="0.997383" /> <stop offset="0.997" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="paint1_linear_1_30" id="paint1_linear_1_30"
x1="132.469" x1="132.469"
y1="37.0914" x2="132.469"
x2="132.469" y1="37.091"
y2="227.847" y2="227.847"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#EFEC32" /> <stop stopColor="#EFEC32" />
<stop offset="1" stop-color="#98FF60" /> <stop offset="1" stopColor="#98FF60" />
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
); );
} }
/** /**
@ -203,170 +202,135 @@ export function BrandingPrideIcon(props: SVGProps<SVGSVGElement>) {
* @returns A JSX element representing the branding icon. * @returns A JSX element representing the branding icon.
*/ */
export function BrandingGenericIcon(props: SVGProps<SVGSVGElement>) { export function BrandingGenericIcon(props: SVGProps<SVGSVGElement>) {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
if (resolvedTheme === "dark") { if (resolvedTheme === "dark") {
return ( return (
<svg <svg
width="265" xmlns="http://www.w3.org/2000/svg"
height="266" width="265"
viewBox="0 0 265 266" height="266"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 265 266"
{...props} {...props}
> >
<rect <rect
x="0.0612793" width="264.939"
y="0.86145" height="264.939"
width="264.939" x="0.061"
height="264.939" y="0.861"
rx="66" fill="url(#paint0_linear_1_20)"
fill="url(#paint0_linear_1_20)" rx="66"
/> />
<path <path
d="M104.05 124.132H94.4089C91.852 124.132 89.3998 123.116 87.5918 121.308C85.7838 119.5 84.7681 117.048 84.7681 114.491V95.2091C84.7681 92.6522 85.7838 90.2 87.5918 88.392C89.3998 86.584 91.852 85.5683 94.4089 85.5683H171.536C174.092 85.5683 176.545 86.584 178.353 88.392C180.161 90.2 181.176 92.6522 181.176 95.2091V114.491C181.176 117.048 180.161 119.5 178.353 121.308C176.545 123.116 174.092 124.132 171.536 124.132H161.895M104.05 143.413H94.4089C91.852 143.413 89.3998 144.429 87.5918 146.237C85.7838 148.045 84.7681 150.497 84.7681 153.054V172.336C84.7681 174.893 85.7838 177.345 87.5918 179.153C89.3998 180.961 91.852 181.977 94.4089 181.977H171.536C174.092 181.977 176.545 180.961 178.353 179.153C180.161 177.345 181.176 174.893 181.176 172.336V153.054C181.176 150.497 180.161 148.045 178.353 146.237C176.545 144.429 174.092 143.413 171.536 143.413H161.895M104.05 104.85H104.098M104.05 162.695H104.098M137.793 104.85L118.511 133.772H147.433L128.152 162.695" stroke="#fff"
stroke="white" strokeLinecap="round"
stroke-width="10" strokeLinejoin="round"
stroke-linecap="round" strokeWidth="10"
stroke-linejoin="round" d="M104.05 124.132h-9.641a9.64 9.64 0 0 1-9.64-9.641V95.209a9.64 9.64 0 0 1 9.64-9.64h77.127a9.64 9.64 0 0 1 9.64 9.64v19.282a9.64 9.64 0 0 1-9.64 9.641h-9.641m-57.845 19.281h-9.641a9.64 9.64 0 0 0-9.64 9.641v19.282a9.64 9.64 0 0 0 9.64 9.641h77.127a9.64 9.64 0 0 0 9.64-9.641v-19.282a9.64 9.64 0 0 0-9.64-9.641h-9.641M104.05 104.85h.048m-.048 57.845h.048m33.695-57.845-19.282 28.922h28.922l-19.281 28.923"
/> />
<circle <circle
cx="132.531" cx="132.531"
cy="133.331" cy="133.331"
r="91.3779" r="91.378"
stroke="url(#paint1_linear_1_20)" stroke="url(#paint1_linear_1_20)"
stroke-width="8" strokeWidth="8"
/> />
<defs> <defs>
<linearGradient <linearGradient
id="paint0_linear_1_20" id="paint0_linear_1_20"
x1="107.361" x1="107.361"
y1="55.6155" x2="230.116"
x2="230.116" y1="55.615"
y2="226.059" y2="226.059"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop /> <stop />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="paint1_linear_1_20" id="paint1_linear_1_20"
x1="132.531" x1="132.531"
y1="37.9529" x2="132.531"
x2="132.531" y1="37.953"
y2="228.709" y2="228.709"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#EFEC32" /> <stop stopColor="#EFEC32" />
<stop offset="1" stop-color="#98FF60" /> <stop offset="1" stopColor="#98FF60" />
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
); );
} }
return ( return (
<svg <svg
width="265" xmlns="http://www.w3.org/2000/svg"
height="265" width="265"
viewBox="0 0 265 265" height="265"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 265 265"
{...props} {...props}
> >
<rect <rect
x="0.0612793" width="264.939"
width="264.939" height="264.939"
height="264.939" x="0.061"
rx="66" fill="url(#paint0_linear_1_25)"
fill="url(#paint0_linear_1_25)" rx="66"
/> />
<path <path
d="M104.05 123.27H94.4089C91.852 123.27 89.3998 122.254 87.5918 120.446C85.7838 118.638 84.7681 116.186 84.7681 113.629V94.3476C84.7681 91.7907 85.7838 89.3385 87.5918 87.5305C89.3998 85.7225 91.852 84.7068 94.4089 84.7068H171.536C174.092 84.7068 176.545 85.7225 178.353 87.5305C180.161 89.3385 181.176 91.7907 181.176 94.3476V113.629C181.176 116.186 180.161 118.638 178.353 120.446C176.545 122.254 174.092 123.27 171.536 123.27H161.895M104.05 142.552H94.4089C91.852 142.552 89.3998 143.567 87.5918 145.376C85.7838 147.184 84.7681 149.636 84.7681 152.193V171.474C84.7681 174.031 85.7838 176.483 87.5918 178.291C89.3998 180.099 91.852 181.115 94.4089 181.115H171.536C174.092 181.115 176.545 180.099 178.353 178.291C180.161 176.483 181.176 174.031 181.176 171.474V152.193C181.176 149.636 180.161 147.184 178.353 145.376C176.545 143.567 174.092 142.552 171.536 142.552H161.895M104.05 103.988H104.098M104.05 161.833H104.098M137.793 103.988L118.511 132.911H147.433L128.152 161.833" stroke="#000"
stroke="black" strokeLinecap="round"
stroke-width="10" strokeLinejoin="round"
stroke-linecap="round" strokeWidth="10"
stroke-linejoin="round" d="M104.05 123.27h-9.641a9.64 9.64 0 0 1-9.64-9.641V94.348a9.64 9.64 0 0 1 9.64-9.641h77.127a9.64 9.64 0 0 1 9.64 9.64v19.282a9.64 9.64 0 0 1-9.64 9.641h-9.641m-57.845 19.282h-9.641a9.64 9.64 0 0 0-9.64 9.641v19.281a9.64 9.64 0 0 0 9.64 9.641h77.127a9.64 9.64 0 0 0 9.64-9.641v-19.281a9.64 9.64 0 0 0-9.64-9.641h-9.641m-57.845-38.564h.048m-.048 57.845h.048m33.695-57.845-19.282 28.923h28.922l-19.281 28.922"
/> />
<circle <circle
cx="132.531" cx="132.531"
cy="132.469" cy="132.469"
r="91.3779" r="91.378"
stroke="url(#paint1_linear_1_25)" stroke="url(#paint1_linear_1_25)"
stroke-width="8" strokeWidth="8"
/> />
<defs> <defs>
<linearGradient <linearGradient
id="paint0_linear_1_25" id="paint0_linear_1_25"
x1="107.361" x1="107.361"
y1="54.754" x2="230.116"
x2="230.116" y1="54.754"
y2="225.198" y2="225.198"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="white" /> <stop stopColor="#fff" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
id="paint1_linear_1_25" id="paint1_linear_1_25"
x1="132.531" x1="132.531"
y1="37.0914" x2="132.531"
x2="132.531" y1="37.091"
y2="227.847" y2="227.847"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
> >
<stop stop-color="#EFEC32" /> <stop stopColor="#EFEC32" />
<stop offset="1" stop-color="#98FF60" /> <stop offset="1" stopColor="#98FF60" />
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
); );
}
export function BadgeOfAffiliation(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="81"
height="81"
viewBox="0 0 81 81"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect width="81" height="81" rx="34" fill="url(#paint0_linear_1_12)" />
<path
d="M29.5 36.5H26C25.0717 36.5 24.1815 36.1313 23.5251 35.4749C22.8687 34.8185 22.5 33.9283 22.5 33V26C22.5 25.0717 22.8687 24.1815 23.5251 23.5251C24.1815 22.8687 25.0717 22.5 26 22.5H54C54.9283 22.5 55.8185 22.8687 56.4749 23.5251C57.1313 24.1815 57.5 25.0717 57.5 26V33C57.5 33.9283 57.1313 34.8185 56.4749 35.4749C55.8185 36.1313 54.9283 36.5 54 36.5H50.5M29.5 43.5H26C25.0717 43.5 24.1815 43.8687 23.5251 44.5251C22.8687 45.1815 22.5 46.0717 22.5 47V54C22.5 54.9283 22.8687 55.8185 23.5251 56.4749C24.1815 57.1313 25.0717 57.5 26 57.5H54C54.9283 57.5 55.8185 57.1313 56.4749 56.4749C57.1313 55.8185 57.5 54.9283 57.5 54V47C57.5 46.0717 57.1313 45.1815 56.4749 44.5251C55.8185 43.8687 54.9283 43.5 54 43.5H50.5M29.5 29.5H29.5175M29.5 50.5H29.5175M41.75 29.5L34.75 40H45.25L38.25 50.5"
stroke="white"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
<defs>
<linearGradient
id="paint0_linear_1_12"
x1="40.5"
y1="0"
x2="40.5"
y2="81"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#37B14F" />
<stop offset="1" stop-color="#3D4B17" />
</linearGradient>
</defs>
</svg>
);
} }
export const Discord = (props: SVGProps<SVGSVGElement>) => ( export const Discord = (props: SVGProps<SVGSVGElement>) => (
<svg <svg
viewBox="0 0 256 199" viewBox="0 0 256 199"
width="1em" width="1em"
height="1em" height="1em"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid" preserveAspectRatio="xMidYMid"
{...props} {...props}
> >
<path <path
d="M216.856 16.597A208.502 208.502 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046-19.692-2.961-39.203-2.961-58.533 0-1.832-4.4-4.55-9.933-6.846-14.046a207.809 207.809 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161.094 161.094 0 0 0 79.735 175.3a136.413 136.413 0 0 1-21.846-10.632 108.636 108.636 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a131.66 131.66 0 0 0 5.355 4.237 136.07 136.07 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848 21.142-6.58 42.646-16.637 64.815-33.213 5.316-56.288-9.08-105.09-38.056-148.36ZM85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2c12.867 0 23.236 11.804 23.015 26.2.02 14.375-10.148 26.18-23.015 26.18Zm85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2 0 14.375-10.148 26.18-23.015 26.18Z" d="M216.856 16.597A208.502 208.502 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046-19.692-2.961-39.203-2.961-58.533 0-1.832-4.4-4.55-9.933-6.846-14.046a207.809 207.809 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161.094 161.094 0 0 0 79.735 175.3a136.413 136.413 0 0 1-21.846-10.632 108.636 108.636 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a131.66 131.66 0 0 0 5.355 4.237 136.07 136.07 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848 21.142-6.58 42.646-16.637 64.815-33.213 5.316-56.288-9.08-105.09-38.056-148.36ZM85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2c12.867 0 23.236 11.804 23.015 26.2.02 14.375-10.148 26.18-23.015 26.18Zm85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2 0 14.375-10.148 26.18-23.015 26.18Z"
fill="#5865F2" fill="#5865F2"
/> />
</svg> </svg>
); );

@ -40,7 +40,7 @@ import { cn } from "@/lib/utils";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
export default function IconDisplay(props: { export default function IconDisplay(props: {
server: OnlineServer | ServerResponse; server: OnlineServer | ServerResponse | { icon: string };
className?: string; className?: string;
}) { }) {
return ( return (

@ -71,12 +71,12 @@ export function NavBar() {
return ( return (
<div <div
className={cn( className={cn(
"w-screen h-[3rem] grid-cols-3 fixed z-10 flex", "h-[3rem] grid-cols-3 fixed z-10 flex",
"items-center justify-self-start me-auto pl-4 flex-1 transition-all justify-between", "items-center justify-self-start me-auto pl-4 flex-1 transition-all justify-between",
"lg:top-0 max-lg:bottom-0", "lg:top-0 max-lg:bottom-0 bg-neutral-100 dark:bg-neutral-900",
showBorder showBorder
? "border-b backdrop-blur-xl" ? "border-b backdrop-blur-xl w-screen"
: "max-lg:border-b max-lg:backdrop-blur-xl", : "max-lg:border-b max-lg:w-screen lg:border-b-slate-300 lg:dark:border-t-zinc-700 lg:border lg:m-2 lg:w-[calc(100vw-24px)] lg:rounded-lg lg:border-neutral-500/20 lg:bg-neutral-100 lg:dark:border-neutral-700/50 lg:dark:bg-neutral-900",
pathname !== null && animatedTopbarPages.includes(pathname) pathname !== null && animatedTopbarPages.includes(pathname)
? "[--animation-delay:1000ms] opacity-0 animate-fade-in" ? "[--animation-delay:1000ms] opacity-0 animate-fade-in"
: "", : "",
@ -192,8 +192,10 @@ export function NavBar() {
</DropdownMenu> </DropdownMenu>
<SignedIn> <SignedIn>
<div <div
className="absolute right-0 -z-10 h-full className={cn(
overflow-hidden w-full ml-auto" "absolute right-0 -z-10 h-full transition-all overflow-hidden w-full ml-auto",
showBorder ? "" : "hidden",
)}
style={{ borderRadius: "inherit" }} style={{ borderRadius: "inherit" }}
> >
<img <img

@ -121,14 +121,14 @@ export function CustomErrors({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<Link <Link
noExtraIcons noextraicons
target="_blank" target="_blank"
href={`https://typescript.tv/errors/#ts${c.code}`} href={`https://typescript.tv/errors/#ts${c.code}`}
> >
<DropdownMenuItem>typescript.tv</DropdownMenuItem> <DropdownMenuItem>typescript.tv</DropdownMenuItem>
</Link> </Link>
<Link <Link
noExtraIcons noextraicons
target="_blank" target="_blank"
href={`https://ts-error-translator.vercel.app/?error=${compressToEncodedURIComponent(c.messageText.toString())}`} href={`https://ts-error-translator.vercel.app/?error=${compressToEncodedURIComponent(c.messageText.toString())}`}
> >

@ -40,7 +40,8 @@ export function ModificationAction({ value }: { value?: Action }) {
>) ?? [] >) ?? []
).findIndex( ).findIndex(
(c) => (c) =>
JSON.stringify(c.metadata) === JSON.stringify(filter.toIdentifier()) && JSON.stringify(c.metadata) ===
JSON.stringify(filter.toIdentifier()) &&
c.type === filter.getSpecificFilterId(), c.type === filter.getSpecificFilterId(),
); );
return existing; return existing;
@ -48,7 +49,7 @@ export function ModificationAction({ value }: { value?: Action }) {
return -1; return -1;
}; };
useEffect(() => setApplied(findExisting())) useEffect(() => setApplied(findExisting()));
return ( return (
<> <>
@ -68,15 +69,17 @@ export function ModificationAction({ value }: { value?: Action }) {
className="mt-1" className="mt-1"
onClick={async () => { onClick={async () => {
if (value?.type() === "filter") { if (value?.type() === "filter") {
const updatedUser = await user?.reload();
const filter = value as Filter; const filter = value as Filter;
const existing = findExisting(); const existing = findExisting();
if (isSignedIn) { if (isSignedIn) {
const existingArray = const existingArray =
(user.unsafeMetadata.filters as Array< (updatedUser?.unsafeMetadata.filters as Array<
ClerkEmbeddedFilter<unknown> ClerkEmbeddedFilter<unknown>
>) ?? []; >) ?? [];
existingArray.splice(existing, 1); const previousFilters = updatedUser?.unsafeMetadata
.filters as Array<ClerkEmbeddedFilter<unknown>>;
if (existing === -1) if (existing === -1)
await user.update({ await user.update({
unsafeMetadata: { unsafeMetadata: {
@ -86,19 +89,19 @@ export function ModificationAction({ value }: { value?: Action }) {
type: filter.getSpecificFilterId(), type: filter.getSpecificFilterId(),
metadata: filter.toIdentifier(), metadata: filter.toIdentifier(),
}, },
...((user.unsafeMetadata.filters as Array< ...previousFilters,
ClerkEmbeddedFilter<unknown>
>) ?? []),
] as Array<ClerkEmbeddedFilter<unknown>>, ] as Array<ClerkEmbeddedFilter<unknown>>,
}, },
}); });
else else {
existingArray.splice(existing, 1);
await user.update({ await user.update({
unsafeMetadata: { unsafeMetadata: {
filters: existingArray,
...user.unsafeMetadata, ...user.unsafeMetadata,
filters: existingArray,
}, },
}); });
}
} else { } else {
const existingArray = const existingArray =
(JSON.parse( (JSON.parse(
@ -110,17 +113,20 @@ export function ModificationAction({ value }: { value?: Action }) {
localStorage.setItem( localStorage.setItem(
"mhsf__filters", "mhsf__filters",
JSON.stringify([ JSON.stringify([
...((JSON.parse(
localStorage.getItem("mhsf__filters") ?? "[]",
) as Array<ClerkEmbeddedFilter<unknown>>) ?? []),
{ {
type: filter.getSpecificFilterId(), type: filter.getSpecificFilterId(),
metadata: filter.toIdentifier(), metadata: filter.toIdentifier(),
}, },
...((JSON.parse(
localStorage.getItem("mhsf__filters") ?? "[]",
) as Array<ClerkEmbeddedFilter<unknown>>) ?? []),
]), ]),
); );
else else
localStorage.setItem("mhsf__filters", JSON.stringify(existingArray)); localStorage.setItem(
"mhsf__filters",
JSON.stringify(existingArray),
);
} }
setApplied(findExisting()); setApplied(findExisting());

@ -35,7 +35,7 @@ import { ModificationFrame } from "./modification-frame";
export function ModificationButton({disabled}: {disabled?: boolean}) { export function ModificationButton({disabled}: {disabled?: boolean}) {
return ( return (
<Dialog> <Dialog>
<DialogTrigger> <DialogTrigger asChild>
<Button disabled={disabled}>Filters & Sorting</Button> <Button disabled={disabled}>Filters & Sorting</Button>
</DialogTrigger> </DialogTrigger>

@ -42,6 +42,7 @@ export function ModificationFrame() {
communication.toIframe.send("ping", {from: "top-layer"}) communication.toIframe.send("ping", {from: "top-layer"})
}) })
communication.toIframe.handle("rerender-servers", (c) => { communication.toIframe.handle("rerender-servers", (c) => {
window.dispatchEvent(new Event("start-loading-server-view"))
window.dispatchEvent(new Event("update-modification-stack")) window.dispatchEvent(new Event("update-modification-stack"))
}) })
}, [ref]) }, [ref])

@ -39,7 +39,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { useEffectOnce } from "@/lib/useEffectOnce"; import { useEffectOnce } from "@/lib/useEffectOnce";
import { allTags } from "@/config/tags"; import { allTags } from "@/config/tags";
import { type ReactNode, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Dialog, Dialog,
@ -176,6 +176,7 @@ export function TagShower(props: {
useEffectOnce(() => { useEffectOnce(() => {
if (loading) { if (loading) {
// biome-ignore lint/complexity/noForEach: no.
allTags.forEach((tag) => { allTags.forEach((tag) => {
if (!tag.condition) { if (!tag.condition) {
tag.name({ online: props.server }).then((n) => { tag.name({ online: props.server }).then((n) => {
@ -246,7 +247,7 @@ export function TagShower(props: {
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{'"'} {'"'}
{t.docsName == undefined ? t.name : t.docsName} {t.docsName === undefined ? t.name : t.docsName}
{'"'} documentation {'"'} documentation
</DialogTitle> </DialogTitle>
<DialogDescription <DialogDescription

@ -44,19 +44,34 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ServerRandomServerProvider } from "./server-random-server-provider";
import { Dice2, Dices, EllipsisIcon, RefreshCcw, ShareIcon } from "lucide-react";
import { toast } from "sonner";
import useClipboard from "@/lib/useClipboard";
export function ServerList() { export function ServerList() {
const { servers, loading, serverCount, playerCount } = useServers(); const { servers, loading, serverCount, playerCount, refresh } = useServers();
const { const {
filteredData, filteredData,
testModeEnabled, testModeEnabled,
testModeLoading, testModeLoading,
testModeStatus, testModeStatus,
filterCount, filterCount,
tagStrings,
loading: filterLoading, loading: filterLoading,
} = useFilters(servers); } = useFilters(servers);
const { itemsLength, fetchMoreData, hasMoreData, data } = const { itemsLength, fetchMoreData, hasMoreData, data } =
useInfiniteScrolling(filteredData); useInfiniteScrolling(filteredData);
const clipboard = useClipboard();
if (loading) if (loading)
return ( return (
@ -67,53 +82,119 @@ export function ServerList() {
return ( return (
<main className="px-3 lg:px-16"> <main className="px-3 lg:px-16">
<h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl mb-3"> <ServerRandomServerProvider servers={filteredData}>
Statistics <h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl mb-3">
</h1> Statistics
<Statistics </h1>
totalServers={serverCount} <Statistics
totalPlayers={playerCount} totalServers={serverCount}
topServer={servers[0]} totalPlayers={playerCount}
/> topServer={servers[0]}
<Separator className="my-6" />
<h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl">
Servers
</h1>
<div className="flex items-center">
<Tooltip>
<TooltipTrigger>
<ModificationButton disabled={testModeEnabled} />
</TooltipTrigger>
<TooltipContent side="bottom" className="backdrop-blur bg-transparent text-black dark:text-white ">{filterCount} modification(s) enabled</TooltipContent>
</Tooltip>
<ServerTestModeSelector
testModeStatus={testModeStatus}
testModeEnabled={testModeEnabled}
testModeLoading={testModeLoading}
/> />
</div> <Separator className="my-6" />
{filterLoading ? ( <h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl">
<span className="mt-2 left-[50%] right-[50%] absolute"> Servers
<Spinner /> </h1>
</span> <div className="flex items-center justify-between">
) : ( <span className="flex items-center">
<InfiniteScroll <Tooltip>
dataLength={itemsLength} <TooltipTrigger>
next={fetchMoreData} <ModificationButton disabled={testModeEnabled} />
hasMore={hasMoreData} </TooltipTrigger>
loader={ <TooltipContent
<span className="mt-2 left-[50%] right-[50%] absolute"> side="bottom"
<Spinner /> className="backdrop-blur bg-transparent text-black dark:text-white "
</span> >
} {filterCount} modification(s) enabled
> </TooltipContent>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 mt-3"> </Tooltip>
{data.map((c) => ( <ServerTestModeSelector
<ServerCard server={c} key={c.staticInfo._id} /> testModeStatus={testModeStatus}
))} testModeEnabled={testModeEnabled}
</div> testModeLoading={testModeLoading}
</InfiniteScroll> />
)} <div className="flex items-center gap-1 ml-3 max-w-[calc(100vw-200px)] overflow-auto max-lg:pb-2">
{tagStrings.map((c) => (
<Badge
key={c}
className="flex px-3 break-keep whitespace-nowrap"
variant="gray-subtle"
>
{c}
</Badge>
))}
</div>
</span>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
className="flex items-center"
size="square-md"
variant="secondary"
>
<EllipsisIcon size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSeparator>Servers</DropdownMenuSeparator>
<DropdownMenuItem
onClick={() =>
window.dispatchEvent(new Event("open-random-server"))
}
className="flex items-center gap-2"
>
<Dices size={16} />
Pick random server
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => refresh()}
>
<RefreshCcw size={16} />
Reload
</DropdownMenuItem>
<DropdownMenuSeparator>Share</DropdownMenuSeparator>
<DropdownMenuItem
className="flex items-center gap-2"
onClick={() => {
const data = { url: "https://mhsf.app", text: "Check out MHSF, the modern server finder!" };
if (navigator.canShare(data))
navigator.share(data)
else {
clipboard.writeText("https://mhsf.app")
toast.success("Sent to clipboard!")
}
}}
>
<ShareIcon size={16} />
Share MHSF!
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{filterLoading ? (
<span className="mt-2 left-[50%] right-[50%] absolute">
<Spinner />
</span>
) : (
<InfiniteScroll
dataLength={itemsLength}
next={fetchMoreData}
hasMore={hasMoreData}
loader={
<span className="mt-2 left-[50%] right-[50%] absolute">
<Spinner />
</span>
}
>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 mt-3">
{data.map((c) => (
<ServerCard server={c} key={c.staticInfo._id} />
))}
</div>
</InfiniteScroll>
)}
</ServerRandomServerProvider>
</main> </main>
); );
} }

@ -28,61 +28,40 @@
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
export const allFolders: DocsFolder[] = [ "use client";
{
name: "General",
docs: [
{
title: "Getting Started",
url: "/docs/getting-started",
},
{
title: "Reading",
url: "/docs/reading",
},
],
},
{
name: "Guides",
docs: [
{
title: "Linking",
url: "/docs/guides/linking",
},
{
title: "Owning a Server",
url: "/docs/guides/owning-a-server",
},
{
title: "Server Customization",
url: "/docs/guides/customization",
},
{ title: "Reporting a server", url: "/docs/guides/reporting-server" },
],
},
{
name: "Advanced",
docs: [
{ title: "Tech Stack", url: "/docs/advanced/tech-stack" },
{ title: "Using the Command-bar", url: "/docs/advanced/command-bar" },
{ title: "Tips with external servers", url: "/docs/advanced/external" },
{ title: "Achievements", url: "/docs/advanced/achievements" },
],
},
{
name: "Legal",
docs: [
{ title: "ECA Agreement", url: "/docs/legal/external-content-agreement" },
],
},
];
export type Docs = { import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
title: string; import type { OnlineServer } from "@/lib/types/mh-server";
url: string; import { type ReactNode, useEffect, useState } from "react";
}; import { MOTDRenderer } from "../server-page/motd/motd-renderer";
import IconDisplay from "../icons/minecraft-icon-display";
import ServerCard, { TagShower } from "./server-card";
export type DocsFolder = { export function ServerRandomServerProvider({
name: string; servers,
docs: Array<Docs>; children,
}; }: { servers: OnlineServer[]; children: ReactNode | ReactNode[] }) {
const [open, setOpen] = useState(false);
const [selectedServer, setSelectedServer] = useState<OnlineServer | null>();
useEffect(() => {
if (servers.length !== 0)
window.addEventListener("open-random-server", () => {
setSelectedServer(servers[Math.floor(Math.random() * servers.length)]);
setOpen(true);
});
}, [servers]);
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
{selectedServer !== null && selectedServer !== undefined && (
<ServerCard server={selectedServer}/>
)}
</DialogContent>
</Dialog>
{children}
</>
);
}

@ -73,7 +73,6 @@ export function Statistics({
setAverages(fetchJson); setAverages(fetchJson);
})(); })();
} catch (e) { } catch (e) {
console.log(e);
setError(true); setError(true);
} }
}, []); }, []);

@ -0,0 +1,104 @@
/*
* 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 { Placeholder } from "@/components/ui/placeholder";
import { Separator } from "@/components/ui/separator";
import { formalNames } from "@/config/achievements";
import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import type { Achievement } from "@/lib/types/achievement";
import type { ServerResponse } from "@/lib/types/mh-server";
import { X } from "lucide-react";
export function AchievementsView({
server,
mhsfData,
}: { server: ServerResponse; mhsfData: ReturnType<typeof useMHSFServer> }) {
return (
<Material className="p-4 relative h-[250px] max-lg:mt-3">
<span className="mb-2">
<strong className="text-lg">Achievements</strong>
<Separator className="my-2" />
</span>
<div className="p-2 max-h-[170px] overflow-auto">
{mhsfData.server?.achievements.currently.filter(
(value, index, array) => listify(array).indexOf(value.type) === index,
).length === 0 && (
<Placeholder
icon={<X />}
title="We couldn't find any achievements"
description="Maybe shake the box harder?"
className="mt-4"
/>
)}
{mhsfData.server?.achievements.currently
.filter(
(value, index, array) =>
listify(array).indexOf(value.type) === index,
)
.map((c, i) => {
const Icon = formalNames[c.type].icon;
return (
<div className="mb-2" key={i}>
<span
className="flex items-center"
style={{ color: formalNames[c.type].color }}
>
<Icon size={16} className="mr-2" />
<span
dangerouslySetInnerHTML={{
__html: formalNames[c.type].title,
}}
/>
</span>
<p>{formalNames[c.type].description}</p>
<span className="text-sm text-muted-foreground">
Achieved on {new Date(c.date).getMonth()}/
{new Date(c.date).getDate()}/{new Date(c.date).getFullYear()}{" "}
<span className="text-muted-foreground/70">
{new Date(c.date).toLocaleTimeString()}
</span>
</span>
</div>
);
})}
</div>
</Material>
);
}
const listify = (list: Achievement[]) => {
const newL: Array<string> = [];
list.forEach((c) => newL.push(c.type));
return newL;
};

@ -0,0 +1,55 @@
/*
* 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 { Ripple } from "../../home-page/ripple";
const PARTNER_HERO = "This server is partnered with MHSF.";
const PARTNER_DESCRIPTION =
"This server and its staff support the future of MHSF";
const PARTNER_DESCRIPTION_2 =
"and a portion of users on MHSF may come from this server";
const PARTNER_DESCRIPTION_3 = "or it's communication standards.";
export function AffiliateRow() {
return (
<Material className="p-4 col-span-2 row-span-2 relative h-[500px] max-lg:mb-3 flex items-center justify-center">
<span className="text-center">
<h1 className="animate-fade-in text-balance bg-gradient-to-br from-black from-30% to-black/40 bg-clip-text pb-6 text-2xl font-semibold leading-none tracking-tighter text-transparent opacity-0 [--animation-delay:200ms] sm:text-2xl md:text-3xl lg:text-4xl dark:from-white dark:to-white/40">
{PARTNER_HERO}
</h1>
<p className="animate-fade-in mb-6 mt-6 -translate-y-4 text-balance text-md tracking-tight text-gray-400 opacity-0 [--animation-delay:400ms] md:text-xl">
{PARTNER_DESCRIPTION} <br /> {PARTNER_DESCRIPTION_2} <br /> {PARTNER_DESCRIPTION_3}
</p>
</span>
<Ripple mainCircleSize={700} className="max-md:hidden" />
</Material>
);
}

@ -0,0 +1,241 @@
/*
* 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 {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Material } from "@/components/ui/material";
import { Separator } from "@/components/ui/separator";
import { useEmbedGenerator } from "@/lib/hooks/use-embed-generator";
import { cn } from "@/lib/utils";
import { EllipsisVertical } from "lucide-react";
import { useEffect, useState } from "react";
import { ShikiRenderer } from "./embed-shiki-renderer";
import { codeToHtml } from "shiki";
import { useTheme } from "@/lib/hooks/use-theme";
import useClipboard from "@/lib/useClipboard";
import { toast } from "sonner";
export function EmbedCreatorRow({ serverName }: { serverName: string }) {
const embedCreator = useEmbedGenerator(serverName);
const clipboard = useClipboard();
const { resolvedTheme } = useTheme();
const [tab, setTab] = useState<"preview" | "code">("preview");
const [highlightedHtml, setHighlightedHtml] = useState("");
const [highlightedJsx, setHighlightedJsx] = useState("");
const [codeTab, setCodeTab] = useState<"html" | "jsx">("html");
useEffect(() => {
const selectedTheme =
resolvedTheme === "dark" ? "poimandres" : "vitesse-light";
async function highlightCode() {
const jsx = await codeToHtml(embedCreator.out.jsxCode ?? "", {
lang: "jsx",
theme: selectedTheme,
});
const html = await codeToHtml(embedCreator.out.htmlCode ?? "", {
lang: "html",
theme: selectedTheme,
});
setHighlightedHtml(html);
setHighlightedJsx(jsx);
}
highlightCode();
});
return (
<Material className="p-4 relative h-[250px] max-lg:mt-3">
<span className="mb-2">
<span className="flex items-center justify-between">
<span className="flex gap-4 items-center">
<strong className="text-lg">Embed Creator</strong>
<button
type="button"
className={cn(
"text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-zinc-700/30 transition-all duration-75 disabled:opacity-50 disabled:pointer-events-none",
"rounded-xl px-2 flex items-center gap-2",
tab === "preview" &&
"bg-slate-100 dark:bg-zinc-700/30 font-medium",
)}
onClick={() => setTab("preview")}
>
Preview
</button>
<button
type="button"
className={cn(
"text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-zinc-700/30 transition-all duration-75 disabled:opacity-50 disabled:pointer-events-none",
"rounded-xl px-2 flex items-center gap-2",
tab === "code" &&
"bg-slate-100 dark:bg-zinc-700/30 font-medium",
)}
onClick={() => setTab("code")}
>
Code
</button>
</span>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
className="flex items-center"
size="square-md"
variant="secondary"
>
<EllipsisVertical size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSeparator>General</DropdownMenuSeparator>
<DropdownMenuCheckboxItem
checked={embedCreator.in.staticMode}
onCheckedChange={embedCreator.in.setStatic}
>
Static embed
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={embedCreator.in.removeMinehutBranding}
onCheckedChange={embedCreator.in.setRMHB}
>
Remove Minehut branding
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator>Theme</DropdownMenuSeparator>
<DropdownMenuRadioGroup
value={embedCreator.in.theme}
onValueChange={(c) =>
embedCreator.in.setTheme(c as "light" | "dark")
}
>
<DropdownMenuRadioItem value="light">
Light Mode
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
Dark Mode
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator>Copy</DropdownMenuSeparator>
{tab === "code" ? (
<DropdownMenuItem
onClick={() => {
clipboard.writeText(
embedCreator.out[
codeTab === "html" ? "htmlCode" : "jsxCode"
] as string,
);
toast.success(`Copied ${codeTab.toLocaleUpperCase()} code!`)
}}
>
Copy code
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
onClick={() => {
clipboard.writeText(embedCreator.out.jsxCode as string);
toast.success("Copied!");
}}
>
Copy JSX
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
clipboard.writeText(embedCreator.out.htmlCode as string);
toast.success("Copied!");
}}
>
Copy HTML
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</span>
<Separator className="my-2" />
</span>
{tab === "preview" && (
<iframe
src={embedCreator.out.finalURL}
className="max-md:w-full w-[390px]"
height={145}
style={{ borderRadius: "0.25rem" }}
allow="clipboard-write"
frameBorder={0}
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
/>
)}
{tab === "code" && (
<div className="max-h-[180px] overflow-auto">
<div className="dark:bg-[#1b1e28] w-full h-[32px] pt-2 px-2 rounded-t flex items-center gap-2">
<button
type="button"
className={cn(
"text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-zinc-700/30 transition-all duration-75 disabled:opacity-50 disabled:pointer-events-none",
"rounded-xl px-2 flex items-center gap-2",
codeTab === "html" &&
"bg-slate-100 dark:bg-zinc-700/30 font-medium",
)}
onClick={() => setCodeTab("html")}
>
HTML
</button>
<button
type="button"
className={cn(
"text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-zinc-700/30 transition-all duration-75 disabled:opacity-50 disabled:pointer-events-none",
"rounded-xl px-2 flex items-center gap-2",
codeTab === "jsx" &&
"bg-slate-100 dark:bg-zinc-700/30 font-medium",
)}
onClick={() => setCodeTab("jsx")}
>
JSX
</button>
</div>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: Its shiki man give me a break :sob: */}
<div
dangerouslySetInnerHTML={{
__html: codeTab === "html" ? highlightedHtml : highlightedJsx,
}}
className="rounded-b"
/>
</div>
)}
</Material>
);
}

@ -0,0 +1,67 @@
import { Material } from "@/components/ui/material";
import { Separator } from "@/components/ui/separator";
import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import type { ServerResponse } from "@/lib/types/mh-server";
import type { ReactNode } from "react";
import { convert } from "../util";
import IconDisplay from "../../icons/minecraft-icon-display";
export function GeneralInfo({
server,
mhsfData,
}: { server: ServerResponse; mhsfData: ReturnType<typeof useMHSFServer> }) {
return (
<Material className="p-4 relative h-[250px] max-lg:mt-3">
<span className="mb-2">
<strong className="text-lg">Information</strong>
<Separator className="my-2" />
</span>
<div className="p-2 max-h-[170px] overflow-auto">
<InfoBox
title="Credits/Month"
description={Math.floor(server.credits_per_day)}
/>
<InfoBox
title="All time joins"
description={convert(server.joins)}
/>
<InfoBox
title="Server Id"
description={<code>{server._id}</code>}
/>
<InfoBox
title="Server Expired"
description={server.expired ? "Yes" : "No"}
/>
<InfoBox
title="Server External"
description={server?.rawPlan === undefined
? "? (unknown)"
: server?.rawPlan === "EXTERNAL" ? "Yes" : "No"}
/>
<InfoBox
title="Server Icon"
description={<div className="flex gap-1 items-center"><IconDisplay server={server}/><code>{(server.icon ?? "sign").toLocaleUpperCase()}</code></div>}
/>
<InfoBox
title="Visible"
description={server.visibility ? "Yes" : "No"}
/>
</div>
</Material>
);
}
function InfoBox({
title,
description,
}: { title: ReactNode; description: ReactNode }) {
return (
<span>
<strong className="text-sm">{title}</strong>
<p className="mb-1">{description}</p>
<Separator />
</span>
);
}

@ -0,0 +1,139 @@
/*
* 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 { Separator } from "@/components/ui/separator";
import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import type { ServerResponse } from "@/lib/types/mh-server";
import { getIndexFromRarity } from "@/lib/types/server-icon";
import IconDisplay from "../../icons/minecraft-icon-display";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Banknote, Info, X } from "lucide-react";
import { useIcons } from "@/lib/hooks/use-icons";
import { Placeholder } from "@/components/ui/placeholder";
export function IconsRow({
server,
mhsfData,
}: { server: ServerResponse; mhsfData: ReturnType<typeof useMHSFServer> }) {
const { icons } = useIcons();
return (
<Material className="p-4 relative h-[250px] max-lg:mt-3">
<span className="mb-2">
<div className="flex items-center">
<strong className="text-lg">Purchased Icons</strong>
<Tooltip>
<TooltipTrigger>
<Info size={16} className="ml-2" />
</TooltipTrigger>
<TooltipContent>
Purchased Icons are icons that are under the server's ownership, <br />
they may or may not available at that certain moment either.
</TooltipContent>
</Tooltip>
</div>
<Separator className="my-2" />
</span>
<div className="p-2 max-h-[180px] overflow-auto">
{server?.purchased_icons.length === 0 &&
<Placeholder
icon={<X />}
title="We couldn't find any icons"
description="Maybe shake the box harder?"
className="mt-4"
/>}
{server?.purchased_icons.map((icon, i) => (
<p
key={i}
className="pb-4 flex items-center justify-between"
style={{
color: getIndexFromRarity(
icons?.find((c) => c._id === icon)?.rank.toLowerCase(),
).text,
}}
>
<div className="flex items-center">
<IconDisplay
server={{
icon: icons?.find((c) => c._id === icon)?.icon_name ?? "",
}}
className="mr-2"
/>
{icons?.find((c) => c._id === icon)?.display_name}
<Tooltip>
<TooltipTrigger>
<Info size={18} className="ml-2" />
</TooltipTrigger>
<TooltipContent>
Just because an item is available, it doesn't directly <br />
mean that it can be bought immediately, it just means its in the{" "}
<br />
pool of icons that are in the weekly rotation.
<br />
<br />
<span className="flex items-center">
<span className="mr-1">Available currently:</span>
{icons?.find((c) => c._id === icon)?.available ? "Yes" : "No"}
</span>
<span className="flex items-center">
<span className="mr-1">Disabled currently:</span>
{icons?.find((c) => c._id === icon)?.disabled ? "Yes" : "No"}
</span>
<span className="flex items-center">
<span className="mr-1">Price:</span>
<Banknote size={16} className="mr-1" />
{icons?.find((c) => c._id === icon)?.price} credits
</span>
</TooltipContent>
</Tooltip>
</div>
<span
className="mx-2 p-1 pr-2 rounded italic font-bold"
style={{
backgroundColor: getIndexFromRarity(
icons?.find((c) => c._id === icon)?.rank.toLowerCase(),
).bg,
}}
>
{icons?.find((c) => c._id === icon)?.rank.toLocaleUpperCase()}
</span>
</p>
))}
</div>
</Material>
);
}

@ -39,22 +39,13 @@ import { Material } from "@/components/ui/material";
import { useState } from "react"; import { useState } from "react";
import { useMHSFServer } from "@/lib/hooks/use-mhsf-server"; import { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { Button } from "@/components/ui/button";
import { Ellipsis, EllipsisVertical, Shuffle } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { RearrangeDrawer } from "../rearrange/rearrange-drawer";
export function MOTDRow({ export function MOTDRow({
server, server,
mhsfData, mhsfData,
}: { server: ServerResponse; mhsfData: ReturnType<typeof useMHSFServer> }) { }: { server: ServerResponse; mhsfData: ReturnType<typeof useMHSFServer> }) {
const clipboard = useClipboard(); const clipboard = useClipboard();
const [tab, setTab] = useState("motd"); const [tab, setTab] = useState(mhsfData.server?.customizationData.description !== undefined ? "description" : "motd");
return ( return (
<Material className="p-4 relative h-[250px]"> <Material className="p-4 relative h-[250px]">
@ -81,7 +72,7 @@ export function MOTDRow({
"text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-zinc-700/30 transition-all duration-75 disabled:opacity-50 disabled:pointer-events-none", "text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-zinc-700/30 transition-all duration-75 disabled:opacity-50 disabled:pointer-events-none",
"rounded-xl px-2 flex items-center gap-2", "rounded-xl px-2 flex items-center gap-2",
tab === "description" && tab === "description" &&
"bg-slate-100 dark:bg-zinc-700/30 font-medium", "bg-slate-100 dark:bg-zinc-700/30 font-medium",
)} )}
onClick={() => setTab("description")} onClick={() => setTab("description")}
> >
@ -118,8 +109,8 @@ export function MOTDRow({
</> </>
)} )}
{tab === "description" && ( {tab === "description" && (
<div className="prose mt-2 break-words overflow-y-auto max-h-[175px] dark:prose-invert"> <div className="prose mt-2 break-words overflow-y-auto max-h-[175px] min-w-full dark:prose-invert">
<Markdown>{mhsfData.server?.customizationData.description}</Markdown> <Markdown className="min-w-full">{mhsfData.server?.customizationData.description}</Markdown>
</div> </div>
)} )}
</Material> </Material>

@ -0,0 +1,21 @@
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer";
import { ReactNode, useEffect, useState } from "react";
export function ServerEditorProvider({children}: {children: ReactNode | ReactNode[]}) {
const [open, setOpen] = useState(false);
useEffect(() => {
window.addEventListener("open-server-editor", () => setOpen(true));
}, [])
return <>
{children}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent>
<DrawerTitle>Server Settings</DrawerTitle>
</DrawerContent>
</Drawer>
</>
}

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

@ -11,71 +11,77 @@ import { useSettingsStore } from "@/lib/hooks/use-settings-store";
import { Button } from "@/components/ui/button"; 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";
export function ServerProvider({ serverId }: { serverId: string }) { export function ServerProvider({ serverId }: { serverId: string }) {
const { server, error, loading } = useServer({ id: serverId }); const { server, error, loading } = useServer({ id: serverId });
const settings = useSettingsStore(); const settings = useSettingsStore();
const mhsf = useMHSFServer(serverId); const mhsf = useMHSFServer(serverId);
if (error !== null) if (error !== null)
return ( return (
<div className="absolute top-[50%] left-[50%]"> <div className="absolute top-[50%] left-[50%]">
<Placeholder <Placeholder
icon={<X />} icon={<X />}
title="Error while fetching server" title="Error while fetching server"
description={ description={
<> <>
Try again later <br /> If this occurs again, please contact Try again later <br /> If this occurs again, please contact
support or make a GitHub issue. <br /> {error} support or make a GitHub issue. <br /> {error}
</> </>
} }
/> />
</div> </div>
); );
return ( return (
<DebugProvider <DebugProvider
debugOptions={{ debugOptions={{
serverName: (server ?? { name: "" }).name, serverName: (server ?? { name: "" }).name,
serverId: serverId, serverId: serverId,
mhsfData: mhsf.server, mhsfData: mhsf.server,
serverData: server, serverData: server,
onlineServerData: null, onlineServerData: null,
}} }}
> >
{loading || mhsf.loading ? ( {loading || mhsf.loading ? (
<div className="absolute top-[50%] left-[50%] transform -translate-x-1/2 -translate-y-1/2 block justify-center text-center gap-2"> <div className="absolute top-[50%] left-[50%] transform -translate-x-1/2 -translate-y-1/2 block justify-center text-center gap-2">
<span className="w-full flex justify-center"> <span className="w-full flex justify-center">
<Spinner /> <Spinner />
</span> </span>
<span> <span>
<AnimatedText <AnimatedText
text={ text={
loading && mhsf.loading loading && mhsf.loading
? "Loading server and MHSF data..." ? "Loading server and MHSF data..."
: loading : loading
? "Loading server data..." ? "Loading server data..."
: "Loading MHSF data..." : "Loading MHSF data..."
} }
className="text-center w-full mt-2" className="text-center w-full mt-2"
/> />
</span> </span>
{settings.get("debug-mode") === "true" && ( {settings.get("debug-mode") === "true" && (
<Button <Button
onClick={() => window.dispatchEvent(new Event("open-debug-menu"))} onClick={() => window.dispatchEvent(new Event("open-debug-menu"))}
> >
Debug Stack Debug Stack
</Button> </Button>
)} )}
</div> </div>
) : ( ) : (
<div className="px-10"> <div className="px-10">
<ReportingProvider server={mhsf}> <ServerEditorProvider>
<ServerMainPage server={server as ServerResponse} mhsfData={mhsf} /> <ReportingProvider server={mhsf}>
</ReportingProvider> <ServerMainPage
</div> server={server as ServerResponse}
)} mhsfData={mhsf}
</DebugProvider> />
); </ReportingProvider>
</ServerEditorProvider>
</div>
)}
</DebugProvider>
);
} }

@ -33,14 +33,25 @@ import useClipboard from "@/lib/useClipboard";
import { MOTDRow } from "./motd/motd-row"; import { MOTDRow } from "./motd/motd-row";
import { StatisticsMainRow } from "./stats/stats-main-row"; import { StatisticsMainRow } from "./stats/stats-main-row";
import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server"; import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import { GeneralInfo } from "./general-info/general-info";
import { AchievementsView } from "./achievements/achievements";
import { IconsRow } from "./icons/icons-row";
import { affiliates } from "./util";
import { AffiliateRow } from "./afilliate/affilliate-row";
import { EmbedCreatorRow } from "./embeds/embed-creator";
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();
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 />}
<MOTDRow server={server} mhsfData={mhsfData}/> <MOTDRow server={server} mhsfData={mhsfData}/>
<StatisticsMainRow server={server} mhsfData={mhsfData} /> <StatisticsMainRow server={server} mhsfData={mhsfData} />
<GeneralInfo server={server} mhsfData={mhsfData} />
<AchievementsView server={server} mhsfData={mhsfData} />
<IconsRow server={server} mhsfData={mhsfData} />
<EmbedCreatorRow serverName={server.name} />
</span> </span>
); );
} }

@ -126,9 +126,8 @@ export function StatisticsChart({
data: any; data: any;
mainDataPoint: string; mainDataPoint: string;
}) { }) {
console.log(data);
return ( return (
<ChartContainer config={chartConfig} className="max-h-[202px] min-w-full"> <ChartContainer config={chartConfig} className="max-h-[202px] max-lg:max-h-[177px] min-w-full">
<AreaChart <AreaChart
accessibilityLayer accessibilityLayer
data={data.slice(data.length - 30, data.length)} data={data.slice(data.length - 30, data.length)}

@ -40,6 +40,8 @@ export function convert(value: number) {
return result; return result;
} }
export const affiliates = ["CoreBoxx"]
export const loadingList = [ export const loadingList = [
"Making gamer's safer", "Making gamer's safer",
"Finding why Apple is so expensive", "Finding why Apple is so expensive",

@ -12,7 +12,7 @@ export function StatusButton() {
if (loading) return <Spinner />; if (loading) return <Spinner />;
return ( return (
<Link href={`https://${statusURL as string}`} noExtraIcons target="_blank"> <Link href={`https://${statusURL as string}`} noextraicons target="_blank">
<Button variant="secondary" className="rounded-xl"> <Button variant="secondary" className="rounded-xl">
<span <span
className={cn( className={cn(

@ -0,0 +1,119 @@
/*
* 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 { Material } from "@/components/ui/material";
import {
SignedIn,
SignedOut,
useReverification,
useSignIn,
useUser,
} from "@clerk/nextjs";
import type { CreateExternalAccountParams, OAuthStrategy } from "@clerk/types";
import { UserInformation } from "./waitlist-page";
import { useEffect, useState } from "react";
import { Spinner } from "@/components/ui/spinner";
import { useEffectOnce } from "@/lib/useEffectOnce";
import { Button } from "@/components/ui/button";
import { useRouter } from "@/lib/useRouter";
export function WaitlistDiscordNeeded() {
return (
<div className="px-3 lg:px-32 pt-24">
<h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl mb-3">
Discord link required
</h1>
<p className="mb-3">
You are using an MHSF account that hasn't been linked w/ Discord. We{" "}
<br />
need to ensure you are in the beta, and need you to link your Discord to{" "}
<br />
verify your identity.
</p>
<SignedIn>
<Material className="mb-2">
<UserInformation discordPage />
</Material>
</SignedIn>
<Material>
<SignedOut>You're signed out.</SignedOut>
<SignedIn>
<SignedInBoundary />
</SignedIn>
</Material>
</div>
);
}
function SignedInBoundary() {
const [loading, setLoading] = useState(true);
const router = useRouter();
const { isLoaded, user } = useUser();
const createExternalAccount = useReverification(
(params: CreateExternalAccountParams) =>
user?.createExternalAccount(params),
);
useEffectOnce(() => {
(async () => {
const user = await fetch("/api/v1/user/waitlist/get-discord-details");
if (user.status !== 200) setLoading(false);
})();
});
if (loading) return <Spinner />;
const addDiscord = async () => {
await createExternalAccount({
strategy: "oauth_discord",
redirectUrl: "/waitlist",
})
.then((res) => {
if (res?.verification?.externalVerificationRedirectURL) {
router.push(res.verification.externalVerificationRedirectURL.href);
}
})
.catch((err) => {
console.log("ERROR", err);
});
};
return (
<>
<Button onClick={() => addDiscord()}>Link your Discord account</Button>
</>
);
}

@ -0,0 +1,189 @@
/*
* 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 { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Material } from "@/components/ui/material";
import { Spinner } from "@/components/ui/spinner";
import { useEffectOnce } from "@/lib/useEffectOnce";
import { useRouter } from "@/lib/useRouter";
import type { DiscordUser } from "@/pages/api/v1/user/waitlist/check-waitlist-eligibility";
import { SignedIn, SignedOut, useClerk, useUser } from "@clerk/nextjs";
import { CalendarArrowDown } from "lucide-react";
import Image from "next/image";
import { useEffect, useState } from "react";
import { WaitlistSuccessDialog } from "./waitlist-success";
export function WaitlistPage() {
const clerk = useClerk();
const { user } = useUser();
return (
<div className="px-3 lg:px-32 pt-24">
<h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl mb-3">
v2 private beta
</h1>
<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.
</p>
<SignedIn>
<Material className="mb-2">
<UserInformation />
</Material>
</SignedIn>
<Material>
<SignedOut>
<p>
You must be signed in to check for eligibility for this beta. Please
make sure you use the Discord connection so we can check if you
eligibile for the beta.
</p>
<span className="flex items-center gap-2">
<Button onClick={() => clerk.openSignIn()} variant="secondary">
Sign-in
</Button>
<Button onClick={() => clerk.openSignUp()}>Sign-up</Button>
</span>
</SignedOut>
<SignedIn>
{user?.publicMetadata.v2allowed !== true ? (
<SignedInBoundary />
) : (
<Alert variant="normal" className="gap-2">
You are already in the v2 beta.
</Alert>
)}
</SignedIn>
</Material>
</div>
);
}
export function UserInformation({ discordPage }: { discordPage?: boolean }) {
const [loading, setLoading] = useState(true);
const [discordData, setDiscordData] = useState<DiscordUser | null>(null);
const router = useRouter();
const { user } = useUser();
useEffectOnce(() => {
(async () => {
const user = await fetch("/api/v1/user/waitlist/get-discord-details");
const json = await user.json();
if (user.status !== 200 && !discordPage) {
router.push("/waitlist/oauth-need-discord");
} else {
setDiscordData(json.discordData as DiscordUser);
setLoading(false);
}
})();
});
if (loading) return <Spinner />;
return (
<span className="flex items-center gap-2 text-sm">
<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"
/>
<span className="block">
<p>Signed in as @{user?.username}</p>
{discordData !== undefined && discordData !== null && (
<p className="group cursor-pointer flex items-center gap-1">
Discord linked as {discordData.global_name}
<span className="text-muted-foreground hidden group-hover:block">
@{discordData.username}
</span>
{discordData.clan.identity_enabled === true && (
<Badge className="flex items-center">
<Image
src={`https://cdn.discordapp.com/clan-badges/${discordData.clan.identity_guild_id}/${discordData.clan.badge}.png?size=16`}
alt="clan tag bg"
width={16}
height={16}
/>
{discordData.clan.tag}
</Badge>
)}
</p>
)}
</span>
</span>
);
}
function SignedInBoundary() {
const [loading, setLoading] = useState(true);
const [text, setText] = useState("");
const [id, setId] = useState("");
const router = useRouter();
const [success, setSuccess] = useState(false);
useEffectOnce(() => {
(async () => {
const eligible = await fetch(
"/api/v1/user/waitlist/check-waitlist-eligibility",
);
const json = await eligible.json();
const status = eligible.status;
setText(json.message);
if (status === 200) {
setId(json.refUUID);
setSuccess(true);
}
setLoading(false);
})();
});
if (loading) return <Spinner />;
return (
<p>
{text} <WaitlistSuccessDialog open={success} setOpen={() => true} uuid={id} />
</p>
);
}

@ -0,0 +1,106 @@
/*
* 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 { Button } from "@/components/ui/button";
import { Material } from "@/components/ui/material";
import { Spinner } from "@/components/ui/spinner";
import { useEffectOnce } from "@/lib/useEffectOnce";
import { useRouter } from "@/lib/useRouter";
import { SignedIn, SignedOut, useClerk } from "@clerk/nextjs";
import { useQueryState } from "nuqs";
import { useEffect, useState } from "react";
import { WaitlistSuccessDialog } from "./waitlist-success";
export function WaitlistReferralBeta() {
const [id] = useQueryState("id", { defaultValue: "" });
const clerk = useClerk();
return (
<div className="px-3 lg:px-32 pt-24">
<h1 className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl mb-3">
v2 private beta
</h1>
<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.
</p>
<Material>
<SignedIn>
<LoggedInBoundary id={id} />
</SignedIn>
<SignedOut>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => clerk.openSignIn()}>Sign In</Button>
<Button onClick={() => clerk.openSignUp()}>Sign Up</Button>
</div>
</SignedOut>
</Material>
</div>
);
}
function LoggedInBoundary({ id }: { id: string }) {
const [loading, setLoading] = useState(true);
const [success, setSuccess] = useState(false);
const [text, setText] = useState("");
const router = useRouter();
useEffectOnce(() => {
(async () => {
const refEligibility = await fetch(
"/api/v1/user/waitlist/ref-waitlist-eligibility",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id }),
},
);
const status = refEligibility.status;
const json = await refEligibility.json();
if (status === 200) {
setSuccess(true);
} else {
setText(json.message);
}
setLoading(false);
})();
});
if (loading) return <Spinner />;
return <div>{!success && <>{text}</>} <WaitlistSuccessDialog open={success} setOpen={() => true} /></div>;
}

@ -0,0 +1,90 @@
/*
* 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Link } from "@/components/util/link";
import useClipboard from "@/lib/useClipboard";
import { useRouter } from "@/lib/useRouter";
import { Copy } from "lucide-react";
import { toast } from "sonner";
export function WaitlistSuccessDialog({
open,
setOpen,
uuid,
}: { open: boolean; setOpen: (c: boolean) => void; uuid?: string }) {
const clipboard = useClipboard();
const router = useRouter();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogTitle>You are eligibile!</DialogTitle>
<DialogDescription>
You have been invited into the v2 private beta!{" "}
{uuid && (
<>
You may also invite up to two (2) people with a special link
below. <strong>You will only see this link once.</strong>
</>
)}
</DialogDescription>
{uuid && (
<span className="flex items-center">
<p>https://mhsf.app/waitlist/ref?id={uuid}</p>
<Button
size="square-md"
onClick={() => {
clipboard.writeText(`https://mhsf.app/waitlist/ref?id=${uuid}`);
toast.success("Copied!");
}}
className="flex items-center justify-center"
>
<Copy size={16} />
</Button>
</span>
)}
<DialogFooter>
<DialogTrigger>
<Button onClick={() => router.push('/')}>Go home</Button>
</DialogTrigger>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

@ -61,7 +61,7 @@ const badgeVariants = cva(
"purple-subtle": `bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400 "purple-subtle": `bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400
ring-purple-400 dark:ring-purple-500/30`, ring-purple-400 dark:ring-purple-500/30`,
rainbow: rainbow:
"text-white ring-transparent z-10 [background:_linear-gradient(45deg,rgba(255,_0,_0,_1)_0%,rgba(255,_154,_0,_1)_10%,rgba(208,_222,_33,_1)_20%,rgba(79,_220,_74,_1)_30%,rgba(63,_218,_216,_1)_40%,rgba(47,_201,_226,_1)_50%,rgba(28,_127,_238,_1)_60%,rgba(95,_21,_242,_1)_70%,rgba(186,_12,_248,_1)_80%,rgba(251,_7,_217,_1)_90%,rgba(255,_0,_0,_1)_100%);] backdrop-blur-sm opacity-60 ", "text-white ring-transparent z-10 bg-blur-[15px] rainbow-tag",
custom: "", custom: "",
}, },
allowIconOnly: { allowIconOnly: {

@ -28,6 +28,7 @@
* OTHER DEALINGS IN THE SOFTWARE. * OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { cn } from "@/lib/utils";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
function Placeholder({ function Placeholder({
@ -35,14 +36,16 @@ function Placeholder({
title, title,
description, description,
children, children,
className
}: { }: {
icon?: ReactNode; icon?: ReactNode;
title?: ReactNode | string; title?: ReactNode | string;
description?: ReactNode | string; description?: ReactNode | string;
className?: string;
children?: ReactNode; children?: ReactNode;
}) { }) {
return ( return (
<div className="text-slate-700 dark:text-zinc-300 flex flex-col justify-center items-center gap-2"> <div className={cn("text-slate-700 dark:text-zinc-300 flex flex-col justify-center items-center gap-2", className)}>
{icon && ( {icon && (
<div className="border border-slate-200 dark:border-zinc-700 dark:bg-zinc-800 p-3 rounded-full"> <div className="border border-slate-200 dark:border-zinc-700 dark:bg-zinc-800 p-3 rounded-full">
{icon} {icon}

@ -37,45 +37,69 @@ import { usePathname } from "next/navigation";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
const roboto = Roboto({ const roboto = Roboto({
subsets: ["latin"], subsets: ["latin"],
weight: ["100", "300", "400", "500", "700", "900"], weight: ["100", "300", "400", "500", "700", "900"],
}); });
const overflowXHiddenPages = ["/home"]; const overflowXHiddenPages = ["/home"];
export function FontBoundary({ export function FontBoundary({
children, children,
className className,
}: { }: {
children?: ReactNode | ReactNode[]; children?: ReactNode | ReactNode[];
className?: string; className?: string;
}) { }) {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const [fontFamily, setFontFamily] = useState("inter"); const [fontFamily, setFontFamily] = useState("inter");
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { useEffect(() => {
setFontFamily((settingsStore.get("font-family") ?? "inter") as string); setFontFamily((settingsStore.get("font-family") ?? "inter") as string);
window.addEventListener("font-family-change", () => { window.addEventListener("font-family-change", () => {
setFontFamily((settingsStore.get("font-family") ?? "inter") as string); setFontFamily((settingsStore.get("font-family") ?? "inter") as string);
}); });
}, [settingsStore]); }, [settingsStore]);
return ( useEffect(() => {
<body const classes = [
className={`font-${fontFamily} ${(() => { `font-${fontFamily}`,
switch (fontFamily) { (() => {
case "geist-sans": switch (fontFamily) {
return GeistSans.className; case "geist-sans":
case "roboto": return GeistSans.className;
return roboto.className; case "roboto":
case "inter": return roboto.className;
return inter.className; case "inter":
default: return inter.className;
return "system-ui-font--font-boundary"; default:
} return "system-ui-font--font-boundary";
})()} ${pathname !== null && overflowXHiddenPages.includes(pathname) ? "overflow-x-hidden" : ""} ${className}`} }
> })() as string,
{children} "overflow-x-hidden",
</body> className,
); ] as string[];
document.body.classList.add(...classes);
return () => document.body.classList.remove(...classes)
});
return (
<div
className={`font-${fontFamily} ${(() => {
switch (fontFamily) {
case "geist-sans":
return GeistSans.className;
case "roboto":
return roboto.className;
case "inter":
return inter.className;
default:
return "system-ui-font--font-boundary";
}
})()} overflow-x-hidden ${className}`}
>
{children}
</div>
);
} }

@ -35,7 +35,7 @@ export function Link(
props: LinkProps & { props: LinkProps & {
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
noExtraIcons?: boolean; noextraicons?: boolean;
target?: string; target?: string;
} }
) { ) {
@ -43,7 +43,7 @@ export function Link(
return ( return (
<NextLink {...props} href={pageFind(href || "") || "#"} title={href}> <NextLink {...props} href={pageFind(href || "") || "#"} title={href}>
{!props.noExtraIcons && ( {!props.noextraicons && (
<> <>
{(href || "").startsWith("Docs:") && ( {(href || "").startsWith("Docs:") && (
<Book size={16} className="mr-[2px] inline-flex" /> <Book size={16} className="mr-[2px] inline-flex" />
@ -56,7 +56,7 @@ export function Link(
{props.children} {props.children}
{!props.noExtraIcons && (href || "").startsWith("https") && ( {!props.noextraicons && (href || "").startsWith("https") && (
<ExternalLink size={12} className="ml-[2px] mb-[3px] inline-flex" /> <ExternalLink size={12} className="ml-[2px] mb-[3px] inline-flex" />
)} )}
</NextLink> </NextLink>

@ -1,93 +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 GradientBanner from "@/components/effects/gradient-banner";
import MainBanner from "@/components/feat/MainBanner";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { useState, useEffect } from "react";
import { DialogContent, Dialog } from "@/components/ui/dialog";
import AffiliatePopup from "@/components/misc/AffiliatePopup";
import { Gradient } from "stripe-gradient";
import {useRouter} from "@/lib/useRouter";
import {pageFind} from "@/components/misc/Link"
export const defaultBanners: {
bannerSpace: number;
bannerContent: React.ReactNode;
}[] = [
// The affilation banner ALWAYS has to be first.
{
bannerSpace: 1,
bannerContent: (
<>
<AffiliateBanner />
</>
),
},
];
function AffiliateBanner() {
const router = useRouter();
return (
<>
<div onClick={() => router.push(pageFind("Special:GitHub/releases/tag/1.8.0") as string)} className="cursor-pointer">
<MainBanner size={1} className="max-h-[2rem] border-0">
<GradientBanner>
<strong>v2</strong>: the future of MHSF
</GradientBanner>
</MainBanner>
</div>
</>
);
}
export const bannerHooks: (() =>
| { bannerSpace: number; bannerContent: React.ReactNode }
| undefined)[] = [
() => {
if (process.env.NEXT_PUBLIC_VERCEL_ENV !== "production")
return {
bannerSpace: 1,
bannerContent: (
<MainBanner className="bg-orange-600">
Your not in production!{" "}
<Link href="https://mhsf.app">
<Button variant="link" className="dark:text-black">
Go to production
</Button>
</Link>
</MainBanner>
),
};
return undefined;
},
];

@ -30,68 +30,132 @@
import { import {
ArrowDownUpIcon, ArrowDownUpIcon,
Database,
DatabaseZap,
HardDriveDownload,
List,
ServerCog, ServerCog,
SlidersHorizontal, SlidersHorizontal,
UserPlus,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { type Filter } from "../lib/types/filter"; import { type Filter } from "../lib/types/filter";
import type { Sort } from "../lib/types/sort"; import type { Sort } from "../lib/types/sort";
import { TagFilter } from "@/lib/types/filters/tag-filter"; import { TagFilter } from "@/lib/types/filters/tag-filter";
import { allCategories } from "./tags";
import { CategoryFilter } from "@/lib/types/filters/category-filter";
import { PlayerRangeFilter } from "@/lib/types/filters/player-range-filter";
import { CombinationFilter } from "@/lib/types/filters/combination-filter";
type ModDBCategory = { type ModDBCategory = {
displayTitle: string; displayTitle: string;
description: string; description: string;
__custom?: boolean; __custom?: boolean;
entries: { entries: {
name: string; name: string;
icon: LucideIcon; icon: LucideIcon;
color: string; color: string;
value: Filter | Sort | { customAction: string }; value: Filter | Sort | { customAction: string };
description: string; description: string;
}[]; }[];
}; };
export const serverModDB: ModDBCategory[] = [ export const serverModDB: ModDBCategory[] = [
{ {
displayTitle: "Create Custom Files", displayTitle: "Create Custom Files",
description: description: `Create custom TypeScript-based filter or sorting systems, completely from the comfort of your own browser.
`Create custom TypeScript-based filter or sorting systems, completely from the comfort of your own browser.
Types used are *builtin* and you can see live type definitions and IntelliSense in the editor.`, Types used are *builtin* and you can see live type definitions and IntelliSense in the editor.`,
entries: [ entries: [
{ {
name: "Create Sort", name: "Create Sort",
icon: ArrowDownUpIcon, icon: ArrowDownUpIcon,
value: { customAction: "custom-sort" }, value: { customAction: "custom-sort" },
color: "#a3a68b", color: "#a3a68b",
description: "Create a new custom sort system using TypeScript, completely from the comfort of your own browser." description:
"Create a new custom sort system using TypeScript, completely from the comfort of your own browser.",
}, },
{ {
name: "Create Filter", name: "Create Filter",
icon: SlidersHorizontal, icon: SlidersHorizontal,
value: { customAction: "custom-filter" }, value: { customAction: "custom-filter" },
color: "#a3a68b", color: "#a3a68b",
description: "Create a new custom filtering system using TypeScript, completely from the comfort of your own browser." description:
"Create a new custom filtering system using TypeScript, completely from the comfort of your own browser.",
}, },
], ],
}, },
{ {
displayTitle: "Custom Files", displayTitle: "Custom Files",
description: "These are all of your activated modifications made in the editor.", description:
"These are all of your activated modifications made in the editor.",
__custom: true, __custom: true,
// Entries are already pre-loaded. // Entries are already pre-loaded.
entries: [] entries: [],
}, },
{ {
displayTitle: "Tag Filters", displayTitle: "Tag Filters",
description: "These are filters that are associated with an assortment of tags.", description:
"These are filters that are associated with an assortment of tags.",
entries: [ entries: [
{ {
name: "Always Online", name: "Always Online",
description: "All servers that are always online.", description: "All servers that are always online.",
color: "#a380e0", color: "#a380e0",
value: new TagFilter(2, false), value: new TagFilter(3, false),
icon: ServerCog icon: ServerCog,
} },
] {
} name: "Minehut-hosted",
description:
"Only show servers hosted directly on Minehut's infrastructure, and not external servers.",
color: "#fce2e2",
value: new TagFilter(0, true),
icon: HardDriveDownload,
},
{
name: "No player-less servers",
description: "Exclude servers that have no players",
color: "#66c219",
value: new TagFilter(2, true),
icon: UserPlus,
},
],
},
{
displayTitle: "Categories",
description: "Sort servers by the categories they directly are in.",
entries: allCategories.map((c, i) => {
return {
color: "#eae9e9",
value: new CategoryFilter(i),
name: c.name,
description: `Filter all servers that are have the category '${c.name}'`,
icon: List
};
}),
},
{
displayTitle: "Legacy Player Range Filters",
description: "Use old player range filters from MHSF v0-v1.",
entries: [
{
color: "#ceaced",
value: new CombinationFilter([
new PlayerRangeFilter(7, 15),
new TagFilter(3, true),
]),
name: "Only allow smaller servers",
description:
"Only allow servers that have the player range 7-15, and cannot be Always Online.",
icon: Database,
},
{
color: "#ceaced",
value: new PlayerRangeFilter(15, null),
name: "Only allow bigger servers",
description: "Only allow servers with more than 15 players",
icon: DatabaseZap,
},
],
},
]; ];

@ -29,9 +29,10 @@
*/ */
import type { BadgeColor } from "@/components/feat/server-list/server-card"; import type { BadgeColor } from "@/components/feat/server-list/server-card";
import { affiliates } from "@/components/feat/server-page/util";
import { MHSFData } from "@/lib/types/data"; import { MHSFData } from "@/lib/types/data";
import type { OnlineServer, ServerResponse } from "@/lib/types/mh-server"; import type { OnlineServer, ServerResponse } from "@/lib/types/mh-server";
import { Cake, ServerCog } from "lucide-react"; import { Cake, HardDriveDownload, ServerCog } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
const serverCache: any = {}; const serverCache: any = {};
@ -49,168 +50,193 @@ const serverCache: any = {};
// //
// You may also use `requestServer()` to grab the offline version of the server from the API, which may get you more information about the server (ServerResponse) // You may also use `requestServer()` to grab the offline version of the server from the API, which may get you more information about the server (ServerResponse)
export const allTags: Array<{ export const allTags: Array<{
name: (server: { name: (server: {
online?: OnlineServer; online?: OnlineServer;
server?: ServerResponse; server?: ServerResponse;
}) => Promise<string | ReactNode>; }) => Promise<string | ReactNode>;
condition?: (server: { condition?: (server: {
online?: OnlineServer; online?: OnlineServer;
server?: ServerResponse; server?: ServerResponse;
mhsfData?: MHSFData; mhsfData?: MHSFData;
}) => Promise<boolean>; }) => Promise<boolean>;
tooltipDesc: string; tooltipDesc: string;
htmlDocs: string; htmlDocs: string;
docsName: string; docsName: string;
role?: BadgeColor; role?: BadgeColor;
__disab?: boolean; __disab?: boolean;
__filter?: boolean; __filter?: boolean;
}> = [ }> = [
{ {
name: async (c) => ( name: async (s) => (
<> <span>
<div <HardDriveDownload size={16} />
className="items-center bg-green-700 dark:bg-green-400" </span>
style={{ ),
width: ".4rem", tooltipDesc: "This tag represents that the server is externally hosted.",
height: ".4rem", docsName: "External",
borderRadius: "9999px", htmlDocs:
}} "If a server is externally hosted, this tag appears. This can also be seen in the server plan.",
/> condition: async (s) => {
{String( return (
c.online === undefined (s.online !== undefined
? c.server?.playerCount ? s.online.staticInfo.serverPlan
: c.online.playerData.playerCount : (s.server?.server_plan ?? "")
)}{" "} )
online .split(" ")[0]
</> .split("_")[0]
), .toLocaleLowerCase() === "external"
condition: async (c) => );
(c.online === undefined },
? c.server?.playerCount role: "yellow-subtle",
: c.online.playerData.playerCount) !== 0, __filter: true,
htmlDocs: },
"'Players Online' specifies the amount of players currently online. If this server is a network, the amount of players may not be accurate as this counter only counts the number of players coming directly from Minehut", {
tooltipDesc: name: async (c) => (
"'Players Online' specifies the amount of players currently online.", <>
<div
className="items-center bg-green-700 dark:bg-green-400"
style={{
width: ".4rem",
height: ".4rem",
borderRadius: "9999px",
}}
/>
{String(
c.online === undefined
? c.server?.playerCount
: c.online.playerData.playerCount,
)}{" "}
online
</>
),
condition: async (c) =>
(c.online === undefined
? c.server?.playerCount
: c.online.playerData.playerCount) !== 0,
htmlDocs:
"'Players Online' specifies the amount of players currently online. If this server is a network, the amount of players may not be accurate as this counter only counts the number of players coming directly from Minehut",
tooltipDesc:
"'Players Online' specifies the amount of players currently online.",
role: "green-subtle", role: "green-subtle",
docsName: "Players Online", docsName: "Players Online",
__filter: true, __filter: true,
}, },
{ {
name: async (c) => ( name: async (c) => (
<> <>
<div <div
className="items-center bg-gray-700 dark:bg-gray-300" className="items-center bg-gray-700 dark:bg-gray-300"
style={{ style={{
width: ".4rem", width: ".4rem",
height: ".4rem", height: ".4rem",
borderRadius: "9999px", borderRadius: "9999px",
}} }}
/>{" "} />{" "}
0 online 0 online
</> </>
), ),
condition: async (c) => condition: async (c) =>
(c.online === undefined ? c.server?.playerCount: c.online.playerData.playerCount) === (c.online === undefined
0, ? c.server?.playerCount
htmlDocs: "Nobody is online this server.", : c.online.playerData.playerCount) === 0,
tooltipDesc: "Nobody is online this server.", htmlDocs: "Nobody is online this server.",
role: "gray-subtle", tooltipDesc: "Nobody is online this server.",
docsName: "Nobody Online", role: "gray-subtle",
__filter: true, docsName: "Nobody Online",
}, __filter: true,
{ },
name: async () => ( {
<> name: async () => (
<ServerCog size={16} /> <>
Always Online <ServerCog size={16} />
</> Always Online
), </>
condition: async (b) => ),
b.online !== undefined && b.online.staticInfo?.alwaysOnline, condition: async (b) =>
tooltipDesc: b.online !== undefined && b.online.staticInfo?.alwaysOnline,
'"Always online" means that the server will not shut down until the plan associated with it expires.', tooltipDesc:
htmlDocs: ` '"Always online" means that the server will not shut down until the plan associated with it expires.',
htmlDocs: `
This tag appears on servers where the plan they are under allows the server to be always online. However, if the plan associated with the tag expires, the server will no longer be Always Online. <em>This is in servers with one of the more expensive plans, or just a server that is external.</em> This tag appears on servers where the plan they are under allows the server to be always online. However, if the plan associated with the tag expires, the server will no longer be Always Online. <em>This is in servers with one of the more expensive plans, or just a server that is external.</em>
`, `,
docsName: "Always Online", docsName: "Always Online",
role: "blue-subtle", role: "blue-subtle",
__disab: true, __disab: true,
}, },
{ {
name: async (s) => name: async (s) =>
(s.online !== undefined (s.online !== undefined
? s.online.staticInfo.planMaxPlayers ? s.online.staticInfo.planMaxPlayers
: s.server?.maxPlayers) + " max players", : s.server?.maxPlayers) + " max players",
condition: async (s) => condition: async (s) =>
s.online !== undefined s.online !== undefined
? s.online.staticInfo.planMaxPlayers != null ? s.online.staticInfo.planMaxPlayers != null
: s.server?.maxPlayers != null, : s.server?.maxPlayers != null,
tooltipDesc: tooltipDesc:
"This tag represents the maximum amount of players the server can have at one time.", "This tag represents the maximum amount of players the server can have at one time.",
docsName: "Max Players", docsName: "Max Players",
htmlDocs: htmlDocs:
"This tag represents the maximum amount of players the server can have at one time. This doesn't mean the amount of players before the server crashes, it means the amount Minehut said the server can handle or the plan the server is on. <em>However, sometimes it might not appear because the server is external.</em>", "This tag represents the maximum amount of players the server can have at one time. This doesn't mean the amount of players before the server crashes, it means the amount Minehut said the server can handle or the plan the server is on. <em>However, sometimes it might not appear because the server is external.</em>",
role: "blue", role: "blue",
__filter: true, __filter: true,
}, },
{ {
name: async () => "Partner", name: async () => "Partner",
condition: async (s) => condition: async (s) =>
(s.server ?? s.online ?? { name: "" }).name === "CoreBoxx", affiliates.includes((s.server ?? s.online ?? { name: "" }).name),
tooltipDesc: "This server is a partner with MHSF.", tooltipDesc: "This server is a partner with MHSF.",
docsName: "Partner", docsName: "Partner",
htmlDocs: "This tag represents that this server is a partner with MHSF.", htmlDocs: "This tag represents that this server is a partner with MHSF.",
role: "rainbow", role: "rainbow",
}, },
{ {
name: async (s) => ( name: async (s) => (
<span className="capitalize"> <span className="capitalize">
{(s.online !== undefined {(s.online !== undefined
? s.online.staticInfo.serverPlan ? s.online.staticInfo.serverPlan
: (s.server?.server_plan ?? "") : (s.server?.server_plan ?? "")
) )
.split(" ")[0] .split(" ")[0]
.split("_")[0] .split("_")[0]
.toLocaleLowerCase()} .toLocaleLowerCase()}
</span> </span>
), ),
tooltipDesc: "This tag represents the server plan this server is using.", tooltipDesc: "This tag represents the server plan this server is using.",
docsName: "Server Plan", docsName: "Server Plan",
htmlDocs: htmlDocs:
"This tag represents the maximum amount of players the server can have at one time. This doesn't mean the amount of players before the server crashes, it means the amount Minehut said the server can handle or the plan the server is on. <em>However, sometimes it might not appear because the server is external.</em>", "This tag represents the maximum amount of players the server can have at one time. This doesn't mean the amount of players before the server crashes, it means the amount Minehut said the server can handle or the plan the server is on. <em>However, sometimes it might not appear because the server is external.</em>",
role: "red-subtle", role: "red-subtle",
__filter: true, __filter: true,
}, },
{ {
name: async (s) => ( name: async (s) => (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Cake size={16} /> Created {timeConverter(s.server?.creation)} <Cake size={16} /> Created {timeConverter(s.server?.creation)}
</span> </span>
), ),
condition: async (s) => s.server !== undefined, condition: async (s) => s.server !== undefined,
tooltipDesc: "This tag represents the date this server was created.", tooltipDesc: "This tag represents the date this server was created.",
docsName: "Creation Date", docsName: "Creation Date",
htmlDocs: "This tag represents the date this server was created.", htmlDocs: "This tag represents the date this server was created.",
role: "gray", role: "gray",
}, },
{ {
name: async (s) => "Favorited", name: async (s) => "Favorited",
condition: async (s) => condition: async (s) =>
(s.mhsfData ?? { favoriteData: { favoritedByAccount: false } }) (s.mhsfData ?? { favoriteData: { favoritedByAccount: false } })
.favoriteData.favoritedByAccount ?? false, .favoriteData.favoritedByAccount ?? false,
tooltipDesc: "This tag represents that you favorited this server.", tooltipDesc: "This tag represents that you favorited this server.",
docsName: "Favorited", docsName: "Favorited",
htmlDocs: htmlDocs:
"This tag shows that you favorited this server in MHSF. The amount of favorites is publicly shown to other users using MHSF. We do not provide server owners with data about who favorites a server, unlike traditional voting systems.", "This tag shows that you favorited this server in MHSF. The amount of favorites is publicly shown to other users using MHSF. We do not provide server owners with data about who favorites a server, unlike traditional voting systems.",
role: "red", role: "red",
}, },
// deprecated // deprecated
/**{ /**{
name: async () => "Velocity", name: async () => "Velocity",
condition: async (s) => { condition: async (s) => {
var type = await requestServer(s); var type = await requestServer(s);
@ -227,179 +253,179 @@ export const allTags: Array<{
]; ];
export const allCategories: Array<{ export const allCategories: Array<{
name: string; name: string;
condition: (server: OnlineServer) => Promise<boolean>; condition: (server: OnlineServer) => Promise<boolean>;
role?: BadgeColor; role?: BadgeColor;
}> = [ }> = [
{ {
name: "Farming", name: "Farming",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("farming"); return b.allCategories.includes("farming");
}, },
role: "default", role: "default",
}, },
{ {
name: "SMP", name: "SMP",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("smp"); return b.allCategories.includes("smp");
}, },
role: "default", role: "default",
}, },
{ {
name: "Factions", name: "Factions",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("factions"); return b.allCategories.includes("factions");
}, },
role: "default", role: "default",
}, },
{ {
name: "Meme", name: "Meme",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("meme"); return b.allCategories.includes("meme");
}, },
role: "default", role: "default",
}, },
{ {
name: "Puzzle", name: "Puzzle",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("puzzle"); return b.allCategories.includes("puzzle");
}, },
role: "default", role: "default",
}, },
{ {
name: "Box", name: "Box",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("box"); return b.allCategories.includes("box");
}, },
role: "default", role: "default",
}, },
{ {
name: "Minigames", name: "Minigames",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("minigames"); return b.allCategories.includes("minigames");
}, },
role: "default", role: "default",
}, },
{ {
name: "RPG", name: "RPG",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("rpg"); return b.allCategories.includes("rpg");
}, },
role: "default", role: "default",
}, },
{ {
name: "Parkour", name: "Parkour",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("parkour"); return b.allCategories.includes("parkour");
}, },
role: "default", role: "default",
}, },
{ {
name: "Lifesteal", name: "Lifesteal",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("lifesteal"); return b.allCategories.includes("lifesteal");
}, },
role: "default", role: "default",
}, },
{ {
name: "Prison", name: "Prison",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("prison"); return b.allCategories.includes("prison");
}, },
role: "default", role: "default",
}, },
{ {
name: "Gens", name: "Gens",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("gens"); return b.allCategories.includes("gens");
}, },
role: "default", role: "default",
}, },
{ {
name: "Skyblock", name: "Skyblock",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("skyblock"); return b.allCategories.includes("skyblock");
}, },
role: "default", role: "default",
}, },
{ {
name: "Roleplay", name: "Roleplay",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("roleplay"); return b.allCategories.includes("roleplay");
}, },
role: "default", role: "default",
}, },
{ {
name: "PvP", name: "PvP",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("pvp"); return b.allCategories.includes("pvp");
}, },
role: "default", role: "default",
}, },
{ {
name: "Modded", name: "Modded",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("modded"); return b.allCategories.includes("modded");
}, },
role: "default", role: "default",
}, },
{ {
name: "Creative", name: "Creative",
condition: async (b: any) => { condition: async (b: any) => {
return b.allCategories.includes("creative"); return b.allCategories.includes("creative");
}, },
role: "default", role: "default",
}, },
]; ];
async function requestServer(s: OnlineServer): Promise<ServerResponse> { async function requestServer(s: OnlineServer): Promise<ServerResponse> {
if (serverCache[s.name] === undefined) { if (serverCache[s.name] === undefined) {
const re = await fetch( const re = await fetch(
"https://api.minehut.com/server/" + s.name + "?byName=true" "https://api.minehut.com/server/" + s.name + "?byName=true",
); );
const json = await re.json(); const json = await re.json();
serverCache[s.name] = json.server; serverCache[s.name] = json.server;
return json.server; return json.server;
} }
return serverCache[s.name]; return serverCache[s.name];
} }
function timeConverter(UNIX_timestamp: any) { function timeConverter(UNIX_timestamp: any) {
const a = new Date(UNIX_timestamp); const a = new Date(UNIX_timestamp);
const months = [ const months = [
"1", "1",
"2", "2",
"3", "3",
"4", "4",
"5", "5",
"6", "6",
"7", "7",
"8", "8",
"9", "9",
"10", "10",
"11", "11",
"12", "12",
]; ];
const year = a.getFullYear(); const year = a.getFullYear();
const month = months[a.getMonth()]; const month = months[a.getMonth()];
const date = a.getDate(); const date = a.getDate();
const time = month + "/" + date + "/" + year; const time = month + "/" + date + "/" + year;
return time; return time;
} }

@ -67,7 +67,6 @@ export async function getBackendProcedure(request: NextApiRequest): Promise<Back
if (detectedIp !== null) { if (detectedIp !== null) {
const collection = defaultDatabase.collection("blocked-ips"); const collection = defaultDatabase.collection("blocked-ips");
console.log(await collection.findOne({ ip: detectedIp }), detectedIp)
if (await collection.findOne({ ip: detectedIp }) !== null) { if (await collection.findOne({ ip: detectedIp }) !== null) {
await mongoClient.close() await mongoClient.close()

@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
const JSX_INSERTS = `<iframe
src="{{ embed }}"
width={390}
height={145}
style={{ borderRadius: "0.25rem" }}
allow="clipboard-write"
frameBorder={0}
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
/>`
const HTML_INSERTS = `<iframe
src="{{ embed }}"
width="390"
height="145"
style="border-radius: 0.25rem;"
allow="clipboard-write"
frameborder="0"
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
></iframe>`
export function useEmbedGenerator(name: string) {
// In parameters
const [theme, setTheme] = useState<"light" | "dark">("light");
const [removeMinehutBranding, setRMHB] = useState<boolean>(false);
const [staticMode, setStatic] = useState<boolean>(false);
// Out parameters
const [jsxCode, setJSX] = useState<string>();
const [htmlCode, setHTML] = useState<string>();
const [finalURL, setFinalURL] = useState<string>();
useEffect(() => {
const baseUrl = `${window.location.protocol}//${window.location.host}`;
const url = new URL(`/embed/${name}`, baseUrl);
url.searchParams.set("theme", theme);
if (removeMinehutBranding)
url.searchParams.set("branding", "false");
if (staticMode)
url.searchParams.set("static", "true")
setJSX(JSX_INSERTS.replaceAll("{{ embed }}", url.toString()));
setHTML(HTML_INSERTS.replaceAll("{{ embed }}", url.toString()));
setFinalURL(url.toString());
}, [theme, removeMinehutBranding, staticMode, name])
return {
in: {
theme, setTheme,
removeMinehutBranding, setRMHB,
staticMode, setStatic
},
out: {
jsxCode,
htmlCode,
finalURL
}
}
}

@ -37,7 +37,7 @@ import { transpileTypeScript } from "@/app/(sl-modification-frame)/servers/embed
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 { ClerkEmbeddedFilter } from "@/components/feat/server-list/modification/modification-action";
import { supportedFilters } from "../types/filter"; import { supportedFilters } from "../types/supportedFilters";
type EmbeddedFilter = { type EmbeddedFilter = {
identifier: string; identifier: string;
@ -56,6 +56,7 @@ export function useFilters(data: OnlineServer[]) {
const [testModeLoading, setTestModeLoading] = useState(true); const [testModeLoading, setTestModeLoading] = useState(true);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<EmbeddedFilter[]>([]); const [filters, setFilters] = useState<EmbeddedFilter[]>([]);
const [tagStrings, setTagStrings] = useState<string[]>([]);
const [sort, setSort] = useState<SortFunction<OnlineServer> | null>(null); const [sort, setSort] = useState<SortFunction<OnlineServer> | null>(null);
const { user, isSignedIn } = useUser(); const { user, isSignedIn } = useUser();
@ -68,8 +69,6 @@ export function useFilters(data: OnlineServer[]) {
); );
const sortedData = sort === null ? resultData : resultData.sort(sort); const sortedData = sort === null ? resultData : resultData.sort(sort);
console.log({ sortedData, modificationMap, resultData, data, newFilters });
if (sortedData.length !== 0) setFilteredData(sortedData); if (sortedData.length !== 0) setFilteredData(sortedData);
}; };
@ -167,11 +166,6 @@ export function useFilters(data: OnlineServer[]) {
if (type === "sort") { if (type === "sort") {
newServers = data.sort((a, b) => filterFunc(a, b)); newServers = data.sort((a, b) => filterFunc(a, b));
setTestModeStatus(`Sorted ${newServers.length} servers.`); setTestModeStatus(`Sorted ${newServers.length} servers.`);
console.log(
newServers,
data.sort((a, b) => filterFunc(a, b)),
);
console.log(filterFunc);
setFilteredData(() => [...newServers]); setFilteredData(() => [...newServers]);
} }
@ -209,15 +203,16 @@ export function useFilters(data: OnlineServer[]) {
// biome-ignore lint: I'm gonna turn this off :sob: // biome-ignore lint: I'm gonna turn this off :sob:
useEffect(() => { useEffect(() => {
window.addEventListener("start-loading-server-view", () => setLoading(true))
if (!t) if (!t)
window.addEventListener("update-modification-stack", async () => { window.addEventListener("update-modification-stack", async () => {
await user?.reload(); await user?.reload();
setLoading(true);
let newFilters: EmbeddedFilter[] = []; let newFilters: EmbeddedFilter[] = [];
const filters = const filters =
((isSignedIn ? user.unsafeMetadata.filters : JSON.parse(localStorage.getItem("mhsf__filters") ?? "[]")) as Array< ((isSignedIn ? user.unsafeMetadata.filters : JSON.parse(localStorage.getItem("mhsf__filters") ?? "[]")) as Array<
ClerkEmbeddedFilter<unknown> ClerkEmbeddedFilter<unknown>
>) ?? []; >) ?? [];
setTagStrings([]);
if (isSignedIn) { if (isSignedIn) {
const activatedModifications = const activatedModifications =
@ -309,15 +304,13 @@ export function useFilters(data: OnlineServer[]) {
newFilters.push({ newFilters.push({
identifier: filterType?.ns + (Math.random() * Math.random() * Math.random()).toString(), identifier: filterType?.ns + (Math.random() * Math.random() * Math.random()).toString(),
functionFilter: (server: OnlineServer) => parsedFilter?.applyToServer({ online: server }) ?? true functionFilter: (server: OnlineServer) => parsedFilter?.applyToServer({ online: server }) ?? true
}) });
setTagStrings((c) => [...c, ...(parsedFilter?.getTagStrings() as string[])])
}); });
console.log(newFilters);
await updateServers(newFilters); await updateServers(newFilters);
}); });
}, [data]); }, [data]);
console.log(filters);
return { return {
filteredData, filteredData,
@ -328,5 +321,6 @@ export function useFilters(data: OnlineServer[]) {
filters.filter((item, index, array) => array.indexOf(item) === index) filters.filter((item, index, array) => array.indexOf(item) === index)
.length + (sort === null ? 1 : 0), .length + (sort === null ? 1 : 0),
loading, loading,
tagStrings
}; };
} }

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

@ -40,8 +40,6 @@ export function useMHSFServer(id: string) {
const response = await fetch("/api/v1/server/get/" + id); const response = await fetch("/api/v1/server/get/" + id);
const json = await response.json(); const json = await response.json();
console.log(json.server);
setServer(json.server); setServer(json.server);
})(); })();
}, [id]); }, [id]);

@ -14,7 +14,6 @@ export function useServer(serverSpecifier: { id?: string; name?: string }) {
`https://api.minehut.com/server/${serverSpecifier.id || serverSpecifier.name}${serverSpecifier.name ? "?byName=true" : ""}` `https://api.minehut.com/server/${serverSpecifier.id || serverSpecifier.name}${serverSpecifier.name ? "?byName=true" : ""}`
); );
const json = await res.json(); const json = await res.json();
console.log(json);
if (json.server === null) throw new Error("Server not found"); if (json.server === null) throw new Error("Server not found");
setServer(json.server); setServer(json.server);

@ -61,6 +61,23 @@ export function useServers() {
serverCount, serverCount,
loading, loading,
error, error,
refresh: () => {
setLoading(true);
try {
(async () => {
const serversFetch = await fetch("https://api.minehut.com/servers");
const serversJson: ServersAPIResponse = await serversFetch.json();
setPlayerCount(serversJson.total_players);
setServerCount(serversJson.total_servers);
setServers(serversJson.servers);
setLoading(false);
})();
} catch (e) {
console.error(e);
setError(true);
}
}
}; };
} }

@ -0,0 +1,13 @@
import { FilterIdentifier, Filter } from "./filter";
import { CategoryFilter } from "./filters/category-filter";
import { CombinationFilter } from "./filters/combination-filter";
import { PlayerRangeFilter } from "./filters/player-range-filter";
import { TagFilter } from "./filters/tag-filter";
export const filterRegistry = new Map<string, (identifier: FilterIdentifier) => Filter>();
// Register filters
filterRegistry.set("app.mhsf.filter.util.combinationFilter", (identifier: FilterIdentifier) => new CombinationFilter([]).fromIdentifier(identifier));
filterRegistry.set("app.mhsf.filter.tagFilter", (identifier: FilterIdentifier) => new TagFilter(0, false).fromIdentifier(identifier));
filterRegistry.set("app.mhsf.filter.categoryFilter", (identifier: FilterIdentifier) => new CategoryFilter(0).fromIdentifier(identifier));
filterRegistry.set("app.mhsf.filter.playerRangeFilter", (identifier: FilterIdentifier) => new PlayerRangeFilter(0, 0).fromIdentifier(identifier));

@ -31,36 +31,22 @@
import { allTags } from "@/config/tags"; import { allTags } from "@/config/tags";
import type { OnlineServer, ServerResponse } from "./mh-server"; import type { OnlineServer, ServerResponse } from "./mh-server";
import type { MHSFData } from "./data"; import type { MHSFData } from "./data";
import { TagFilter } from "./filters/tag-filter";
import { CategoryFilter } from "./filters/category-filter"; export type FilterIdentifier = {
[key: string]: string | number | boolean | null | Array<FilterIdentifier> | FilterIdentifier
}
/* Any filter that can be converted back and forth from a string or a Filter object */ /* Any filter that can be converted back and forth from a string or a Filter object */
export interface Filter { export interface Filter {
type(): "filter"; type(): "filter";
toIdentifier(): { [key: string]: string | number | boolean }; toIdentifier(): FilterIdentifier;
getSpecificFilterId(): string; getSpecificFilterId(): string;
fromIdentifier(identifier: { fromIdentifier(identifier: FilterIdentifier): Filter;
[key: string]: string | number | boolean;
}): Filter;
applyToServer(server: { applyToServer(server: {
online?: OnlineServer; online?: OnlineServer;
server?: ServerResponse; server?: ServerResponse;
mhsfData?: MHSFData; mhsfData?: MHSFData;
}): Promise<boolean>; }): Promise<boolean>;
getTagStrings(): string[];
} }
export const supportedFilters: {
ns: string;
fi: (identifier: {
[key: string]: string | number | boolean;
}) => Filter;
}[] = [
{
ns: "app.mhsf.filter.tagFilter",
fi: new TagFilter(0, false).fromIdentifier
},
{
ns: "app.mhsf.filter.categoryFilter",
fi: new CategoryFilter(0).fromIdentifier
}
];

@ -30,7 +30,7 @@
import { allCategories } from "@/config/tags"; import { allCategories } from "@/config/tags";
import type { MHSFData } from "../data"; import type { MHSFData } from "../data";
import type { Filter } from "../filter"; import type { Filter, FilterIdentifier } from "../filter";
import type { OnlineServer, ServerResponse } from "../mh-server"; import type { OnlineServer, ServerResponse } from "../mh-server";
export class CategoryFilter implements Filter { export class CategoryFilter implements Filter {
@ -40,11 +40,11 @@ export class CategoryFilter implements Filter {
return "filter"; return "filter";
} }
toIdentifier(): { [key: string]: string | number | boolean } { toIdentifier(): FilterIdentifier {
return { categoryIndex: this.categoryIndex }; return { categoryIndex: this.categoryIndex };
} }
fromIdentifier(identifier: { [key: string]: string | number | boolean; }): Filter { fromIdentifier(identifier: FilterIdentifier): Filter {
return new CategoryFilter(identifier.categoryIndex as number); return new CategoryFilter(identifier.categoryIndex as number);
} }
@ -61,4 +61,8 @@ export class CategoryFilter implements Filter {
constructor(categoryIndex: number) { constructor(categoryIndex: number) {
this.categoryIndex = categoryIndex; this.categoryIndex = categoryIndex;
} }
getTagStrings(): string[] {
return [`Server is a ${allCategories[this.categoryIndex].name} server`]
}
} }

@ -0,0 +1,80 @@
/*
* 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 { MHSFData } from "../data";
import { type Filter, type FilterIdentifier } from "../filter";
import { filterRegistry } from "../filter-registry";
import type { OnlineServer, ServerResponse } from "../mh-server";
export class CombinationFilter implements Filter {
filters: Filter[];
fromIdentifier(identifier: FilterIdentifier): Filter {
return new CombinationFilter((identifier.filters as Array<{type: string, metadata: { [key: string]: string | number | boolean }}>).map((c) => {
const factory = filterRegistry.get(c.type);
return factory ? factory(c.metadata) : undefined;
}).filter((v) => v !== undefined))
}
toIdentifier(): FilterIdentifier {
return {
filters: this.filters.map((c) => {
return { type: c.getSpecificFilterId(), metadata: c.toIdentifier() };
}),
};
}
getSpecificFilterId(): string {
return "app.mhsf.filter.util.combinationFilter";
}
type(): "filter" {
return "filter";
}
async applyToServer(server: {
online?: OnlineServer;
server?: ServerResponse;
mhsfData?: MHSFData;
}): Promise<boolean> {
const map = this.filters.map((c) => c.applyToServer(server));
const asynced = await Promise.all(map);
return !asynced.includes(false);
}
constructor(filters: Filter[]) {
this.filters = filters;
}
getTagStrings(): string[] {
return this.filters.flatMap((c) => c.getTagStrings());
}
}

@ -0,0 +1,91 @@
/*
* 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 WARRANTIE
* 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 { MHSFData } from "../data";
import type { Filter, FilterIdentifier } from "../filter";
import { OnlineServer, ServerResponse } from "../mh-server";
export class PlayerRangeFilter implements Filter {
min: number | null;
max: number | null;
type(): "filter" {
return "filter";
}
getSpecificFilterId(): string {
return "app.mhsf.filter.playerRangeFilter";
}
constructor(min: number | null, max: number | null) {
this.min = min;
this.max = max;
}
fromIdentifier(identifier: FilterIdentifier): Filter {
return new PlayerRangeFilter(identifier.min as number | null, identifier.max as number | null);
}
toIdentifier(): FilterIdentifier {
return {min: this.min, max: this.max};
}
applyToServer(server: {
online?: OnlineServer;
server?: ServerResponse;
mhsfData?: MHSFData;
}): Promise<boolean> {
if (this.max === null && this.min === null)
return new Promise((r) => r(true));
if (this.max === null)
return new Promise((r) => r((server.online?.playerData.playerCount ?? 0) >= (this.min ?? 0)))
if (this.min === null)
return new Promise((r) => r((server.online?.playerData.playerCount ?? 0) <= (this.max ?? 0)))
return new Promise((r) =>
r(
(server.online?.playerData.playerCount ?? 0) <= (this.max ?? 0) &&
(server.online?.playerData.playerCount ?? 0) >= (this.min ?? 0),
),
);
}
getTagStrings(): string[] {
const tagArray = [];
if (this.max !== null)
tagArray.push(`${this.max} maximum players`)
if (this.min !== null)
tagArray.push(`${this.min} minimum players`)
return tagArray;
}
}

@ -31,7 +31,7 @@
import { allTags } from "@/config/tags"; import { allTags } from "@/config/tags";
import type { MHSFData } from "../data"; import type { MHSFData } from "../data";
import type { OnlineServer, ServerResponse } from "../mh-server"; import type { OnlineServer, ServerResponse } from "../mh-server";
import type { Filter } from "../filter"; import type { Filter, FilterIdentifier } from "../filter";
export class TagFilter implements Filter { export class TagFilter implements Filter {
tagId: string; tagId: string;
@ -41,7 +41,7 @@ export class TagFilter implements Filter {
return "filter"; return "filter";
} }
toIdentifier(): { [key: string]: string | number | boolean } { toIdentifier(): FilterIdentifier {
return { tagId: this.tagId, opposite: this.opposite }; return { tagId: this.tagId, opposite: this.opposite };
} }
@ -49,9 +49,7 @@ export class TagFilter implements Filter {
return "app.mhsf.filter.tagFilter"; return "app.mhsf.filter.tagFilter";
} }
fromIdentifier(identifier: { fromIdentifier(identifier: FilterIdentifier): Filter {
[key: string]: string | number | boolean;
}): Filter {
return new TagFilter(identifier.tagId as string, identifier.opposite as boolean); return new TagFilter(identifier.tagId as string, identifier.opposite as boolean);
} }
@ -74,12 +72,6 @@ export class TagFilter implements Filter {
).condition ?? (() => true) ).condition ?? (() => true)
)(server); )(server);
console.log(result, server.online?.name, (
allTags.find((c) => btoa(c.docsName) === this.tagId) ?? {
condition: () => true,
}
));
if (typeof result === "boolean") if (typeof result === "boolean")
return new Promise((r) => r(this.opposite ? !result : result)) return new Promise((r) => r(this.opposite ? !result : result))
@ -87,4 +79,10 @@ export class TagFilter implements Filter {
result.then((c) => r(this.opposite ? !c : c)) result.then((c) => r(this.opposite ? !c : c))
}); });
} }
getTagStrings(): string[] {
if (this.opposite)
return [`Server does not have the '${allTags.find((c) => btoa(c.docsName) === this.tagId)?.docsName}' filter`]
return [`Server has the '${allTags.find((c) => btoa(c.docsName) === this.tagId)?.docsName}' filter`]
}
} }

@ -30,7 +30,6 @@
export async function getMinehutIcons(): Promise<MinehutIcon[] | undefined> { export async function getMinehutIcons(): Promise<MinehutIcon[] | undefined> {
const icons = await fetch("https://api.minehut.com/servers/icons"); const icons = await fetch("https://api.minehut.com/servers/icons");
console.log(icons);
if (!icons.ok) return undefined; if (!icons.ok) return undefined;
return await icons.json(); return await icons.json();
} }

@ -38,4 +38,5 @@ export interface Sort {
[key: string]: string | number | boolean; [key: string]: string | number | boolean;
}): Sort; }): Sort;
sortToServers(serverA: OnlineServer, serverB: OnlineServer): number; sortToServers(serverA: OnlineServer, serverB: OnlineServer): number;
getTagStrings(): string[];
} }

@ -0,0 +1,7 @@
import { FilterIdentifier, Filter } from "./filter";
import { filterRegistry } from "./filter-registry";
export const supportedFilters = Array.from(filterRegistry.entries()).map(([ns, fi]) => ({
ns,
fi
}));

@ -43,7 +43,11 @@ const isOldServerRoute = createRouteMatcher([
"/server/:serverName", "/server/:serverName",
"/server/:serverName/statistics", "/server/:serverName/statistics",
]); ]);
const apiRoute = createRouteMatcher(["/api/(.*)"]); const isWaitlistPage = createRouteMatcher(["/waitlist", "/waitlist(.*)"]);
const apiWaitlistPage = createRouteMatcher([
"/api/v1/user/waitlist(.*)",
"/api/v1/get-status",
]);
export default process.env.NEXT_PUBLIC_IS_AUTH === "true" export default process.env.NEXT_PUBLIC_IS_AUTH === "true"
? clerkMiddleware(async (auth, req) => { ? clerkMiddleware(async (auth, req) => {
@ -52,6 +56,19 @@ export default process.env.NEXT_PUBLIC_IS_AUTH === "true"
const requestHeaders = new Headers(req.headers); const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-url", req.url); requestHeaders.set("x-url", req.url);
if (!isWaitlistPage(req) && !apiWaitlistPage(req)) {
if (process.env.NEXT_PUBLIC_WAITLIST_ENABLED === "true") {
if (authRes.userId === null)
return NextResponse.redirect(new URL("/waitlist", req.url));
const metadata = (await client.users.getUser(authRes.userId))
.publicMetadata;
if (metadata.v2allowed !== true)
return NextResponse.redirect(new URL("/waitlist", req.url));
}
}
if (isRootRoute(req)) { if (isRootRoute(req)) {
switch (authRes.userId === null) { switch (authRes.userId === null) {
case false: case false:
@ -72,7 +89,7 @@ export default process.env.NEXT_PUBLIC_IS_AUTH === "true"
new URL(`/server/v2/minehut/${minehutRes.server._id}`, req.url), new URL(`/server/v2/minehut/${minehutRes.server._id}`, req.url),
); );
} }
return NextResponse.next({ return NextResponse.next({
request: { request: {
headers: requestHeaders, headers: requestHeaders,

@ -95,7 +95,6 @@ export default serve({
server: server.name, server: server.name,
date: new Date(), date: new Date(),
}); });
console.log(i, mh.servers.length);
}); });
return true; return true;
}); });

@ -95,7 +95,6 @@ export default async function handler(
res.send({ result: dailyAverages }); res.send({ result: dailyAverages });
} catch (error) { } catch (error) {
console.log(error);
res.status(500).json({ message: "An error occurred while fetching data" }); res.status(500).json({ message: "An error occurred while fetching data" });
} finally { } finally {
await client.close(); await client.close();

@ -114,7 +114,6 @@ export default async function handler(
res.send({ data }); res.send({ data });
} catch (error) { } catch (error) {
console.log(error);
res.status(500).json({ message: "An error occurred while fetching data" }); res.status(500).json({ message: "An error occurred while fetching data" });
} finally { } finally {
await client.close(); await client.close();

@ -30,6 +30,7 @@
import { getBackendProcedure } from "@/lib/backend-procedure"; import { getBackendProcedure } from "@/lib/backend-procedure";
import type { MHSFData } from "@/lib/types/data"; import type { MHSFData } from "@/lib/types/data";
import { clerkClient, getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
@ -83,17 +84,17 @@ export default async function handler(
await mongo.connect(); await mongo.connect();
const db = mongo.db(process.env.CUSTOM_MONGO_DB ?? "mhsf"); const db = mongo.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const stats = mongo.db("mhsf") const stats = mongo.db("mhsf")
const userId = req.cookies.userId; const {userId} = getAuth(req);
// Run queries in parallel // Run queries in parallel
const [favoriteData, customizationData, playerData, achievements] = const [favoriteData, customizationData, playerData, achievements] =
await Promise.all([ await Promise.all([
findFavoriteData(serverData.name, userId, stats, { findFavoriteData(serverData.name, userId ?? undefined, stats, {
maxFavoriteEntries, maxFavoriteEntries,
favoriteTimespanStart, favoriteTimespanStart,
favoriteTimespanEnd, favoriteTimespanEnd,
}), }),
findCustomizationData(serverData.name, userId, db), findCustomizationData(serverData.name, userId ?? undefined, stats),
findPlayerData(serverData.name, stats, { findPlayerData(serverData.name, stats, {
maxPlayerEntries, maxPlayerEntries,
playerTimespanStart, playerTimespanStart,
@ -147,6 +148,7 @@ async function findCustomizationData(
isOwned: boolean; isOwned: boolean;
isOwnedByUser: boolean; isOwnedByUser: boolean;
}> { }> {
const clerk = await clerkClient();
// Run queries in parallel // Run queries in parallel
const [customizationData, ownedServerData] = await Promise.all([ const [customizationData, ownedServerData] = await Promise.all([
db.collection("customization").findOne({ server: serverName }), db.collection("customization").findOne({ server: serverName }),
@ -160,6 +162,7 @@ async function findCustomizationData(
...(customizationData as any), ...(customizationData as any),
isOwned: true, isOwned: true,
isOwnedByUser: ownedServerData?.author === userId, isOwnedByUser: ownedServerData?.author === userId,
userProfilePicture: userId ? (await clerk.users.getUser(ownedServerData?.author)).imageUrl : 'no user'
}; };
} }

@ -0,0 +1,127 @@
/*
* MHSF, Minehut Server List
* All external content is rather licensed under the ECA Agreement
* located here: https://mhsf.app/docs/legal/external-content-agreement
*
* All code under MHSF is licensed under the MIT License
* by open source contributors
*
* Copyright (c) 2025 dvelo
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { clerkClient, getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { userId } = getAuth(req);
if (!userId) {
return res.json({ message: "User not found" });
}
// Get the OAuth access token for the user
const provider = "discord";
const client = await clerkClient();
const clerkResponse = await client.users.getUserOauthAccessToken(
userId,
provider,
);
const accessToken = clerkResponse.data[0]?.token || "";
if (!accessToken) {
return res.status(401).json({ message: "Access token not found" });
}
const response = await fetch("https://discord.com/api/users/@me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
const json: DiscordUser = await response.json()
const mongo = new MongoClient(process.env.MONGO_DB as string);
const db = mongo.db("mhsf").collection('waitlist-approved');
const refs = mongo.db("mhsf").collection('waitlist-refs');
const entry = await db.findOneAndDelete({
user: json.username
})
if (entry === null) {
return res.status(400).send({message: "You are unfortunately not eligible."})
}
const rand = crypto.randomUUID();
await refs.insertOne({
usersRemaining: 2,
id: rand,
userAssociatedTo: json.username
})
const user = await client.users.getUser(userId);
await client.users.updateUserMetadata(userId, {
publicMetadata: {
...user.publicMetadata,
v2allowed: true
}
});
return res.send({message: "You are eligible!", refUUID: rand})
}
export interface DiscordUser {
id: string
username: string
avatar: string
discriminator: string
public_flags: number
flags: number
accent_color: number
global_name: string
banner_color: string
clan: Clan
primary_guild: PrimaryGuild
mfa_enabled: boolean
locale: string
premium_type: number
email: string
verified: boolean
}
export interface Clan {
identity_guild_id: string
identity_enabled: boolean
tag: string
badge: string
}
export interface PrimaryGuild {
identity_guild_id: string
identity_enabled: boolean
tag: string
badge: string
}

@ -0,0 +1,36 @@
import { clerkClient, getAuth } from "@clerk/nextjs/server";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { userId } = getAuth(req);
if (!userId) {
return res.json({ message: "User not found" });
}
// Get the OAuth access token for the user
const provider = "discord";
const client = await clerkClient();
const clerkResponse = await client.users.getUserOauthAccessToken(
userId,
provider,
);
const accessToken = clerkResponse.data[0]?.token || "";
if (!accessToken) {
return res.status(401).json({ message: "Access token not found" });
}
const response = await fetch("https://discord.com/api/users/@me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
const json = await response.json()
res.send({ discordData: json });
}

@ -0,0 +1,92 @@
/*
* 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 { clerkClient, getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const client = await clerkClient();
const { userId } = getAuth(req);
if (!userId) {
return res.status(400).json({ message: "User not found" });
}
const user = await client.users.getUser(userId);
if (user.publicMetadata.v2allowed === true) {
return res.status(400).json({ message: "v2 already allowed." });
}
const { id } = req.body;
if (!id) {
return res.status(400).json({ message: "ID not specified" });
}
const uuidTested =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
id,
);
if (uuidTested === false)
return res.status(400).json({ message: "UUID not valid" });
const mongo = new MongoClient(process.env.MONGO_DB as string);
const refs = mongo.db("mhsf").collection("waitlist-refs");
const ref = await refs.findOne({
id,
});
if (ref !== undefined && ref !== null) {
if (ref.usersRemaining !== 0) {
await refs.findOneAndUpdate(
{
id,
},
{
$inc: { usersRemaining: -1 },
},
);
await client.users.updateUserMetadata(userId, {
publicMetadata: {
...user.publicMetadata,
v2allowed: true,
},
});
return res.send({ message: "You are eligible!" });
}
return res.status(400).json({ message: "No users left" });
}
return res.status(400).json({ message: "Unknown ID" });
}

1171
yarn.lock

File diff suppressed because it is too large Load Diff