feat: custom modifications

This commit is contained in:
dvelo 2025-04-04 21:10:50 -05:00
parent bddf5f1528
commit ed2d6f0ac1
21 changed files with 1686 additions and 875 deletions

@ -1,5 +1,5 @@
{
"name": "mh-stats",
"name": "mhsf",
"version": "1.3.0",
"private": true,
"packageManager": "yarn@1.22.22",
@ -35,9 +35,15 @@
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "1.1.0",
"@radix-ui/react-tabs": "^1.1.3",
"@tanstack/react-query": "^5.69.0",
"@trpc/client": "^11.0.0",
"@trpc/next": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/lodash": "^4.17.16",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/request-ip": "^0.0.41",
"@unocss/eslint-plugin": "^0.61.5",
"@unocss/postcss": "^0.61.5",
"@unocss/transformer-directives": "^0.61.5",
@ -80,8 +86,10 @@
"recharts": "^2.15.1",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0",
"request-ip": "^3.3.0",
"sonner": "^1.7.0",
"stripe-gradient": "^1.0.1",
"superjson": "^2.2.2",
"swapy": "^1.0.5",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^4.0.7",
@ -89,7 +97,8 @@
"tailwindcss-patch": "^4.0.0",
"turbo": "^2.4.0",
"unplugin-tailwindcss-mangle": "^3.0.1",
"vaul": "^1.1.2"
"vaul": "^1.1.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@clerk/themes": "^2.1.19",

@ -74,22 +74,22 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<ClerkProvider>
<IsScript>
<NuqsAdapter>
<FontBoundary>
<TooltipProvider>
<Toaster richColors position="top-center" />
<ClerkProvider>
<NavBar />
<div className="pt-16 min-h-screen">{children}</div>
<Footer />
</ClerkProvider>
</TooltipProvider>
</FontBoundary>
</NuqsAdapter>
</IsScript>
</ClerkProvider>
<ClerkProvider>
<IsScript>
<NuqsAdapter>
<FontBoundary>
<TooltipProvider>
<Toaster richColors position="top-center" />
<ClerkProvider>
<NavBar />
<div className="pt-16 min-h-screen">{children}</div>
<Footer />
</ClerkProvider>
</TooltipProvider>
</FontBoundary>
</NuqsAdapter>
</IsScript>
</ClerkProvider>
</ThemeProvider>
</html>
);

@ -0,0 +1,122 @@
/*
* 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 { ModificationAction } from "@/components/feat/server-list/modification/modification-action";
import { ClerkCustomActivatedModification } from "@/components/feat/server-list/modification/modification-file-creation-dialog";
import {
Setting,
SettingContent,
SettingDescription,
SettingMeta,
SettingTitle,
} from "@/components/feat/settings/setting";
import { Button } from "@/components/ui/button";
import { Material } from "@/components/ui/material";
import { Separator } from "@/components/ui/separator";
import { Link } from "@/components/util/link";
import { serverModDB } from "@/config/sl-mod-db";
import { useUser } from "@clerk/nextjs";
import { ArrowLeft, Filter, SortAsc } from "lucide-react";
import { useQueryState } from "nuqs";
import { use } from "react";
import Markdown from "react-markdown";
export default function ModificationPage({
params,
}: {
params: Promise<{ category: string; "custom-mod": string }>;
}) {
const { category, "custom-mod": mod } = use(params);
const { user } = useUser();
const [backRoute] = useQueryState("b", {
defaultValue: "/servers/embedded/sl-modification-frame",
});
console.log(mod);
const modObj = (
(user?.unsafeMetadata
.activatedModifications as ClerkCustomActivatedModification[]) ?? []
).find((c) => c.friendlyName === atob(decodeURIComponent(mod)));
if (modObj === undefined)
return <>We couldn't find the modification you were looking for.</>;
return (
<main className="max-w-[800px] p-4">
<div
className="h-[150px] w-full rounded-xl p-2"
style={{ backgroundColor: modObj?.color }}
>
<Link href={backRoute}>
<ArrowLeft />
</Link>
</div>
<span className="p-4">
<h1 className="text-xl font-bold w-full">{modObj?.friendlyName}</h1>
<Markdown className="text-wrap pt-2">
This is a custom modification. Enable it! (or not) It's your own! (are
you proud?)
</Markdown>
<Button className="mt-2">
{modObj?.active ? "Disable" : "Enable"}
</Button>
<Separator className="mt-3" />
<Material className="mt-6">
<Setting>
<SettingContent className="flex items-center">
<SettingMeta>
<SettingTitle>Type</SettingTitle>
<SettingDescription>
What type of modification is this?
</SettingDescription>
</SettingMeta>
<div className="flex items-center">
{modObj.testMode === "sort" ? (
<div className="flex items-center gap-1">
<SortAsc size={16} />
Sort
</div>
) : (
<div className="flex items-center gap-1">
<Filter size={16} /> Filter
</div>
)}
</div>
</SettingContent>
</Setting>
</Material>
</span>
</main>
);
}

@ -31,62 +31,95 @@
"use client";
import { ModificationAction } from "@/components/feat/server-list/modification/modification-action";
import { ClerkCustomActivatedModification } from "@/components/feat/server-list/modification/modification-file-creation-dialog";
import { Material } from "@/components/ui/material";
import { Separator } from "@/components/ui/separator";
import { Link } from "@/components/util/link";
import { serverModDB } from "@/config/sl-mod-db";
import { useRouter } from "@/lib/useRouter";
import { cn } from "@/lib/utils";
import { ArrowLeft } from "lucide-react";
import { SignedIn, useUser } from "@clerk/nextjs";
import { ArrowLeft, Binary } from "lucide-react";
import { use } from "react";
import Markdown from "react-markdown";
export default function ServerListCategoryFrame({
params,
params,
}: {
params: Promise<{ category: string }>;
params: Promise<{ category: string }>;
}) {
const { category } = use(params);
const categoryObj = serverModDB.find(
(c) => c.displayTitle === atob(category)
);
const router = useRouter();
const { user } = useUser();
const { category } = use(params);
const categoryObj = serverModDB.find(
(c) => c.displayTitle === atob(category),
);
const router = useRouter();
return (
<main className="max-w-[800px] p-4">
<h1 className="text-xl font-bold w-full flex items-center gap-2">
<Link href="/servers/embedded/sl-modification-frame">
<ArrowLeft size={20} />
</Link>
{categoryObj?.displayTitle}
</h1>
<Markdown className="text-wrap pt-2">{categoryObj?.description}</Markdown>
return (
<main className="max-w-[800px] p-4">
<h1 className="text-xl font-bold w-full flex items-center gap-2">
<Link href="/servers/embedded/sl-modification-frame">
<ArrowLeft size={20} />
</Link>
{categoryObj?.displayTitle}
</h1>
<Markdown className="text-wrap pt-2">{categoryObj?.description}</Markdown>
<Material className="mt-10 p-4 grid grid-cols-6 gap-2">
{categoryObj?.entries.map((m) => (
<Material
className="p-2 hover:drop-shadow-card-hover cursor-pointer"
elevation="high"
onClick={() =>
router.push(
`/servers/embedded/sl-modification-frame/category/${category}/modification/${btoa(m.name)}?b=${encodeURIComponent(`/servers/embedded/sl-modification-frame/category/${category}`)}`
)
}
key={m.name}
>
<div
className={cn(
"w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center"
)}
style={{ backgroundColor: m.color }}
>
<m.icon className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center" />
</div>
<span className="text-sm text-center w-full flex items-center justify-center">
{m.name}
</span>
</Material>
))}
</Material>
</main>
);
<Material className="mt-10 p-4 grid grid-cols-6 gap-2">
{categoryObj?.entries.map((m) => (
<Material
className="p-2 hover:drop-shadow-card-hover cursor-pointer"
elevation="high"
onClick={() =>
router.push(
`/servers/embedded/sl-modification-frame/category/${category}/modification/${btoa(m.name)}?b=${encodeURIComponent(`/servers/embedded/sl-modification-frame/category/${category}`)}`,
)
}
key={m.name}
>
<div
className={cn(
"w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center",
)}
style={{ backgroundColor: m.color }}
>
<m.icon className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center" />
</div>
<span className="text-sm text-center w-full flex items-center justify-center">
{m.name}
</span>
</Material>
))}
<SignedIn>
{categoryObj?.__custom &&
(
(user?.unsafeMetadata
.activatedModifications as ClerkCustomActivatedModification[]) ??
[]
).map((m) => (
<Material
elevation="high"
className="p-2 hover:drop-shadow-card-hover cursor-pointer"
key={m.friendlyName}
onClick={() =>
router.push(
`/servers/embedded/sl-modification-frame/category/${category}/modification/_custom/${btoa(m.friendlyName)}`,
)
}
>
<div
className="w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center"
style={{ backgroundColor: m.color }}
>
<Binary className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center" />
</div>
<span className="text-sm text-center w-full flex items-center justify-center">
{m.friendlyName}
</span>
</Material>
))}
</SignedIn>
</Material>
</main>
);
}

@ -47,15 +47,21 @@ import {
Braces,
EllipsisVertical,
FileCode,
Filter,
Pencil,
SortAsc,
Trash,
} from "lucide-react";
import { use } from "react";
import { toast } from "sonner";
import { findSupportedOperations } from "../file/[filename]/page";
export default function ServerListModificationFrame() {
const { user } = useUser();
const files =
(user?.unsafeMetadata.customFiles as Array<ClerkCustomModification>) ?? [];
const operations = use((async () => await Promise.all(files.map(async (c) => await findSupportedOperations(c.contents))))())
console.log(operations)
return (
<main className="max-w-[800px] p-4">
<h1 className="text-xl font-bold w-full flex items-center gap-2">
@ -80,6 +86,8 @@ export default function ServerListModificationFrame() {
>
<span className="flex items-center gap-1">
<FileCode size={16} />
{operations[i].filter && <Filter size={16}/>}
{operations[i].sort && <SortAsc size={16}/>}
{c.name}.ts
</span>
<span>
@ -105,6 +113,7 @@ export default function ServerListModificationFrame() {
files.splice(i, 1);
await user?.update({
unsafeMetadata: {
...user.unsafeMetadata,
customFiles: files,
},
});

@ -34,68 +34,100 @@ import { Material } from "@/components/ui/material";
import { Separator } from "@/components/ui/separator";
import { Link } from "@/components/util/link";
import { serverModDB } from "@/config/sl-mod-db";
import { ArrowRight } from "lucide-react";
import { ArrowRight, Binary } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useRouter } from "@/lib/useRouter";
import { SignedIn, useUser } from "@clerk/nextjs";
import { ClerkCustomActivatedModification } from "@/components/feat/server-list/modification/modification-file-creation-dialog";
export default function ServerListModificationFrame() {
const router = useRouter();
const router = useRouter();
const { user } = useUser();
return (
<main className="max-w-[800px] p-4">
<h1 className="text-xl font-bold w-full">Filters & Sorting</h1>
<div className="flex items-center gap-2 my-2">
<Button size="sm">Active modifications</Button>
<Link href="/servers/embedded/sl-modification-frame/files">
<Button size="sm">Custom files</Button>
</Link>
<Button size="sm">Settings</Button>
</div>
<span className="text-wrap pt-2">
Pick out different filters & sorting systems to customize your server
viewing experience. We frequently add new filters in accordance to new
features, as well.
</span>
<Material className="mt-10 p-4">
{serverModDB.map((c) => (
<span key={c.displayTitle}>
<h2 className="text-lg font-bold pb-3 flex justify-between">
{c.displayTitle}
<Link
href={`/servers/embedded/sl-modification-frame/category/${btoa(c.displayTitle)}`}
className="flex gap-2 text-sm font-normal items-center"
>
<ArrowRight size={16} />
View more
</Link>
</h2>
<div className="grid grid-cols-6 gap-2">
{c.entries.map((m) => (
<Material
elevation="high"
className="p-2 hover:drop-shadow-card-hover cursor-pointer"
key={m.name}
onClick={() =>
router.push(
`/servers/embedded/sl-modification-frame/category/${btoa(c.displayTitle)}/modification/${btoa(m.name)}`
)
}
>
<div
className="w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center"
style={{ backgroundColor: m.color }}
>
<m.icon className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center" />
</div>
<span className="text-sm text-center w-full flex items-center justify-center">
{m.name}
</span>
</Material>
))}
</div>
</span>
))}
</Material>
</main>
);
return (
<main className="max-w-[800px] p-4">
<h1 className="text-xl font-bold w-full">Filters & Sorting</h1>
<div className="flex items-center gap-2 my-2">
<Button size="sm">Active modifications</Button>
<Link href="/servers/embedded/sl-modification-frame/files">
<Button size="sm">Custom files</Button>
</Link>
<Button size="sm">Settings</Button>
</div>
<span className="text-wrap pt-2">
Pick out different filters & sorting systems to customize your server
viewing experience. We frequently add new filters in accordance to new
features, as well.
</span>
<Material className="mt-10 p-4">
{serverModDB.map((c) => (
<div key={c.displayTitle} className="my-4">
<h2 className="text-lg font-bold pb-3 flex justify-between">
{c.displayTitle}
<Link
href={`/servers/embedded/sl-modification-frame/category/${btoa(c.displayTitle)}`}
className="flex gap-2 text-sm font-normal items-center"
>
<ArrowRight size={16} />
View more
</Link>
</h2>
<div className="grid grid-cols-6 gap-2">
{c.entries.map((m) => (
<Material
elevation="high"
className="p-2 hover:drop-shadow-card-hover cursor-pointer"
key={m.name}
onClick={() =>
router.push(
`/servers/embedded/sl-modification-frame/category/${btoa(c.displayTitle)}/modification/${btoa(m.name)}`,
)
}
>
<div
className="w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center"
style={{ backgroundColor: m.color }}
>
<m.icon className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center" />
</div>
<span className="text-sm text-center w-full flex items-center justify-center">
{m.name}
</span>
</Material>
))}
<SignedIn>
{c.__custom &&
(
(user?.unsafeMetadata
.activatedModifications as ClerkCustomActivatedModification[]) ??
[]
).map((m) => (
<Material
elevation="high"
className="p-2 hover:drop-shadow-card-hover cursor-pointer"
key={m.friendlyName}
onClick={() =>
router.push(
`/servers/embedded/sl-modification-frame/category/${btoa(c.displayTitle)}/modification/custom/${btoa(m.friendlyName)}`,
)
}
>
<div
className="w-full h-[40px] mb-2 rounded-lg items-center text-center justify-center"
style={{ backgroundColor: m.color }}
>
<Binary className="relative top-[calc(50%-12px)] items-center w-full text-center justify-center" />
</div>
<span className="text-sm text-center w-full flex items-center justify-center">
{m.friendlyName}
</span>
</Material>
))}
</SignedIn>
</div>
</div>
))}
</Material>
</main>
);
}

@ -0,0 +1,49 @@
/*
* 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 { createContext } from "@/server/context";
import { appRouter } from "@/server/router/_app";
import { getAuth } from "@clerk/nextjs/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
// I still have no clue why this works.
import type { NextRequest } from "../../../../../../../node_modules/@clerk/nextjs/node_modules/next/dist/server/web/spec-extension/request";
const handler = (request: NextRequest) => {
const {userId} = getAuth(request);
return fetchRequestHandler({
endpoint: "/api/trpc",
req: request,
router: appRouter,
createContext: createContext(userId),
});
};
export { handler as GET, handler as POST };

@ -236,7 +236,7 @@
.loading-shimmer {
-webkit-text-fill-color: transparent;
animation-duration: 2.5s;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-name: loading-shimmer;
background: var(--color-muted-foreground)

@ -1,6 +1,7 @@
import { BrandingGenericIcon } from "../icons/branding-icons";
import { Link } from "../../util/link";
import { FooterStatus } from "./status";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
export function Footer() {
return (
@ -35,6 +36,29 @@ export function Footer() {
Contact
</Link>
</li>
<li className="text-sm">
<DropdownMenu
>
<DropdownMenuTrigger>
<a className="text-muted-foreground hover:text-shadcn-primary transition-colors cursor-pointer">Discord</a>
</DropdownMenuTrigger>
<DropdownMenuContent>
<Link href="https://t.mhsf.app/d/m" noExtraIcons>
<DropdownMenuItem className="block py-2">
Minehut Discord
<small className="flex">Not officially owned by MHSF, however conversations about MHSF and related take place there.</small>
</DropdownMenuItem>
</Link>
<Link href="https://t.mhsf.app/d/u" noExtraIcons>
<DropdownMenuItem className="block py-2">
MHSF Discord
<small className="flex">A read-only server for updates related to MHSF.</small>
</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
</li>
</ul>
</span>
<FooterStatus />

@ -63,6 +63,15 @@ export type ClerkCustomModification = {
testId?: string;
};
export type ClerkCustomActivatedModification = {
originalFileName: string;
friendlyName: string;
transpiledContents: string;
active: boolean;
testMode: "filter" | "sort";
color: string;
}
export function ModificationFileCreationDialog({
children,
type,
@ -98,10 +107,11 @@ export function ModificationFileCreationDialog({
<DialogTrigger>
<Button
className="w-full"
onClick={(e) => {
onClick={async (e) => {
if (!isSignedIn) return toast.error("Please login.");
user?.update({
await user?.update({
unsafeMetadata: {
...user.unsafeMetadata,
customFiles: [
...((user.unsafeMetadata
.customFiles as Array<ClerkCustomModification>) ?? []),

@ -1,5 +1,10 @@
import { AnimatedText } from "@/components/ui/animated-text";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useQueryState } from "nuqs";
export function ServerTestModeSelector({
@ -18,16 +23,27 @@ export function ServerTestModeSelector({
<div className="pl-5 flex items-center gap-1">
<Tooltip>
<TooltipTrigger>
<span className="relative flex size-2.5 pt-[1px] items-center cursor-pointer">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-orange-400 opacity-75" />
<span className="relative inline-flex size-2.5 rounded-full bg-orange-500" />
</span></TooltipTrigger>
<TooltipContent>Test mode was enabled.</TooltipContent>
<span className="relative flex size-2.5 pt-[1px] items-center cursor-pointer">
<span
className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
testModeLoading ? "bg-orange-500" : "bg-green-400"
)}
/>
<span
className={cn(
"relative inline-flex size-2.5 rounded-full",
testModeLoading ? "bg-orange-500" : "bg-green-500"
)}
/>
</span>
</TooltipTrigger>
<TooltipContent>Test mode was enabled.</TooltipContent>
</Tooltip>
<AnimatedText
className="text-muted-foreground top-[2.5px] left-[6px] min-w-[70vw]"
text={testModeStatus}
glimmer
glimmer={testModeLoading}
/>
</div>
);

@ -38,14 +38,12 @@ import { MultisessionAppSupport } from "@clerk/nextjs/internal";
export const ClerkProvider = ({ children }: { children: React.ReactNode }) => {
const { resolvedTheme } = useTheme();
if (resolvedTheme === "dark") {
console.log(resolvedTheme);
return (
<ImportedClerkProvider appearance={{ baseTheme: dark }}>
<MultisessionAppSupport>{children}</MultisessionAppSupport>
</ImportedClerkProvider>
);
}
console.log("a");
return (
<ImportedClerkProvider>

@ -51,8 +51,7 @@ type ModDBCategory = {
export const serverModDB: ModDBCategory[] = [
{
displayTitle: "Custom Files",
__custom: true,
displayTitle: "Create Custom Files",
description:
`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.`,
@ -73,4 +72,11 @@ export const serverModDB: ModDBCategory[] = [
},
],
},
{
displayTitle: "Custom Files",
description: "These are all of your activated modifications made in the editor.",
__custom: true,
// Entries are already pre-loaded.
entries: []
}
];

@ -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 as ClerkClient, getAuth } from "@clerk/nextjs/server";
import { Db, MongoClient } from "mongodb";
import { NextApiRequest } from "next";
import { NextRequest, NextResponse } from "next/server";
import requestIp from 'request-ip'
type BackendProcedureValue = {
status: "BANNED" | "OK" | "BLOCKED",
allowed: boolean,
mongoClient?: MongoClient,
defaultDatabase?: Db
}
export async function getBackendProcedure(request: NextApiRequest): Promise<BackendProcedureValue> {
const mongoClient = new MongoClient(process.env.MONGO_DB as string);
const {userId} = getAuth(request)
await mongoClient.connect();
const defaultDatabase = mongoClient.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const clerkClient = await ClerkClient();
if (userId !== null) {
// User exists
const user = await clerkClient.users.getUser(userId);
const userBannedMetadata = user.publicMetadata.banned;
if (userBannedMetadata !== undefined) {
// User is banned
await mongoClient.close()
return {
status: "BANNED",
allowed: false
}
}
}
const detectedIp = requestIp.getClientIp(request);
if (detectedIp !== null) {
const collection = defaultDatabase.collection("blocked-ips");
console.log(await collection.findOne({ ip: detectedIp }), detectedIp)
if (await collection.findOne({ ip: detectedIp }) !== null) {
await mongoClient.close()
return { status: "BLOCKED", allowed: false }
}
}
await mongoClient.close()
return {
status: "OK",
allowed: true,
}
}
function convert(request: Headers) {
const headersObject: Record<string, string> = {};
for (const [key, value] of request) {
headersObject[key] = value;
}
return headersObject;
}

@ -36,111 +36,135 @@ import { tryCatch } from "../try-catch";
import { transpileTypeScript } from "@/app/(sl-modification-frame)/servers/embedded/sl-modification-frame/file/[filename]/page";
export function useFilters(data: OnlineServer[]) {
const [filteredData, setFilteredData] = useState<OnlineServer[]>(data);
const [t] = useQueryState("tm");
const [testModeEnabled, setTestModeEnabled] = useState(false);
const [testModeStatus, setTestModeStatus] = useState("Haven't connected thread yet (if stuck, select the other tab, and come back)");
const [testModeLoading, setTestModeLoading] = useState(true);
const [filteredData, setFilteredData] = useState<OnlineServer[]>(data);
const [t] = useQueryState("tm");
const [testModeEnabled, setTestModeEnabled] = useState(false);
const [testModeStatus, setTestModeStatus] = useState(
"Haven't connected thread yet (if stuck, select the other tab, and come back)",
);
const [testModeLoading, setTestModeLoading] = useState(true);
useEffect(() => {
if (filteredData.length === 0) setFilteredData(data);
}, [data, filteredData.length]);
useEffect(() => {
if (filteredData.length === 0) setFilteredData(data);
}, [data, filteredData.length]);
useEffect(() => {
if (data.length !== 0)
window.addEventListener("test-mode.enable", (c) => {
window.dispatchEvent(new Event("test-mode.enabled"));
if (!t) {
toast.error("Couldn't enable test mode; no query variable.");
} else {
setTestModeEnabled(true);
const code = atob(t);
(async () => {
setTestModeStatus("Transpiling TypeScript...");
const startTime = Date.now();
const { error, data: transpiledCode } = await tryCatch(
(async () => transpileTypeScript(code))()
);
if (error) {
setTestModeStatus(
"Failed to transpile TypeScript! Error: " + error.message
);
setTestModeLoading(false);
return;
}
if (transpiledCode === null) {
setTestModeStatus("Cannot continue.");
setTestModeLoading(false);
return;
}
console.log(
"[MHSF Filters] Transpiled TypeScript:",
transpiledCode ?? ""
);
setTestModeStatus("Generating function...");
if (
!transpiledCode.includes("export default") &&
!transpiledCode.includes("export")
) {
setTestModeStatus(
"Transpiled code does not contain any export statements."
);
setTestModeLoading(false);
return;
}
const functionBody = transpiledCode
.replace(/export default(?!.*[;])/g, "") // Avoid replacing if followed by a semicolon
.replace(/export(?!.*[;])/g, ""); // Avoid replacing if followed by a semicolon
const { error: filterErr, data: filterFunc } = await tryCatch(
(async () =>
new Function(
"server",
`${functionBody}
return filter(server)`
))()
);
if (filterErr) {
setTestModeStatus(
`Failed to generate function! Error: ${filterErr.message}`
);
setTestModeLoading(false);
return;
}
if (typeof filterFunc === "function") {
setTestModeStatus(
"Compiled in " + (Date.now() - startTime) + "ms"
);
toast.promise(
async () => {
let newServers = [];
newServers = data.filter((c) => filterFunc(c));
setTestModeStatus(
"Server count " + data.length + " -> " + newServers.length
);
setFilteredData(() => [...newServers]);
setTestModeLoading(false);
},
{
loading: "Manipulating data...",
success: "Manipulated data; test mode finished!",
error: (e) =>
`Error while manipulating data; go back to your editor and run again. ${e}`,
}
);
} else {
setTestModeStatus(
"Code doesn't have a 'filter' function. Cannot be tested."
);
setTestModeLoading(false);
}
})();
}
});
}, [t, data]);
const testModeInit = (type: "filter" | "sort") => {
window.dispatchEvent(new Event("test-mode.enabled"));
if (!t) {
toast.error("Couldn't enable test mode; no query variable.");
} else {
setTestModeEnabled(true);
const code = atob(t);
(async () => {
setTestModeStatus("Transpiling TypeScript...");
const startTime = Date.now();
const { error, data: transpiledCode } = await tryCatch(
(async () => transpileTypeScript(code))(),
);
if (error) {
setTestModeStatus(
"Failed to transpile TypeScript! Error: " + error.message,
);
setTestModeLoading(false);
return;
}
if (transpiledCode === null) {
setTestModeStatus("Cannot continue.");
setTestModeLoading(false);
return;
}
setTestModeStatus("Generating function...");
if (
!transpiledCode.includes("export default") &&
!transpiledCode.includes("export")
) {
setTestModeStatus(
"Transpiled code does not contain any export statements.",
);
setTestModeLoading(false);
return;
}
const functionBody = transpiledCode
.replace(/export default(?!.*[;])/g, "") // Avoid replacing if followed by a semicolon
.replace(/export(?!.*[;])/g, ""); // Avoid replacing if followed by a semicolon
const { error: filterErr, data: filterFunc } = await tryCatch(
(async () =>
type === "filter"
? new Function(
"server",
`${functionBody}
return filter(server)`,
)
: new Function(
"serverA",
"serverB",
`${functionBody}
return sort(serverA, serverB)`,
))(),
);
if (filterErr) {
setTestModeStatus(
`Failed to generate function! Error: ${filterErr.message}`,
);
setTestModeLoading(false);
return;
}
if (typeof filterFunc === "function") {
setTestModeStatus("Compiled in " + (Date.now() - startTime) + "ms");
toast.promise(
async () => {
let newServers = [];
if (type === "filter") {
newServers = data.filter((c) => filterFunc(c));
setTestModeStatus(
"Server count " + data.length + " -> " + newServers.length,
);
if (newServers.length === 0)
setTestModeStatus(
"No servers were specified in the criteria; showing all servers instead",
);
setFilteredData(() => [...newServers]);
}
if (type === "sort") {
newServers = data.sort((a, b) => filterFunc(a, b));
setTestModeStatus("Sorted " + newServers.length + " servers.");
console.log(newServers, data.sort((a, b) => filterFunc(a, b)))
console.log(filterFunc)
setFilteredData(() => [...newServers]);
}
console.log(filteredData, testModeStatus);
setTestModeLoading(false);
window.dispatchEvent(new Event("test-mode.success"));
},
{
loading: "Manipulating data...",
success: "Manipulated data; test mode finished!",
error: (e) =>
`Error while manipulating data; go back to your editor and run again. ${e}`,
},
);
} else {
setTestModeStatus(
"Code doesn't have a 'filter' function. Cannot be tested.",
);
setTestModeLoading(false);
}
})();
}
};
return { filteredData, testModeEnabled, testModeLoading, testModeStatus };
useEffect(() => {
if (data.length !== 0) {
window.addEventListener("test-mode.enable.filter", () =>
testModeInit("filter"),
);
window.addEventListener("test-mode.enable.sort", () =>
testModeInit("sort"),
);
}
}, [t, data]);
return { filteredData, testModeEnabled, testModeLoading, testModeStatus };
}

@ -53,7 +53,6 @@ export function useIframeCommunication(bottomIframe?: RefObject<HTMLIFrameElemen
},
handle: (key: string, callback: (object: any) => void) => {
window.addEventListener('message', (e) => {
console.log(e);
if (e.data.__key === key) {
callback(e.data)
}

@ -35,6 +35,7 @@ import {
} from "@clerk/nextjs/server";
import { type NextRequest, NextResponse } from "next/server";
import type { ServerResponse } from "./lib/types/mh-server";
import { getBackendProcedure } from "./lib/backend-procedure";
// Thanks for the router matcher API Clerk <3
const isRootRoute = createRouteMatcher(["/"]);
@ -57,22 +58,6 @@ export default process.env.NEXT_PUBLIC_IS_AUTH === "true"
return NextResponse.redirect(new URL("/home", req.url));
}
}
// If user is banned, disable all API routes
if (authRes.userId !== null) {
// User exists
const user = await client.users.getUser(authRes.userId);
const userBannedMetadata = user.publicMetadata.banned;
if (userBannedMetadata !== undefined) {
// User is banned
if (apiRoute(req)) {
return NextResponse.json({
banned:
"You were banned. (and I'm not telling you why) Why are you trying to use the API. Huh? Tell me. Now. You're not funny.",
});
}
}
}
if (isOldServerRoute(req)) {
const minehut = await fetch(
`https://api.minehut.com/server/${req.url.split("/server/")[1].split("/")[0]}?byName=true`,

@ -28,328 +28,337 @@
* OTHER DEALINGS IN THE SOFTWARE.
*/
import { getBackendProcedure } from "@/lib/backend-procedure";
import type { MHSFData } from "@/lib/types/data";
import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
export type RouteParams = {
actions: {
favorite: string;
customize: string;
own: string;
report: string;
history: {
dailyData: string;
monthlyData: string;
relativeData: string;
historicalData: string;
};
};
actions: {
favorite: string;
customize: string;
own: string;
report: string;
history: {
dailyData: string;
monthlyData: string;
relativeData: string;
historicalData: string;
};
};
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<{ server: (MHSFData & RouteParams) | null }>
req: NextApiRequest,
res: NextApiResponse<
{ server: (MHSFData & RouteParams) | null } | { error: string }
>,
) {
const {
server,
maxFavoriteEntries,
favoriteTimespanStart,
favoriteTimespanEnd,
maxPlayerEntries,
playerTimespanStart,
playerTimespanEnd,
maxAchievementEntries,
achievementTimespanStart,
achievementTimespanEnd,
} = req.query;
if (!server) return res.status(400).send({ server: null });
const backendProcedure = await getBackendProcedure(req);
const serverData = await findServerData(server as string);
if (!serverData.exists) return res.status(404).send({ server: null });
if (backendProcedure.status !== "OK")
return res.status(403).json({
error: `Backend procedure marked request as '${backendProcedure.status}' instead of required 'OK'`,
});
const {
server,
maxFavoriteEntries,
favoriteTimespanStart,
favoriteTimespanEnd,
maxPlayerEntries,
playerTimespanStart,
playerTimespanEnd,
maxAchievementEntries,
achievementTimespanStart,
achievementTimespanEnd,
} = req.query;
if (!server) return res.status(400).send({ server: null });
const mongo = new MongoClient(process.env.MONGO_DB as string);
const serverData = await findServerData(server as string);
if (!serverData.exists) return res.status(404).send({ server: null });
try {
await mongo.connect();
const db = mongo.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const userId = req.cookies.userId;
const mongo = new MongoClient(process.env.MONGO_DB as string);
// Run queries in parallel
const [favoriteData, customizationData, playerData, achievements] =
await Promise.all([
findFavoriteData(serverData.name, userId, db, {
maxFavoriteEntries,
favoriteTimespanStart,
favoriteTimespanEnd,
}),
findCustomizationData(serverData.name, userId, db),
findPlayerData(serverData.name, db, {
maxPlayerEntries,
playerTimespanStart,
playerTimespanEnd,
}),
findAchievements(serverData.name, db, {
maxAchievementEntries,
achievementTimespanStart,
achievementTimespanEnd,
}),
]);
try {
await mongo.connect();
const db = mongo.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const userId = req.cookies.userId;
res.send({
server: {
favoriteData,
customizationData,
playerData,
achievements,
actions: {
history: {
dailyData: `/api/v1/server/get/${server}/history/daily-data`,
monthlyData: `/api/v1/server/get/${server}/history/monthly-data`,
relativeData: `/api/v1/server/get/${server}/history/relative-data`,
historicalData: `/api/v1/server/get/${server}/history/historical-data`,
},
favorite: `/api/v1/server/get/${server}/favorite-server`,
customize: `/api/v1/server/get/${server}/customize`,
own: `/api/v1/server/get/${server}/own-server`,
report: `/api/v1/server/get/${server}/report-server`,
},
},
});
} catch (error) {
console.error("Error processing request:", error);
res.status(500).send({ server: null });
} finally {
await mongo.close();
}
// Run queries in parallel
const [favoriteData, customizationData, playerData, achievements] =
await Promise.all([
findFavoriteData(serverData.name, userId, db, {
maxFavoriteEntries,
favoriteTimespanStart,
favoriteTimespanEnd,
}),
findCustomizationData(serverData.name, userId, db),
findPlayerData(serverData.name, db, {
maxPlayerEntries,
playerTimespanStart,
playerTimespanEnd,
}),
findAchievements(serverData.name, db, {
maxAchievementEntries,
achievementTimespanStart,
achievementTimespanEnd,
}),
]);
res.send({
server: {
favoriteData,
customizationData,
playerData,
achievements,
actions: {
history: {
dailyData: `/api/v1/server/get/${server}/history/daily-data`,
monthlyData: `/api/v1/server/get/${server}/history/monthly-data`,
relativeData: `/api/v1/server/get/${server}/history/relative-data`,
historicalData: `/api/v1/server/get/${server}/history/historical-data`,
},
favorite: `/api/v1/server/get/${server}/favorite-server`,
customize: `/api/v1/server/get/${server}/customize`,
own: `/api/v1/server/get/${server}/own-server`,
report: `/api/v1/server/get/${server}/report-server`,
},
},
});
} catch (error) {
console.error("Error processing request:", error);
res.status(500).send({ server: null });
} finally {
await mongo.close();
}
}
async function findCustomizationData(
serverName: string,
userId: string | undefined,
db: any
serverName: string,
userId: string | undefined,
db: any,
): Promise<{
description: string | undefined;
banner: string | undefined;
discord: string | undefined;
colorScheme: string | undefined;
userProfilePicture: string | undefined;
isOwned: boolean;
isOwnedByUser: boolean;
description: string | undefined;
banner: string | undefined;
discord: string | undefined;
colorScheme: string | undefined;
userProfilePicture: string | undefined;
isOwned: boolean;
isOwnedByUser: boolean;
}> {
// Run queries in parallel
const [customizationData, ownedServerData] = await Promise.all([
db.collection("customization").findOne({ server: serverName }),
userId
? db.collection("owned-servers").findOne({ server: serverName })
: null,
]);
// Run queries in parallel
const [customizationData, ownedServerData] = await Promise.all([
db.collection("customization").findOne({ server: serverName }),
userId
? db.collection("owned-servers").findOne({ server: serverName })
: null,
]);
if (customizationData) {
return {
...(customizationData as any),
isOwned: true,
isOwnedByUser: ownedServerData?.author === userId,
};
}
if (customizationData) {
return {
...(customizationData as any),
isOwned: true,
isOwnedByUser: ownedServerData?.author === userId,
};
}
return {
isOwned: false,
isOwnedByUser: false,
description: undefined,
banner: undefined,
discord: undefined,
colorScheme: undefined,
userProfilePicture: undefined,
};
return {
isOwned: false,
isOwnedByUser: false,
description: undefined,
banner: undefined,
discord: undefined,
colorScheme: undefined,
userProfilePicture: undefined,
};
}
async function findFavoriteData(
serverName: string,
userId: string | undefined,
db: any,
query: {
maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[];
favoriteTimespanEnd?: string | string[];
}
serverName: string,
userId: string | undefined,
db: any,
query: {
maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[];
favoriteTimespanEnd?: string | string[];
},
) {
// Run queries in parallel
const [userFavorites, metaData, historyData] = await Promise.all([
userId ? db.collection("favorites").findOne({ user: userId }) : null,
db.collection("meta").findOne({ server: serverName }),
fetchHistoryData(db, serverName, query),
]);
// Run queries in parallel
const [userFavorites, metaData, historyData] = await Promise.all([
userId ? db.collection("favorites").findOne({ user: userId }) : null,
db.collection("meta").findOne({ server: serverName }),
fetchHistoryData(db, serverName, query),
]);
// Process user favorites
const favoritedByAccount =
userId && userFavorites
? userFavorites.favorites.includes(serverName)
: null;
// Process user favorites
const favoritedByAccount =
userId && userFavorites
? userFavorites.favorites.includes(serverName)
: null;
// Process favorite count
const favoriteNumber = metaData?.favorites || 0;
// Process favorite count
const favoriteNumber = metaData?.favorites || 0;
return {
favoritedByAccount,
favoriteNumber,
favoriteHistoricalData: historyData,
};
return {
favoritedByAccount,
favoriteNumber,
favoriteHistoricalData: historyData,
};
}
async function fetchHistoryData(
db: any,
serverName: string,
query: {
maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[];
favoriteTimespanEnd?: string | string[];
}
db: any,
serverName: string,
query: {
maxFavoriteEntries?: string | string[];
favoriteTimespanStart?: string | string[];
favoriteTimespanEnd?: string | string[];
},
) {
// Build query filter
const filter: any = { server: serverName };
// Build query filter
const filter: any = { server: serverName };
// Add date range filter if provided
if (query.favoriteTimespanStart && query.favoriteTimespanEnd) {
filter.date = {
$gte: new Date(Number(query.favoriteTimespanStart)),
$lte: new Date(Number(query.favoriteTimespanEnd)),
};
}
// Add date range filter if provided
if (query.favoriteTimespanStart && query.favoriteTimespanEnd) {
filter.date = {
$gte: new Date(Number(query.favoriteTimespanStart)),
$lte: new Date(Number(query.favoriteTimespanEnd)),
};
}
// Determine limit
const limit = query.maxFavoriteEntries ? Number(query.maxFavoriteEntries) : 0;
// Determine limit
const limit = query.maxFavoriteEntries ? Number(query.maxFavoriteEntries) : 0;
// Use projection to only fetch needed fields
const projection = { favorites: 1, date: 1, _id: 0 };
// Use projection to only fetch needed fields
const projection = { favorites: 1, date: 1, _id: 0 };
// Execute optimized query
const cursor = db.collection("history").find(filter).project(projection);
// Execute optimized query
const cursor = db.collection("history").find(filter).project(projection);
// Apply limit if specified
if (limit > 0) {
cursor.limit(limit);
}
// Apply limit if specified
if (limit > 0) {
cursor.limit(limit);
}
return await cursor.toArray();
return await cursor.toArray();
}
async function findServerData(
server: string
server: string,
): Promise<{ exists: boolean; name: string }> {
try {
const response = await fetch("https://api.minehut.com/server/" + server);
try {
const response = await fetch("https://api.minehut.com/server/" + server);
// Check if the response is ok before parsing JSON
if (!response.ok) {
return { exists: false, name: "" };
}
// Check if the response is ok before parsing JSON
if (!response.ok) {
return { exists: false, name: "" };
}
const serverJSON = await response.json();
if (!serverJSON.server) return { exists: false, name: "" };
const serverJSON = await response.json();
if (!serverJSON.server) return { exists: false, name: "" };
return { exists: true, name: serverJSON.server.name };
} catch (error) {
console.error("Error fetching server data:", error);
return { exists: false, name: "" };
}
return { exists: true, name: serverJSON.server.name };
} catch (error) {
console.error("Error fetching server data:", error);
return { exists: false, name: "" };
}
}
async function findPlayerData(
serverName: string,
db: any,
query: {
maxPlayerEntries?: string | string[];
playerTimespanStart?: string | string[];
playerTimespanEnd?: string | string[];
}
serverName: string,
db: any,
query: {
maxPlayerEntries?: string | string[];
playerTimespanStart?: string | string[];
playerTimespanEnd?: string | string[];
},
) {
// Get historical player data
const historyCollection = db.collection("history");
// Get historical player data
const historyCollection = db.collection("history");
// Build query filter
const filter: any = { server: serverName };
// Build query filter
const filter: any = { server: serverName };
// Add date range filter if provided
if (query.playerTimespanStart && query.playerTimespanEnd) {
filter.date = {
$gte: new Date(Number(query.playerTimespanStart)),
$lte: new Date(Number(query.playerTimespanEnd)),
};
}
// Add date range filter if provided
if (query.playerTimespanStart && query.playerTimespanEnd) {
filter.date = {
$gte: new Date(Number(query.playerTimespanStart)),
$lte: new Date(Number(query.playerTimespanEnd)),
};
}
// Use projection to only fetch needed fields
const projection = { player_count: 1, date: 1, _id: 0 };
// Use projection to only fetch needed fields
const projection = { player_count: 1, date: 1, _id: 0 };
// Get max player count in a single query
const [maxResult, playerHistory] = await Promise.all([
historyCollection
.find({ server: serverName })
.sort({ player_count: -1 })
.limit(1)
.project({ player_count: 1 })
.toArray(),
// Get max player count in a single query
const [maxResult, playerHistory] = await Promise.all([
historyCollection
.find({ server: serverName })
.sort({ player_count: -1 })
.limit(1)
.project({ player_count: 1 })
.toArray(),
historyCollection.find(filter).project(projection).toArray(),
]);
historyCollection.find(filter).project(projection).toArray(),
]);
// Apply limit if specified
let historically = playerHistory;
if (query.maxPlayerEntries) {
historically = historically.slice(0, Number(query.maxPlayerEntries));
}
// Apply limit if specified
let historically = playerHistory;
if (query.maxPlayerEntries) {
historically = historically.slice(0, Number(query.maxPlayerEntries));
}
// Format the data to match the expected structure
const formattedHistory = historically.map(
(item: { date: string; player_count?: number }) => ({
date: item.date,
playerCount: item.player_count || 0,
})
);
// Format the data to match the expected structure
const formattedHistory = historically.map(
(item: { date: string; player_count?: number }) => ({
date: item.date,
playerCount: item.player_count || 0,
}),
);
const max = maxResult.length > 0 ? maxResult[0].player_count : 0;
const max = maxResult.length > 0 ? maxResult[0].player_count : 0;
return { historically: formattedHistory, max };
return { historically: formattedHistory, max };
}
async function findAchievements(
serverName: string,
db: any,
query: {
maxAchievementEntries?: string | string[];
achievementTimespanStart?: string | string[];
achievementTimespanEnd?: string | string[];
}
serverName: string,
db: any,
query: {
maxAchievementEntries?: string | string[];
achievementTimespanStart?: string | string[];
achievementTimespanEnd?: string | string[];
},
) {
// Get achievements data
const achievementsCollection = db.collection("achievements");
// Get achievements data
const achievementsCollection = db.collection("achievements");
// Build query filter
const filter: any = { name: serverName };
// Build query filter
const filter: any = { name: serverName };
// Add date range filter if provided
if (query.achievementTimespanStart && query.achievementTimespanEnd) {
// Assuming there's a timestamp or date field in the achievements collection
// If it's stored in _id, we might need a different approach
filter.timestamp = {
$gte: new Date(Number(query.achievementTimespanStart)),
$lte: new Date(Number(query.achievementTimespanEnd)),
};
}
// Add date range filter if provided
if (query.achievementTimespanStart && query.achievementTimespanEnd) {
// Assuming there's a timestamp or date field in the achievements collection
// If it's stored in _id, we might need a different approach
filter.timestamp = {
$gte: new Date(Number(query.achievementTimespanStart)),
$lte: new Date(Number(query.achievementTimespanEnd)),
};
}
// Get historical achievements
let historically = await achievementsCollection.find(filter).toArray();
// Get historical achievements
let historically = await achievementsCollection.find(filter).toArray();
// Apply limit if specified
if (query.maxAchievementEntries) {
historically = historically.slice(0, Number(query.maxAchievementEntries));
}
// Apply limit if specified
if (query.maxAchievementEntries) {
historically = historically.slice(0, Number(query.maxAchievementEntries));
}
const currently: any[] = [];
for (const a of historically)
a.achievements.forEach((item: any, interval: number) =>
currently.push({ interval, ...item })
);
const currently: any[] = [];
for (const a of historically)
a.achievements.forEach((item: any, interval: number) =>
currently.push({ interval, ...item }),
);
return { historically, currently };
return { historically, currently };
}

@ -33,11 +33,18 @@ import { clerkClient, getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import { OnlineServer } from "@/lib/types/mh-server";
import { waitUntil } from "@vercel/functions";
import { getBackendProcedure } from "@/lib/backend-procedure";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const backendProcedure = await getBackendProcedure(req);
if (backendProcedure.status !== "OK")
return res.status(403).json({
error: `Backend procedure marked request as '${backendProcedure.status}' instead of required 'OK'`,
});
const { userId } = getAuth(req);
const { server } = req.query;

@ -3223,11 +3223,43 @@
lodash.merge "^4.6.2"
postcss-selector-parser "6.0.10"
"@tanstack/query-core@5.69.0":
version "5.69.0"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.69.0.tgz#c434505987ade936dc53e6e27aa1406b0295516f"
integrity sha512-Kn410jq6vs1P8Nm+ZsRj9H+U3C0kjuEkYLxbiCyn3MDEiYor1j2DGVULqAz62SLZtUZ/e9Xt6xMXiJ3NJ65WyQ==
"@tanstack/react-query@^5.69.0":
version "5.69.0"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.69.0.tgz#8d58e800854cc11d0aa2c39569f53ae32ba442a9"
integrity sha512-Ift3IUNQqTcaFa1AiIQ7WCb/PPy8aexZdq9pZWLXhfLcLxH0+PZqJ2xFImxCpdDZrFRZhLJrh76geevS5xjRhA==
dependencies:
"@tanstack/query-core" "5.69.0"
"@tootallnate/quickjs-emscripten@^0.23.0":
version "0.23.0"
resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c"
integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==
"@trpc/client@^11.0.0":
version "11.0.0"
resolved "https://registry.yarnpkg.com/@trpc/client/-/client-11.0.0.tgz#3020392edf87abc046594cee0acd379f2c6289d9"
integrity sha512-U2THlxsdr4ykAX5lpTU8k5WRADPQ+68Ex2gfUht3MlCxGK7njBmNSSzjpQSWNt7tMI/xsYrddFiRlmEPrh+Cbg==
"@trpc/next@^11.0.0":
version "11.0.0"
resolved "https://registry.yarnpkg.com/@trpc/next/-/next-11.0.0.tgz#d83e1fac595629c3bc02ba5bbe2595508b7c55ee"
integrity sha512-HpowgsF0jfXG30jEBVK8v90ltbEZiQZq/x0rsjScfZuedkAfapqZvrsrkzv6Pkemz7sxaxJcZB3HEqXxWfkGoA==
"@trpc/react-query@^11.0.0":
version "11.0.0"
resolved "https://registry.yarnpkg.com/@trpc/react-query/-/react-query-11.0.0.tgz#6fb849baf715fb33d2eb6a417d7e75fc00307ae6"
integrity sha512-HeE9bBLA6nqC2xk5wlNZIPQ5vmyli3tgNNab8fTE489+ksNMKxaIx66pZKsMJIorDcP1wS0rWNV+GroU0iR98g==
"@trpc/server@^11.0.0":
version "11.0.0"
resolved "https://registry.yarnpkg.com/@trpc/server/-/server-11.0.0.tgz#5a49758fa3c052a83314c328155d68027164f077"
integrity sha512-xY9q/b/wR/tWGYTm5xmRjivkYD2EZZXmOKmHuNJRYZuLbieeNUsdfQRjJC409WB1pjKWInomhHwuA8bahZJ4lQ==
"@types/acorn@^4.0.0":
version "4.0.6"
resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-4.0.6.tgz#d61ca5480300ac41a7d973dd5b84d0a591154a22"
@ -3491,7 +3523,7 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
"@types/react-dom@^19", "@types/react-dom@^19.0.3":
"@types/react-dom@19.0.4", "@types/react-dom@^19", "@types/react-dom@^19.0.3":
version "19.0.4"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.4.tgz#bedba97f9346bd4c0fe5d39e689713804ec9ac89"
integrity sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==
@ -3503,13 +3535,20 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^19", "@types/react@^19.0.8":
"@types/react@*", "@types/react@19.0.10", "@types/react@^19", "@types/react@^19.0.8":
version "19.0.10"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.10.tgz#d0c66dafd862474190fe95ce11a68de69ed2b0eb"
integrity sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==
dependencies:
csstype "^3.0.2"
"@types/request-ip@^0.0.41":
version "0.0.41"
resolved "https://registry.yarnpkg.com/@types/request-ip/-/request-ip-0.0.41.tgz#c22a3244df2573402989346062851b06b7a5ac4e"
integrity sha512-Qzz0PM2nSZej4lsLzzNfADIORZhhxO7PED0fXpg4FjXiHuJ/lMyUg+YFF5q8x9HPZH3Gl6N+NOM8QZjItNgGKg==
dependencies:
"@types/node" "*"
"@types/resolve@^1.17.1":
version "1.20.6"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.6.tgz#e6e60dad29c2c8c206c026e6dd8d6d1bdda850b8"
@ -4764,6 +4803,13 @@ cookie@~0.7.2:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
copy-anything@^3.0.2:
version "3.0.5"
resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0"
integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==
dependencies:
is-what "^4.1.8"
core-util-is@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
@ -7637,6 +7683,11 @@ is-weakset@^2.0.3:
call-bound "^1.0.3"
get-intrinsic "^1.2.6"
is-what@^4.1.8:
version "4.1.16"
resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f"
integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==
is-wsl@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
@ -10877,6 +10928,11 @@ repeat-string@^1.6.1:
resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==
request-ip@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/request-ip/-/request-ip-3.3.0.tgz#863451e8fec03847d44f223e30a5d63e369fa611"
integrity sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA==
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@ -11677,6 +11733,13 @@ sucrase@^3.32.0, sucrase@^3.35.0:
pirates "^4.0.1"
ts-interface-checker "^0.1.9"
superjson@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/superjson/-/superjson-2.2.2.tgz#9d52bf0bf6b5751a3c3472f1292e714782ba3173"
integrity sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==
dependencies:
copy-anything "^3.0.2"
supports-color@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
@ -12042,7 +12105,7 @@ turbo-windows-arm64@2.4.4:
resolved "https://registry.yarnpkg.com/turbo-windows-arm64/-/turbo-windows-arm64-2.4.4.tgz#e00c26e3d7fd9a82af90018ad3137f14e5221630"
integrity sha512-403sqp9t5sx6YGEC32IfZTVWkRAixOQomGYB8kEc6ZD+//LirSxzeCHCnM8EmSXw7l57U1G+Fb0kxgTcKPU/Lg==
turbo@^2.4.0, turbo@^2.4.2:
turbo@^2.4.0, turbo@^2.4.4:
version "2.4.4"
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.4.4.tgz#cec5dbac5850adebdba71fbdf90e6e9a7723c3d6"
integrity sha512-N9FDOVaY3yz0YCOhYIgOGYad7+m2ptvinXygw27WPLQvcZDl3+0Sa77KGVlLSiuPDChOUEnTKE9VJwLSi9BPGQ==
@ -12867,7 +12930,7 @@ zod@3.23.8:
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
zod@^3.20.6, zod@^3.21.4, zod@^3.23.8, zod@^3.24.1:
zod@^3.20.6, zod@^3.21.4, zod@^3.24.1, zod@^3.24.2:
version "3.24.2"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3"
integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==