feat: enforcing color modes

This commit is contained in:
dvelo 2025-05-16 19:08:19 -05:00
parent 20afc2de8e
commit 2a58192a14
20 changed files with 3075 additions and 2062 deletions

@ -45,7 +45,12 @@ const nextConfig = {
{ {
protocol: "https", protocol: "https",
hostname: "cdn.discordapp.com" hostname: "cdn.discordapp.com"
} },
{
protocol: "https",
hostname: "exh89c9lva.ufs.sh",
pathname: "/f/*",
},
], ],
}, },
async redirects() { async redirects() {

@ -43,6 +43,7 @@
"@radix-ui/react-switch": "1.1.0", "@radix-ui/react-switch": "1.1.0",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@shikijs/rehype": "^3.4.0",
"@tanstack/react-query": "^5.69.0", "@tanstack/react-query": "^5.69.0",
"@trpc/client": "^11.0.0", "@trpc/client": "^11.0.0",
"@trpc/next": "^11.0.0", "@trpc/next": "^11.0.0",
@ -93,10 +94,15 @@
"react-fade-in": "^2.0.1", "react-fade-in": "^2.0.1",
"react-fast-marquee": "^1.6.5", "react-fast-marquee": "^1.6.5",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-markdown": "^10.1.0",
"react-qr-code": "^2.0.15", "react-qr-code": "^2.0.15",
"react-shiki": "^0.6.0",
"react-snowfall": "^2.2.0", "react-snowfall": "^2.2.0",
"recharts": "^2.15.1", "recharts": "^2.15.1",
"rehype-pretty-code": "^0.14.1",
"rehype-react": "^8.0.0",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"request-ip": "^3.3.0", "request-ip": "^3.3.0",
"sonner": "^1.7.0", "sonner": "^1.7.0",
@ -155,7 +161,6 @@
"react-hook-form": "^7.52.2", "react-hook-form": "^7.52.2",
"react-hotkeys-hook": "^4.5.0", "react-hotkeys-hook": "^4.5.0",
"react-infinite-scroll-component": "^6.1.0", "react-infinite-scroll-component": "^6.1.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.0.23", "react-resizable-panels": "^2.0.23",
"recharts": "^2.15.1", "recharts": "^2.15.1",
"shiki": "^1.23.0", "shiki": "^1.23.0",

@ -37,15 +37,26 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Material } from "@/components/ui/material"; import { Material } from "@/components/ui/material";
import { useState } from "react"; import { useState } from "react";
import { useMHSFServer } from "@/lib/hooks/use-mhsf-server"; import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import type { ReactNode } from "react";
import ShikiHighlighter, {
type Element,
isInlineCode,
rehypeInlineCodeProperty,
} from "react-shiki";
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(mhsfData.server?.customizationData.description !== undefined ? "description" : "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]">
@ -72,7 +83,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")}
> >
@ -110,9 +121,37 @@ export function MOTDRow({
)} )}
{tab === "description" && ( {tab === "description" && (
<div className="prose mt-2 break-words overflow-y-auto max-h-[175px] min-w-full dark:prose-invert"> <div className="prose mt-2 break-words overflow-y-auto max-h-[175px] min-w-full dark:prose-invert">
<Markdown className="min-w-full">{mhsfData.server?.customizationData.description}</Markdown> <Markdown
components={{
code: CodeHighlight,
}}
>
{mhsfData.server?.customizationData.description}
</Markdown>
</div> </div>
)} )}
</Material> </Material>
); );
} }
interface CodeHighlightProps {
className?: string | undefined;
children?: ReactNode | undefined;
node?: Element | undefined;
}
export const CodeHighlight = ({
className,
children,
node,
...props
}: CodeHighlightProps): ReactNode => {
const match = className?.match(/language-(\w+)/);
const language = match ? match[1] : undefined;
return (
<ShikiHighlighter language={language} theme="poimandres" {...props}>
{String(children)}
</ShikiHighlighter>
);
};

@ -0,0 +1,74 @@
/*
* 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 { Badge } from "@/components/ui/badge";
import { Material } from "@/components/ui/material";
import type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import type { ServerResponse } from "@/lib/types/mh-server";
import type { BannerUploaderRouter } from "@/pages/api/v1/server/get/[server]/settings/upload-banner";
import { generateUploadDropzone } from "@uploadthing/react";
import { toast } from "sonner";
export function ServerBannerBox({
serverData,
minehutData
}: {
serverData: ReturnType<typeof useMHSFServer>;
minehutData: ServerResponse;
}) {
const UploadDropzone = generateUploadDropzone<BannerUploaderRouter>({
url: `/api/v1/server/get/${minehutData._id}/settings/upload-banner`,
});
return (
<Material className="grid gap-1 mt-2 max-h-[700px]">
<strong className="flex items-center gap-2">Server Banner </strong>
<p className="mb-3">
Pick out whatever represents your server best! Images have a limit of
4.5MB, and the prefered aspect ratio for the banner should be 19:11 to
look the best on MHSF. Powered by UploadThing.
</p>
<UploadDropzone
endpoint="imageUploader"
className="uploadthing-dropzone"
onClientUploadComplete={(res) => {
console.log("Upload complete response:", res);
// Refresh the server data
serverData.refresh();
toast.success("Banner uploaded successfully!");
}}
onUploadError={(error: Error) => {
console.error("Upload error:", error);
toast.error(`Upload failed: ${error.message}`);
}}
/>
</Material>
);
}

@ -0,0 +1,94 @@
import { Input } from "@/components/ui/input";
import { Material } from "@/components/ui/material";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import { ServerResponse } from "@/lib/types/mh-server";
import { debounce } from "lodash";
import { Moon, Sun } from "lucide-react";
import { useEffect, useState } from "react";
export function ServerColorModeBox({
serverData,
minehutData,
}: {
serverData: ReturnType<typeof useMHSFServer>;
minehutData: ServerResponse;
}) {
const [colorModeEnabled, setColorModeEnabled] = useState(false);
const [colorMode, setColorMode] = useState("light");
useEffect(() => {
setColorModeEnabled(serverData.server?.customizationData.colorMode !== undefined);
setColorMode(serverData.server?.customizationData.colorMode ?? "light");
}, [serverData])
useEffect(() => {
if (colorMode === "idc") {
setColorModeEnabled(false);
setColorMode("light");
}
if (colorMode === "dark")
window.dispatchEvent(new Event("force-dark-mode"));
if (colorMode === "light")
window.dispatchEvent(new Event("force-light-mode"));
}, [colorMode]);
useEffect(() => {
update();
}, [colorMode, colorModeEnabled]);
const update = debounce(async () => {
await fetch(
`/api/v1/server/get/${minehutData._id}/settings/change-color-mode${colorModeEnabled !== false ? `?colorMode=${colorMode}` : ""}`
);
}, 500);
return (
<Material className="flex justify-between items-center p-2 mt-2">
<div className="flex items-center font-bold gap-4">
<Switch
checked={colorModeEnabled}
onCheckedChange={setColorModeEnabled}
/>{" "}
Enforce color mode
</div>
<Select
disabled={!colorModeEnabled}
onValueChange={setColorMode}
value={colorMode}
>
<SelectTrigger className="w-[180px] disabled:hidden">
<SelectValue placeholder="Select mode" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="dark">
<div className="flex items-center gap-2">
<Moon size={16} />
Dark
</div>
</SelectItem>
<SelectItem value="light">
<div className="flex items-center gap-2">
<Sun size={16} />
Light
</div>
</SelectItem>
<SelectItem value="idc">I couldn't care less</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Material>
);
}

@ -0,0 +1,78 @@
/*
* 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 type { useMHSFServer } from "@/lib/hooks/use-mhsf-server";
import type { ServerResponse } from "@/lib/types/mh-server";
import { ServerEditorDescription } from "../server-editor-description";
import { Badge } from "@/components/ui/badge";
import { useState } from "react";
import { debounce } from "lodash";
export function ServerDescriptionBox({
serverData,
minehutData,
}: {
serverData: ReturnType<typeof useMHSFServer>;
minehutData: ServerResponse;
}) {
const [descriptionSaved, setDescriptionSaved] = useState(true);
const save = debounce(async (content) => {
await fetch(`/api/v1/server/get/${minehutData._id}/settings/change-description?description=${encodeURIComponent(btoa(content))}`);
setDescriptionSaved(true);
}, 250);
const reload = debounce(async () => {
await serverData.refresh()
}, 1000)
return (
<Material className="grid gap-1 max-h-[700px]">
<strong className="flex items-center gap-2">Server Description <Badge className="ml-2">{descriptionSaved ? "Saved" : "Not Saved"}</Badge></strong>
<p className="mb-3">
A markdown enabled, fancy description for your server! Describe what
players will expect from your server and why they should join; don't
worry, you have more space than MOTD's.
</p>
{!serverData.loading && (
<ServerEditorDescription
defaultMarkdown={
serverData.server?.customizationData.description ??
`# ${minehutData.name}`
}
onUpdate={async (content) => {
setDescriptionSaved(false);
save(content);
reload();
}}
/>
)}
</Material>
);
}

@ -0,0 +1,69 @@
/*
* 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 { DrawerTitle } from "@/components/ui/drawer";
import { Material } from "@/components/ui/material";
import { Spinner } from "@/components/ui/spinner";
import { useEffect } from "react";
import { toast } from "sonner";
export function ServerMigrationBox({
oldVersion,
reupdate,id
}: { oldVersion: number | undefined, reupdate: () => void, id: string }) {
// biome-ignore lint/correctness/useExhaustiveDependencies:
useEffect(() => {
(async () => {
const response = await fetch(`/api/v1/server/get/${id}/settings/migrate`);
if (response.ok) {
reupdate();
} else {
const json = await response.json()
toast.error(json.error)
}
})()
}, [])
return (
<div>
<DrawerTitle>Migrate Server</DrawerTitle>
<Material className="grid gap-1 max-h-[700px]">
<p className="mb-3">
This server must be migrated from version {oldVersion ?? 1} of MHSF.
This is an automatic process.
</p>
<div className="w-full justify-center flex items-center">
<Spinner />
</div>
</Material>
</div>
);
}

@ -20,11 +20,15 @@ import type { OnlineServer, ServerResponse } from "@/lib/types/mh-server";
import { useServers } from "@/lib/hooks/use-servers"; import { useServers } from "@/lib/hooks/use-servers";
import { Alert } from "@/components/ui/alert"; import { Alert } from "@/components/ui/alert";
import { toast } from "sonner"; import { toast } from "sonner";
import { BannerUploaderRouter } from "@/pages/api/v1/server/get/[server]/settings/upload-banner"; import type { BannerUploaderRouter } from "@/pages/api/v1/server/get/[server]/settings/upload-banner";
import { import {
generateUploadButton, generateUploadButton,
generateUploadDropzone, generateUploadDropzone,
} from "@uploadthing/react"; } from "@uploadthing/react";
import { ServerDescriptionBox } from "./customizations/server-description-box";
import { ServerBannerBox } from "./customizations/server-banner-box";
import { ServerMigrationBox } from "./customizations/server-migration-box";
import { ServerColorModeBox } from "./customizations/server-color-mode-box";
const successClasses = const successClasses =
"bg-green-200 border-green-400 dark:bg-green-800 dark:border-green-600"; "bg-green-200 border-green-400 dark:bg-green-800 dark:border-green-600";
@ -228,49 +232,32 @@ export function ServerEditorProvider({
<> <>
{serverData.server?.customizationData.isOwnedByUser ? ( {serverData.server?.customizationData.isOwnedByUser ? (
<div className="!max-h-[700px] !h-[700px] overflow-y-scroll"> <div className="!max-h-[700px] !h-[700px] overflow-y-scroll">
<DrawerTitle className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl mb-3"> {serverData.server?.customizationData
Server Settings .customizationVersion === 2 ? (
</DrawerTitle> <div>
<Material className="grid gap-1 max-h-[700px]"> <DrawerTitle className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl mb-3">
<strong>Server Description</strong> Server Settings
<p className="mb-3"> </DrawerTitle>
A markdown enabled, fancy description for your server! <ServerDescriptionBox
Describe what players will expect from your server and serverData={serverData}
why they should join; don't worry, you have more space minehutData={minehutData}
than MOTD's.
</p>
{!serverData.loading && (
<ServerEditorDescription
defaultMarkdown={
serverData.server?.customizationData.description ??
`# ${minehutData.name}`
}
onUpdate={(content) => console.log(content)}
/> />
)} <ServerBannerBox
</Material> serverData={serverData}
<Material className="grid gap-1 mt-2 max-h-[700px]"> minehutData={minehutData}
<strong>Server Banner</strong> />
<p className="mb-3"> <ServerColorModeBox
Pick out whatever represents your server best! Images serverData={serverData}
have a limit of 4.5MB, and the prefered aspect ratio for minehutData={minehutData}
the banner should be 19:11 to look the best on MHSF. />
</p> </div>
<UploadDropzone ) : (
endpoint="imageUploader" <ServerMigrationBox
className="uploadthing-dropzone" oldVersion={1}
onClientUploadComplete={(res) => { reupdate={() => serverData.refresh()}
console.log("Upload complete response:", res); id={minehutData._id}
// Refresh the server data
serverData.refresh();
toast.success("Banner uploaded successfully!");
}}
onUploadError={(error: Error) => {
console.error("Upload error:", error);
toast.error(`Upload failed: ${error.message}`);
}}
/> />
</Material> )}
</div> </div>
) : ( ) : (
<Placeholder <Placeholder

@ -17,8 +17,12 @@ export function ServerMainPage({
mhsfData: ReturnType<typeof useMHSFServer>; mhsfData: ReturnType<typeof useMHSFServer>;
}) { }) {
useEffect(() => { useEffect(() => {
if (mhsfData.server?.customizationData.banner !== undefined) if (mhsfData.server?.customizationData.colorMode !== undefined) {
window.dispatchEvent(new Event("force-dark-mode")); if (mhsfData.server?.customizationData.colorMode === "dark")
window.dispatchEvent(new Event("force-dark-mode"));
if (mhsfData.server?.customizationData.colorMode === "light")
window.dispatchEvent(new Event("force-light-mode"));
}
}); });
return ( return (

@ -38,24 +38,27 @@ import { usePathname } from "next/navigation";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const [mounted, setMounted] = React.useState(false); const [mounted, setMounted] = React.useState(false);
const pathname = usePathname(); const pathname = usePathname();
const [forcedDark, setForcedDark] = React.useState(false); const [forcedTheme, setForcedTheme] = React.useState<'dark' | 'light' | undefined>();
React.useEffect(() => { React.useEffect(() => {
setMounted(true); setMounted(true);
window.addEventListener("force-dark-mode", () => { window.addEventListener("force-dark-mode", () => {
setForcedDark(true); setForcedTheme('dark');
});
window.addEventListener("force-light-mode", () => {
setForcedTheme('light');
}); });
}); });
React.useEffect(() => { React.useEffect(() => {
setForcedDark(false); setForcedTheme(undefined);
}, [pathname]); }, [pathname]);
if (!mounted) return null; if (!mounted) return null;
return ( return (
<NextThemeProvider forcedTheme={forcedDark ? "dark" : undefined} {...props}> <NextThemeProvider forcedTheme={forcedTheme} {...props}>
{children} {children}
</NextThemeProvider> </NextThemeProvider>
); );

@ -0,0 +1,118 @@
/*
* 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 { findServerData } from "@/pages/api/v1/server/get/[server]";
import { MongoClient, type WithId } from "mongodb";
import { ActualCustomization } from "./types/data";
type ServerMetadata = {
ownedServer: WithId<{
/** Legacy */
server?: string;
/** @deprecated Use `serverId` for better identification */
serverId?: string;
userId: string;
}>;
customizedServer: WithId<
ActualCustomization & {
serverId?: string;
/** @deprecated Use `serverId` for better identification */
server?: string;
}
> | null;
};
type ServerMetadataFunctional = ServerMetadata & {
changeServer: (
changes: Partial<
ActualCustomization & {
serverId?: string;
/** @deprecated Use `serverId` for better identification */
server?: string;
}
>,
) => Promise<void> | void;
};
export async function checkOwnedServerMetadata(
userId: string | null,
mongoClient: MongoClient,
serverSelector: { id: string; name?: string },
): Promise<ServerMetadataFunctional> {
// Step 1: Check auth
if (!userId) throw new Error("Unauthorized");
mongoClient.connect();
// Step 2: Check server
const serverData = await findServerData(serverSelector.id as string);
if (!serverData.exists) throw new Error("Server doesn't exist");
const db = mongoClient.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const ownedServer = (await db.collection("owned-servers").findOne({
$or: [
{ serverId: serverSelector.id },
{ server: serverSelector.name ?? serverData.name },
],
})) as WithId<{
server?: string;
serverId?: string;
userId: string;
author: string;
}>;
if (!ownedServer) throw new Error("Server not linked");
if (ownedServer.author !== userId)
throw new Error("You don't own this server.");
const customizedServer = (await db.collection("customization").findOne({
$or: [
{ serverId: serverSelector.id },
{ server: serverSelector.name ?? serverData.name },
],
})) as WithId<
ActualCustomization & { serverId?: string; server?: string }
> | null;
return {
ownedServer,
customizedServer,
changeServer: async (changes) => {
await db.collection("customization").updateOne(
{
$or: [
{ serverId: serverSelector.id },
{ server: serverSelector.name ?? serverData.name },
],
},
{ $set: changes },
{ upsert: true },
);
},
};
}

@ -0,0 +1,77 @@
/*
* 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 { useCallback, useState } from 'react'
import * as jsxRuntime from 'react/jsx-runtime'
import rehypeReact, { type Options as RehypeReactOptions } from 'rehype-react'
import remarkParse, { type Options as RemarkParseOptions } from 'remark-parse'
import remarkRehype, { type Options as RemarkRehypeOptions } from 'remark-rehype'
import { unified, type Plugin, type PluggableList, type Processor } from 'unified'
import type { Root } from 'mdast'
export interface UseRemarkOptions {
remarkParseOptions?: RemarkParseOptions
remarkPlugins?: PluggableList
remarkRehypeOptions?: RemarkRehypeOptions
rehypePlugins?: PluggableList
rehypeReactOptions?: Pick<RehypeReactOptions, 'components'>
onError?: (err: Error) => void
}
export default function useRemark({
remarkParseOptions,
remarkPlugins = [],
remarkRehypeOptions,
rehypePlugins = [],
rehypeReactOptions,
onError = () => { },
}: UseRemarkOptions = {}): [React.ReactElement | null, (source: string) => void] {
const [content, setContent] = useState<React.ReactElement | null>(null)
const setMarkdown = useCallback((source: string) => {
const processor = unified()
.use(remarkParse, remarkParseOptions)
.use(remarkPlugins as Plugin[])
.use(remarkRehype as Plugin, remarkRehypeOptions)
.use(rehypePlugins as Plugin[])
.use(rehypeReact, {
...rehypeReactOptions,
Fragment: jsxRuntime.Fragment,
jsx: jsxRuntime.jsx,
jsxs: jsxRuntime.jsxs,
} satisfies RehypeReactOptions) as unknown as Processor<Root, Root, React.ReactElement>
processor
.process(source)
.then(vfile => setContent(vfile.result as React.ReactElement))
.catch(onError)
}, [])
return [content, setMarkdown]
}

@ -30,27 +30,43 @@
import { Achievement } from "./achievement"; import { Achievement } from "./achievement";
export type ActualCustomization = {
description: string | undefined;
discord: string | undefined;
/** @version 1 @deprecated Use `colorMode` instead */
colorScheme: string | undefined;
/** @version 2 */
colorMode: "dark" | "light" | undefined;
customizationVersion: number | undefined;
} & (
| {
/** @note Using non-`ufs.io` domains is deprecated */
banner: string;
_deletionId: string;
}
| {
banner: undefined;
}
);
export type MHSFData = { export type MHSFData = {
favoriteData: { favoriteData: {
favoritedByAccount: boolean | null; favoritedByAccount: boolean | null;
favoriteNumber: number; favoriteNumber: number;
favoriteHistoricalData: { date: string; favorites: number }[]; favoriteHistoricalData: { date: string; favorites: number }[];
}; };
customizationData: { customizationData: {
description: string | undefined; userProfilePicture: string | undefined;
banner: string | undefined; /** If undefined then this is 1. */
discord: string | undefined; isOwned: boolean;
colorScheme: string | undefined; isOwnedByUser: boolean;
userProfilePicture: string | undefined; } & ActualCustomization;
isOwned: boolean; playerData: {
isOwnedByUser: boolean; historically: { date: string; playerCount: number }[];
}; max: number;
playerData: { };
historically: { date: string; playerCount: number }[]; achievements: {
max: number; historically: { _id: string; name: string; achievements: Achievement[] }[];
}; currently: Achievement[];
achievements: { };
historically: { _id: string; name: string; achievements: Achievement[] }[];
currently: Achievement[];
};
}; };

@ -1,141 +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 { NextApiRequest, NextApiResponse } from "next";
import { getAuth } from "@clerk/nextjs/server";
import { Document, MongoClient, WithId } from "mongodb";
import { waitUntil } from "@vercel/functions";
const validColors = [
"zinc",
"slate",
"stone",
"gray",
"neutral",
"red",
"rose",
"orange",
"green",
"blue",
"yellow",
"violet",
];
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { userId } = getAuth(req);
const server = req.query.server as string;
const { customization }: { customization: any } = req.body;
if (
customization.description != undefined &&
(!(Array.from(customization.description).length < 1250) ||
!(Array.from(customization.description).length > 2))
)
return res.status(400).send({ message: "Description is incorrect length" });
if (
customization.discord != undefined &&
!/^\d*\.?\d*$/.test(customization.discord)
)
return res
.status(400)
.send({ message: "Discord server has invalid chars" });
if (
customization.colorScheme != undefined &&
!validColors.includes(customization.colorScheme)
)
return res.status(400).send({ message: "Color doesn't exist" });
if (customization == null) {
res.status(400).send({ message: "Couldn't find data" });
return;
}
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
const client = new MongoClient(process.env.MONGO_DB as string);
await client.connect();
const db = client.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const collection = db.collection("owned-servers");
const customizationColl = db.collection("customization");
if (!((await collection.findOne({ server, author: userId })) == undefined)) {
const alreadyExists =
(await customizationColl.findOne({ server })) != undefined;
if (!alreadyExists) {
await customizationColl.insertOne({
server,
colorScheme: customization.colorScheme,
description: customization.description,
banner: customization.banner,
discord: customization.discord,
});
} else {
const find = (await customizationColl.findOne({
server,
})) as WithId<Document>;
if (customization.colorScheme != undefined)
await customizationColl.findOneAndUpdate(
{ server },
{ $set: { colorScheme: customization.colorScheme } },
);
if (customization.description != undefined)
await customizationColl.findOneAndUpdate(
{ server },
{ $set: { description: customization.description } },
);
if (customization.banner != undefined)
await customizationColl.findOneAndUpdate(
{ server },
{ $set: { banner: customization.banner } },
);
if (customization.discord != undefined)
await customizationColl.findOneAndUpdate(
{ server },
{ $set: { discord: customization.discord } },
);
}
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.send({ message: "Done!" });
} else {
// Close the database, but don't close this
// serverless instance until it happens
waitUntil(client.close());
res.status(400).send({ message: "You don't own this server." });
}
}

@ -166,41 +166,38 @@ async function findCustomizationData(
: null, : null,
]); ]);
let user: User | undefined = undefined; let user: User | undefined = undefined;
try { if (ownedServerData) {
user = await clerk.users.getUser(ownedServerData?.author);
} catch (e) { try {
console.warn(e); user = await clerk.users.getUser(ownedServerData?.author);
if (customizationData || ownedServerData) { } catch (e) {
console.warn(e);
if (customizationData || ownedServerData) {
return {
...(customizationData as any),
isOwned: true,
isOwnedByUser: ownedServerData?.author === userId,
userProfilePicture: null,
};
}
return { return {
...(customizationData as any), isOwned: false,
isOwned: true, isOwnedByUser: false,
isOwnedByUser: ownedServerData?.author === userId, description: undefined,
userProfilePicture: null, banner: undefined,
discord: undefined,
colorScheme: undefined,
userProfilePicture: undefined,
}; };
} }
return {
isOwned: false,
isOwnedByUser: false,
description: undefined,
banner: undefined,
discord: undefined,
colorScheme: undefined,
userProfilePicture: undefined,
};
} }
console.log(
ownedServerData?.author === userId,
userId,
ownedServerData?.author,
);
if (customizationData || ownedServerData) { if (customizationData || ownedServerData) {
return { return {
...(customizationData as any), ...(customizationData as any),
isOwned: true, isOwned: true,
isOwnedByUser: ownedServerData?.author === userId, isOwnedByUser: ownedServerData?.author === userId,
userProfilePicture: userId ? user.imageUrl : "no user", userProfilePicture: userId ? user?.imageUrl : "no user",
}; };
} }

@ -0,0 +1,60 @@
/*
* 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 { checkOwnedServerMetadata } from "@/lib/check-owned-server";
import { getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
try {
const { server: serverId, colorMode } = req.query;
const mongo = new MongoClient(process.env.MONGO_DB as string);
const { changeServer } =
await checkOwnedServerMetadata(getAuth(req).userId ?? null, mongo, {
id: serverId as string,
});
if (colorMode !== "light" && colorMode !== "dark" && colorMode !== undefined ) {
return res.status(400).send({ error: "Invalid value" })
}
await changeServer({
colorMode,
});
} catch (error) {
return res.status(400).send({ error: error });
}
return res.send({ message: "Success" });
}

@ -0,0 +1,58 @@
/*
* 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 { checkOwnedServerMetadata } from "@/lib/check-owned-server";
import { getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
try {
const { server: serverId, description } = req.query;
const mongo = new MongoClient(process.env.MONGO_DB as string);
if (!description)
return res.status(400).send({ error: "No description provided" });
const { changeServer } =
await checkOwnedServerMetadata(getAuth(req).userId ?? null, mongo, {
id: serverId as string,
});
await changeServer({
description: atob(decodeURIComponent(description as string)),
});
} catch (error) {
return res.status(400).send({ error: error });
}
return res.send({ message: "Success" });
}

@ -0,0 +1,47 @@
import { checkOwnedServerMetadata } from "@/lib/check-owned-server";
import { getAuth } from "@clerk/nextjs/server";
import { MongoClient } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
import { UTApi } from "uploadthing/server";
import type { ActualCustomization } from "@/lib/types/data";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
try {
const { server: serverId } = req.query;
const mongo = new MongoClient(process.env.MONGO_DB as string);
const { ownedServer, customizedServer, changeServer } =
await checkOwnedServerMetadata(getAuth(req).userId ?? null, mongo, {
id: serverId as string,
});
if (customizedServer?.customizationVersion !== 2) {
if ((!customizedServer?.banner?.startsWith("https://exh89c9lva.ufs.sh")) && customizedServer?.banner) {
const utapi = new UTApi();
console.log(
`https://wsrv.nl/?url=${encodeURIComponent(customizedServer?.banner as string)}`)
const newBanner = await utapi.uploadFilesFromUrl(
`https://wsrv.nl/?url=${encodeURIComponent(customizedServer?.banner as string)}`,
);
if (newBanner.error)
return res.status(400).send({ error: newBanner.error });
await changeServer({ banner: newBanner.data.ufsUrl });
}
await changeServer({
colorMode: "light",
customizationVersion: 2,
serverId: serverId as string,
});
return res.send({ message: "Successfully migrated from svc1 to svc2." });
}
return res.send({ message: "Already migrated." });
} catch (e) {
res.status(400).send({ error: e });
}
}

@ -29,16 +29,13 @@
*/ */
import { getAuth } from "@clerk/nextjs/server"; import { getAuth } from "@clerk/nextjs/server";
import { import { createRouteHandler, createUploadthing } from "uploadthing/next-legacy";
createRouteHandler, import { UploadThingError, UTApi } from "uploadthing/server";
createUploadthing,
type FileRouter,
} from "uploadthing/next-legacy";
import { UploadThingError } from "uploadthing/server";
import { findServerData } from ".."; import { findServerData } from "..";
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import type { FileRoute } from "uploadthing/types"; import type { FileRoute } from "uploadthing/types";
import type { Json } from "@uploadthing/shared"; import type { Json } from "@uploadthing/shared";
import { checkOwnedServerMetadata } from "@/lib/check-owned-server";
const f = createUploadthing(); const f = createUploadthing();
@ -52,74 +49,96 @@ export default createRouteHandler({
}) })
// Set permissions and file types for this FileRoute // Set permissions and file types for this FileRoute
.middleware(async ({ req, res }) => { .middleware(async ({ req, res }) => {
// Step 1: Check authentication // Step 1: Check authentication
const { userId } = getAuth(req); const { userId } = getAuth(req);
if (!userId) throw new UploadThingError("Unauthorized"); if (!userId) throw new UploadThingError("Unauthorized");
// Step 2: Check server // Step 2: Check server
const { server } = req.query; const { server } = req.query;
const serverData = await findServerData(server as string); const serverData = await findServerData(server as string);
const mongoClient = new MongoClient(process.env.MONGO_DB as string); const mongoClient = new MongoClient(process.env.MONGO_DB as string);
try { try {
if (!serverData.exists) throw new UploadThingError("Server doesn't exist"); if (!serverData.exists)
await mongoClient.connect(); throw new UploadThingError("Server doesn't exist");
await mongoClient.connect();
const db = mongoClient.db(process.env.CUSTOM_MONGO_DB ?? "mhsf"); const db = mongoClient.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const ownedServer = await db.collection("owned-servers").findOne({ $or: [{ serverId: server }, { server: serverData.name }] }); const ownedServer = await db
if (!ownedServer) throw new UploadThingError("Server not linked"); .collection("owned-servers")
if (ownedServer.author !== userId) throw new UploadThingError("You don't own this server."); .findOne({
$or: [{ serverId: server }, { server: serverData.name }],
});
if (!ownedServer) throw new UploadThingError("Server not linked");
if (ownedServer.author !== userId)
throw new UploadThingError("You don't own this server.");
return { return {
userId, userId,
ownedServer, ownedServer,
serverId: server, serverId: server,
serverName: serverData.name serverName: serverData.name,
}; };
} finally { } finally {
await mongoClient.close(); await mongoClient.close();
} }
}) })
.onUploadComplete(async ({ metadata, file }) => { .onUploadComplete(async ({ metadata, file }) => {
// This code RUNS ON YOUR SERVER after upload // This code RUNS ON YOUR SERVER after upload
console.log("Upload complete for userId:", metadata.userId); console.log("Upload complete for userId:", metadata.userId);
console.log("file url", file.ufsUrl); console.log("file url", file.ufsUrl);
console.log("metadata:", metadata); console.log("metadata:", metadata);
const utapi = new UTApi();
// Update the server's customization data with the new banner URL // Step 3: Update the server's customization data with the new banner URL
const mongoClient = new MongoClient(process.env.MONGO_DB as string); const mongoClient = new MongoClient(process.env.MONGO_DB as string);
try { try {
await mongoClient.connect(); const { customizedServer } =
const db = mongoClient.db(process.env.CUSTOM_MONGO_DB ?? "mhsf"); await checkOwnedServerMetadata(metadata.userId, mongoClient, {
id: metadata.serverId as string,
});
const db = mongoClient.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
// Update or insert the customization data // Step 3.5: Delete old banner if needed
const result = await db.collection("customization").updateOne( if (customizedServer?.banner) {
{ $or: [{ serverId: metadata.serverId }, { server: metadata.serverName }] }, await utapi.deleteFiles(customizedServer._deletionId);
{ $set: { banner: file.ufsUrl } }, }
{ upsert: true }
);
console.log("Database update result:", result); // Step 4: Update or insert the customization data
const result = await db
.collection("customization")
.updateOne(
{
$or: [
{ serverId: metadata.serverId },
{ server: metadata.serverName },
],
},
{ $set: { banner: file.ufsUrl, customizationVersion: 2, _deletionId: file.key } },
{ upsert: true },
);
// !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback console.log("Database update result:", result);
return { uploadedBy: metadata.userId };
} catch (error) { // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback
console.error("Error updating database:", error); return { uploadedBy: metadata.userId };
throw error; } catch (error) {
} finally { console.error("Error updating database:", error);
await mongoClient.close(); throw error;
} } finally {
await mongoClient.close();
}
}), }),
}, },
}); });
export type BannerUploaderRouter = { export type BannerUploaderRouter = {
imageUploader: FileRoute<{ imageUploader: FileRoute<{
input: undefined; input: undefined;
output: { output: {
uploadedBy: string; uploadedBy: string;
}; };
errorShape: Json; errorShape: Json;
}>; }>;
} };

3934
yarn.lock

File diff suppressed because it is too large Load Diff