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

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

@ -37,15 +37,26 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Material } from "@/components/ui/material";
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 type { ReactNode } from "react";
import ShikiHighlighter, {
type Element,
isInlineCode,
rehypeInlineCodeProperty,
} from "react-shiki";
export function MOTDRow({
server,
mhsfData,
}: { server: ServerResponse; mhsfData: ReturnType<typeof useMHSFServer> }) {
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 (
<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",
"rounded-xl px-2 flex items-center gap-2",
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")}
>
@ -110,9 +121,37 @@ export function MOTDRow({
)}
{tab === "description" && (
<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>
)}
</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 { Alert } from "@/components/ui/alert";
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 {
generateUploadButton,
generateUploadDropzone,
} 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 =
"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 ? (
<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">
Server Settings
</DrawerTitle>
<Material className="grid gap-1 max-h-[700px]">
<strong>Server Description</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={(content) => console.log(content)}
{serverData.server?.customizationData
.customizationVersion === 2 ? (
<div>
<DrawerTitle className="scroll-m-20 text-2xl font-extrabold tracking-tight lg:text-4xl mb-3">
Server Settings
</DrawerTitle>
<ServerDescriptionBox
serverData={serverData}
minehutData={minehutData}
/>
)}
</Material>
<Material className="grid gap-1 mt-2 max-h-[700px]">
<strong>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.
</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}`);
}}
<ServerBannerBox
serverData={serverData}
minehutData={minehutData}
/>
<ServerColorModeBox
serverData={serverData}
minehutData={minehutData}
/>
</div>
) : (
<ServerMigrationBox
oldVersion={1}
reupdate={() => serverData.refresh()}
id={minehutData._id}
/>
</Material>
)}
</div>
) : (
<Placeholder

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

@ -38,24 +38,27 @@ import { usePathname } from "next/navigation";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const [mounted, setMounted] = React.useState(false);
const pathname = usePathname();
const [forcedDark, setForcedDark] = React.useState(false);
const [forcedTheme, setForcedTheme] = React.useState<'dark' | 'light' | undefined>();
React.useEffect(() => {
setMounted(true);
window.addEventListener("force-dark-mode", () => {
setForcedDark(true);
setForcedTheme('dark');
});
window.addEventListener("force-light-mode", () => {
setForcedTheme('light');
});
});
React.useEffect(() => {
setForcedDark(false);
setForcedTheme(undefined);
}, [pathname]);
if (!mounted) return null;
return (
<NextThemeProvider forcedTheme={forcedDark ? "dark" : undefined} {...props}>
<NextThemeProvider forcedTheme={forcedTheme} {...props}>
{children}
</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";
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 = {
favoriteData: {
favoritedByAccount: boolean | null;
favoriteNumber: number;
favoriteHistoricalData: { date: string; favorites: number }[];
};
customizationData: {
description: string | undefined;
banner: string | undefined;
discord: string | undefined;
colorScheme: string | undefined;
userProfilePicture: string | undefined;
isOwned: boolean;
isOwnedByUser: boolean;
};
playerData: {
historically: { date: string; playerCount: number }[];
max: number;
};
achievements: {
historically: { _id: string; name: string; achievements: Achievement[] }[];
currently: Achievement[];
};
favoriteData: {
favoritedByAccount: boolean | null;
favoriteNumber: number;
favoriteHistoricalData: { date: string; favorites: number }[];
};
customizationData: {
userProfilePicture: string | undefined;
/** If undefined then this is 1. */
isOwned: boolean;
isOwnedByUser: boolean;
} & ActualCustomization;
playerData: {
historically: { date: string; playerCount: number }[];
max: number;
};
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,
]);
let user: User | undefined = undefined;
try {
user = await clerk.users.getUser(ownedServerData?.author);
} catch (e) {
console.warn(e);
if (customizationData || ownedServerData) {
if (ownedServerData) {
try {
user = await clerk.users.getUser(ownedServerData?.author);
} catch (e) {
console.warn(e);
if (customizationData || ownedServerData) {
return {
...(customizationData as any),
isOwned: true,
isOwnedByUser: ownedServerData?.author === userId,
userProfilePicture: null,
};
}
return {
...(customizationData as any),
isOwned: true,
isOwnedByUser: ownedServerData?.author === userId,
userProfilePicture: null,
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,
};
}
console.log(
ownedServerData?.author === userId,
userId,
ownedServerData?.author,
);
if (customizationData || ownedServerData) {
return {
...(customizationData as any),
isOwned: true,
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 {
createRouteHandler,
createUploadthing,
type FileRouter,
} from "uploadthing/next-legacy";
import { UploadThingError } from "uploadthing/server";
import { createRouteHandler, createUploadthing } from "uploadthing/next-legacy";
import { UploadThingError, UTApi } from "uploadthing/server";
import { findServerData } from "..";
import { MongoClient } from "mongodb";
import type { FileRoute } from "uploadthing/types";
import type { Json } from "@uploadthing/shared";
import { checkOwnedServerMetadata } from "@/lib/check-owned-server";
const f = createUploadthing();
@ -52,74 +49,96 @@ export default createRouteHandler({
})
// Set permissions and file types for this FileRoute
.middleware(async ({ req, res }) => {
// Step 1: Check authentication
// Step 1: Check authentication
const { userId } = getAuth(req);
if (!userId) throw new UploadThingError("Unauthorized");
// Step 2: Check server
const { server } = req.query;
const serverData = await findServerData(server as string);
const mongoClient = new MongoClient(process.env.MONGO_DB as string);
// Step 2: Check server
const { server } = req.query;
const serverData = await findServerData(server as string);
const mongoClient = new MongoClient(process.env.MONGO_DB as string);
try {
if (!serverData.exists) throw new UploadThingError("Server doesn't exist");
await mongoClient.connect();
try {
if (!serverData.exists)
throw new UploadThingError("Server doesn't exist");
await mongoClient.connect();
const db = mongoClient.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const ownedServer = await db.collection("owned-servers").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.");
const db = mongoClient.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const ownedServer = await db
.collection("owned-servers")
.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 {
userId,
ownedServer,
serverId: server,
serverName: serverData.name
};
} finally {
await mongoClient.close();
}
return {
userId,
ownedServer,
serverId: server,
serverName: serverData.name,
};
} finally {
await mongoClient.close();
}
})
.onUploadComplete(async ({ metadata, file }) => {
// This code RUNS ON YOUR SERVER after upload
console.log("Upload complete for userId:", metadata.userId);
console.log("file url", file.ufsUrl);
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);
try {
await mongoClient.connect();
const db = mongoClient.db(process.env.CUSTOM_MONGO_DB ?? "mhsf");
const { customizedServer } =
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
const result = await db.collection("customization").updateOne(
{ $or: [{ serverId: metadata.serverId }, { server: metadata.serverName }] },
{ $set: { banner: file.ufsUrl } },
{ upsert: true }
);
// Step 3.5: Delete old banner if needed
if (customizedServer?.banner) {
await utapi.deleteFiles(customizedServer._deletionId);
}
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
return { uploadedBy: metadata.userId };
} catch (error) {
console.error("Error updating database:", error);
throw error;
} finally {
await mongoClient.close();
}
console.log("Database update result:", result);
// !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback
return { uploadedBy: metadata.userId };
} catch (error) {
console.error("Error updating database:", error);
throw error;
} finally {
await mongoClient.close();
}
}),
},
});
export type BannerUploaderRouter = {
imageUploader: FileRoute<{
input: undefined;
output: {
uploadedBy: string;
};
errorShape: Json;
}>;
}
imageUploader: FileRoute<{
input: undefined;
output: {
uploadedBy: string;
};
errorShape: Json;
}>;
};

3934
yarn.lock

File diff suppressed because it is too large Load Diff