feat: redid server view

This commit is contained in:
dvelo 2024-11-17 21:10:02 -06:00
parent fd40a8e143
commit 346f9d210e
18 changed files with 581 additions and 278 deletions

@ -53,6 +53,7 @@
"react-dom": "^18", "react-dom": "^18",
"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-qr-code": "^2.0.15",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",

@ -43,9 +43,9 @@ import NewDomainDialog from "@/components/misc/NewDomainDialog";
import ThemedToaster from "@/components/misc/ThemedToaster"; import ThemedToaster from "@/components/misc/ThemedToaster";
import UnofficalDialog from "@/components/misc/UnofficalDialog"; import UnofficalDialog from "@/components/misc/UnofficalDialog";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbList, BreadcrumbList,
BreadcrumbPage, BreadcrumbPage,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { banner } from "@/config/banner"; import { banner } from "@/config/banner";
@ -55,79 +55,74 @@ import { Inter as interFont } from "next/font/google";
import Link from "next/link"; import Link from "next/link";
export const extraMetadata = { export const extraMetadata = {
twitter: { twitter: {
images: [ images: [
{ {
url: "/imgs/icon-cf.png", url: "/imgs/icon-cf.png",
}, },
], ],
}, },
themeColor: "#000000", themeColor: "#000000",
openGraph: { openGraph: {
images: [ images: [
{ {
url: "/imgs/icon-cf.png", url: "/imgs/icon-cf.png",
}, },
], ],
}, },
} satisfies Metadata; } satisfies Metadata;
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: "black", themeColor: "black",
colorScheme: "dark", colorScheme: "dark",
}; };
const inter = interFont({ variable: "--font-inter", subsets: ["latin"] }); const inter = interFont({ variable: "--font-inter", subsets: ["latin"] });
export default async function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<ClerkThemeProvider className={GeistSans.className}> <ClerkThemeProvider className={GeistSans.className}>
<ThemeProvider <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
attribute="class" <TooltipProvider>
defaultTheme="system" {banner.isBanner && (
enableSystem <div className="bg-orange-600 z-10 w-screen h-8 border-b fixed text-black flex items-center text-center font-medium pl-2">
disableTransitionOnChange {banner.bannerText}
> </div>
<TooltipProvider> )}
{banner.isBanner && ( <div
<div className="bg-orange-600 z-10 w-screen h-8 border-b fixed text-black flex items-center text-center font-medium pl-2"> className={
{banner.bannerText} "w-screen h-[3rem] border-b fixed backdrop-blur flex z-10 " +
</div> (banner.isBanner == true ? "mt-8" : "")
)} }
<div >
className={ <div className="items-center me-auto mt-2 pl-7 max-sm:mt-3">
"w-screen h-[3rem] border-b fixed backdrop-blur flex z-10 " + <Breadcrumb>
(banner.isBanner == true ? "mt-8" : "") <BreadcrumbList>
} <Link href="/">
> <BreadcrumbPage className="max-sm:hidden">
<div className="items-center me-auto mt-2 pl-7 max-sm:mt-3"> <BrandingGenericIcon className="max-w-[32px] max-h-[32px] " />
<Breadcrumb> </BreadcrumbPage>
<BreadcrumbList> </Link>
<Link href="/"> <TextFromPathname />
<BreadcrumbPage className="max-sm:hidden"> </BreadcrumbList>
<BrandingGenericIcon className="max-w-[32px] max-h-[32px] " /> </Breadcrumb>
</BreadcrumbPage> </div>
</Link> <TopBar inter={inter.className} />
<TextFromPathname /> </div>
</BreadcrumbList> <div className={banner.isBanner ? "pt-8" : undefined}>
</Breadcrumb> <NextTopLoader />
</div> <ClientFadeIn>{children}</ClientFadeIn>
<TopBar inter={inter.className} /> </div>{" "}
</div> <ThemedToaster />
<div className={banner.isBanner ? "pt-8" : undefined}> <CommandBarer />
<NextTopLoader /> <SpeedInsights />
<ClientFadeIn>{children}</ClientFadeIn> <Analytics />
</div>{" "} <NewDomainDialog />
<ThemedToaster /> <UnofficalDialog />
<CommandBarer /> </TooltipProvider>
<SpeedInsights /> </ThemeProvider>
<Analytics /> </ClerkThemeProvider>
<NewDomainDialog /> );
<UnofficalDialog />
</TooltipProvider>
</ThemeProvider>
</ClerkThemeProvider>
);
} }

@ -130,7 +130,7 @@ export default function ServerPage({ params }: { params: { server: string } }) {
return ( return (
<main> <main>
<ColorProvider server={params.server}> <ColorProvider server={params.server}>
<div className={"pt-16"}> <div className={"pt-16 xl:px-[100px]"}>
<Banner server={params.server} /> <Banner server={params.server} />
<TabServer server={params.server} tabDef="general" /> <TabServer server={params.server} tabDef="general" />
<div className="pt-8"> <div className="pt-8">

@ -37,80 +37,80 @@ import { Separator } from "@/components/ui/separator";
import type { Metadata, ResolvingMetadata } from "next"; import type { Metadata, ResolvingMetadata } from "next";
type Props = { type Props = {
params: { server: string }; params: { server: string };
}; };
export async function generateMetadata( export async function generateMetadata(
{ params }: Props, { params }: Props,
parent: ResolvingMetadata, parent: ResolvingMetadata
): Promise<Metadata> { ): Promise<Metadata> {
// read route params // read route params
const { server } = params; const { server } = params;
const json = await ( const json = await (
await fetch("https://api.minehut.com/server/" + server + "?byName=true") await fetch("https://api.minehut.com/server/" + server + "?byName=true")
).json(); ).json();
return { return {
title: title:
json.server == null json.server == null
? "Server doesn't exist | MHSF" ? "Server doesn't exist | MHSF"
: json.server.name + : json.server.name +
", " + ", " +
(json.server.online (json.server.online
? json.server.playerCount + ? json.server.playerCount +
(json.server.maxPlayers != 10 (json.server.maxPlayers != 10
? "/" + json.server.maxPlayers ? "/" + json.server.maxPlayers
: "") + : "") +
" online" " online"
: "Offline") + : "Offline") +
" | MHSF", " | MHSF",
description: description:
json.server == null json.server == null
? `The server ${server} doesn't exist.` ? `The server ${server} doesn't exist.`
: `View ${server} on Minehut Server Finder!`, : `View ${server} on Minehut Server Finder!`,
authors: json.server == null ? undefined : { name: json.server.owner }, authors: json.server == null ? undefined : { name: json.server.owner },
applicationName: "MHSF (Minehut Server Finder)", applicationName: "MHSF (Minehut Server Finder)",
icons: icons:
json.server == null json.server == null
? undefined ? undefined
: "https://mcapi.marveldc.me/item/" + : "https://mcapi.marveldc.me/item/" +
(json.server.icon == undefined ? "OAK_SIGN" : json.server.icon) + (json.server.icon == undefined ? "OAK_SIGN" : json.server.icon) +
"?width=64&height=64", "?width=64&height=64",
openGraph: { openGraph: {
type: "profile", type: "profile",
siteName: "MHSF (Minehut Server Finder)", siteName: "MHSF (Minehut Server Finder)",
images: [ images: [
{ {
url: url:
"https://mcapi.marveldc.me/item/" + "https://mcapi.marveldc.me/item/" +
json.server.icon + json.server.icon +
"?width=64&height=64", "?width=64&height=64",
}, },
{ {
url: "/favicon.ico", url: "/favicon.ico",
}, },
], ],
}, },
}; };
} }
export default function ServerPage({ params }: { params: { server: string } }) { export default function ServerPage({ params }: { params: { server: string } }) {
return ( return (
<main> <main>
<ColorProvider server={params.server}> <ColorProvider server={params.server}>
<div className={"pt-16"}> <div className={"pt-16 xl:px-[100px]"}>
<Banner server={params.server} /> <Banner server={params.server} />
<TabServer server={params.server} tabDef="statistics" /> <TabServer server={params.server} tabDef="statistics" />
<div className="pt-8"> <div className="pt-8">
<ServerView server={params.server} /> <ServerView server={params.server} />
<Separator /> <Separator />
<br /> <br />
<div className="p-4 gap-4"> <div className="p-4 gap-4">
<NewChart server={params.server} /> <NewChart server={params.server} />
</div> </div>
</div> </div>
</div> </div>
</ColorProvider> </ColorProvider>
</main> </main>
); );
} }

@ -147,6 +147,37 @@
/* }*/ /* }*/
/*}*/ /*}*/
@layer base {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2;
}
@keyframes clip-down {
from {
clip-path: inset(0 0 100% 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
.dark::view-transition-new(root),
.light::view-transition-new(root) {
animation: 0.7s clip-down;
}
}
.backdrop-blur { .backdrop-blur {
-webkit-backdrop-filter: blur(8px) !important; -webkit-backdrop-filter: blur(8px) !important;
backdrop-filter: blur(8px) !important; backdrop-filter: blur(8px) !important;

@ -37,7 +37,7 @@ import {
getIndexFromRarity, getIndexFromRarity,
getMinehutIcons, getMinehutIcons,
} from "@/lib/types/server-icon"; } from "@/lib/types/server-icon";
import { Copy, ExternalLink, Info } from "lucide-react"; import { Copy, Info, QrCode, Share2 } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import FadeIn from "react-fade-in/lib/FadeIn"; import FadeIn from "react-fade-in/lib/FadeIn";
@ -56,6 +56,9 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "./ui/drawer"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "./ui/drawer";
import EmbedSelector from "./feat/EmbedSelector"; import EmbedSelector from "./feat/EmbedSelector";
import { Separator } from "./ui/separator";
import QRCodeGenerator from "./feat/QRCodeGen";
import NoItems from "./misc/NoItems";
export default function AfterServerView({ server }: { server: string }) { export default function AfterServerView({ server }: { server: string }) {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@ -64,6 +67,7 @@ export default function AfterServerView({ server }: { server: string }) {
const [icons, setIcons] = useState<MinehutIcon[]>(); const [icons, setIcons] = useState<MinehutIcon[]>();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [qrCodeOpen, setQrCodeOpen] = useState(false);
const [view, setView] = useState( const [view, setView] = useState(
description !== "" || discord !== "" ? "desc" : "extra" description !== "" || discord !== "" ? "desc" : "extra"
); );
@ -104,6 +108,14 @@ export default function AfterServerView({ server }: { server: string }) {
<EmbedSelector server={server} /> <EmbedSelector server={server} />
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
<Drawer open={qrCodeOpen} onOpenChange={setQrCodeOpen}>
<DrawerContent className="max-w-md w-full mx-auto rounded-t-[10px]">
<DrawerHeader>
<DrawerTitle>QR Code generator</DrawerTitle>
</DrawerHeader>
<QRCodeGenerator server={server} />
</DrawerContent>
</Drawer>
<FadeIn> <FadeIn>
<div className="grid sm:grid-cols-6 h-full pl-4 pr-4 "> <div className="grid sm:grid-cols-6 h-full pl-4 pr-4 ">
<div className="ml-5 mb-2 flex items-center sm:hidden overflow-auto w-[calc(100vw-5rem)]"> <div className="ml-5 mb-2 flex items-center sm:hidden overflow-auto w-[calc(100vw-5rem)]">
@ -135,9 +147,10 @@ export default function AfterServerView({ server }: { server: string }) {
> >
Purchased Icons Purchased Icons
</Button> </Button>
<Separator orientation="vertical" />
<Button variant="ghost" onClick={() => setEmbedOpened(true)}> <Button variant="ghost" onClick={() => setEmbedOpened(true)}>
Embed Creator <Share2 className="h-[1rem] w-[1rem] mr-2" />
<ExternalLink className="h-[1.2rem] w-[1.2rem] ml-1" /> Embeds
</Button> </Button>
</div> </div>
<div className="max-sm:hidden"> <div className="max-sm:hidden">
@ -168,9 +181,16 @@ export default function AfterServerView({ server }: { server: string }) {
> >
Purchased Icons Purchased Icons
</Button> </Button>
<br />
<Separator />
<br />
<Button variant="ghost" onClick={() => setEmbedOpened(true)}> <Button variant="ghost" onClick={() => setEmbedOpened(true)}>
Embed Creator <Share2 className="h-[1rem] w-[1rem] mr-2" />
<ExternalLink className="h-[1.2rem] w-[1.2rem] ml-1" /> Embeds
</Button>
<Button variant="ghost" onClick={() => setQrCodeOpen(true)}>
<QrCode className="h-[1rem] w-[1rem] mr-2" />
QR Code
</Button> </Button>
</div> </div>
</div> </div>
@ -450,6 +470,7 @@ export default function AfterServerView({ server }: { server: string }) {
ownership, they may or may not available at that certain ownership, they may or may not available at that certain
moment either. moment either.
</p> </p>
{serverObject?.purchased_icons.length == 0 && <NoItems />}
{serverObject?.purchased_icons.map((icon) => ( {serverObject?.purchased_icons.map((icon) => (
<Card key={icon} className="my-4"> <Card key={icon} className="my-4">
<CardContent <CardContent

@ -29,27 +29,25 @@
*/ */
"use client"; "use client";
import { Separator } from "@/components/ui/separator";
import { useState } from "react"; import { useState } from "react";
import Banner from "./Banner"; import Banner from "./Banner";
import ServerCustomize from "./ServerCustomize"; import ServerCustomize from "./ServerCustomize";
import TabServer from "./misc/TabServer"; import TabServer from "./misc/TabServer";
export default function CustomizeRoot({ export default function CustomizeRoot({
params, params,
}: { }: {
params: { server: string }; params: { server: string };
}) { }) {
const [color, setColor] = useState(""); const [color, setColor] = useState("");
return ( return (
<div className={"pt-16 theme-" + color}> <div className={"pt-16 xl:px-[100px] theme-" + color}>
<Banner server={params.server} /> <Banner server={params.server} />
<TabServer server={params.server} tabDef="customize" /> <TabServer server={params.server} tabDef="customize" />
<Separator /> <br />
<br /> <div className="pl-[40px] pr-[40px]">
<div className="pl-[40px] pr-[40px]"> <ServerCustomize server={params.server} cs={color} setCS={setColor} />
<ServerCustomize server={params.server} cs={color} setCS={setColor} /> </div>
</div> </div>
</div> );
);
} }

@ -196,7 +196,7 @@ export function NewChart({ server }: { server: string }) {
); );
} }
function convert(value: number) { export function convert(value: number) {
var result: string = value.toString(); var result: string = value.toString();
if (value >= 1000000) { if (value >= 1000000) {
result = Math.floor(value / 1000000) + "m"; result = Math.floor(value / 1000000) + "m";

@ -31,15 +31,11 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import {
Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter,
CardHeader,
CardTitle, CardTitle,
BetterHeader, BetterHeader,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { motion } from "framer-motion";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -47,14 +43,22 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import ServerSingle from "@/lib/single"; import ServerSingle from "@/lib/single";
import { SignedIn, SignedOut } from "@clerk/nextjs"; import { motion } from "framer-motion";
import SignInPopoverButton from "./clerk/SignInPopoverButton"; import { Cake, Check, Heart, Star, Users, X } from "lucide-react";
import { Star, X } from "lucide-react"; import {
import { favoriteServer, isFavorited } from "@/lib/api"; favoriteServer,
import { LoadingButton } from "./ui/loading-button"; getCommunityServerFavorites,
isFavorited,
} from "@/lib/api";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Skeleton } from "./ui/skeleton"; import { Skeleton } from "./ui/skeleton";
import FadeIn from "react-fade-in/lib/FadeIn"; import FadeIn from "react-fade-in/lib/FadeIn";
import { Button } from "./ui/button";
import IconDisplay from "./IconDisplay";
import { useClerk, useUser } from "@clerk/nextjs";
import { LoaderIcon } from "react-hot-toast";
import { Separator } from "./ui/separator";
import { convert } from "@/components/NewChart";
export default function ServerView(props: { server: string }) { export default function ServerView(props: { server: string }) {
const [single, setSingle] = useState(new ServerSingle(props.server)); const [single, setSingle] = useState(new ServerSingle(props.server));
@ -64,6 +68,9 @@ export default function ServerView(props: { server: string }) {
const [loadingFavorite, setLoadingFavorite] = useState(false); const [loadingFavorite, setLoadingFavorite] = useState(false);
const [randomText, setRandomText] = useState(""); const [randomText, setRandomText] = useState("");
const [lastOnline, setLastOnline] = useState(0); const [lastOnline, setLastOnline] = useState(0);
const { isSignedIn } = useUser();
const [communityFavorited, setCommunityFavorited] = useState(0);
const clerk = useClerk();
const [format, setFormat] = useState(""); const [format, setFormat] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const allText = [""]; const allText = [""];
@ -91,16 +98,19 @@ export default function ServerView(props: { server: string }) {
setLastOnline(online); setLastOnline(online);
} }
}); });
getCommunityServerFavorites(single.grabOffline()?.name as string).then(
(b) => {
setCommunityFavorited(b);
}
);
}); });
}, []); }, []);
if (loading) { if (loading) {
return ( return (
<> <>
<div className="grid p-4 sm:grid-cols-3 gap-4"> <div className="p-4">
<Skeleton className="sm:col-span-2 h-[245px]" /> <Skeleton className="sm:col-span-2 h-[155px]" />
<Skeleton className="h-[245px]" />
</div> </div>
</> </>
); );
@ -125,8 +135,14 @@ export default function ServerView(props: { server: string }) {
</div> </div>
)} )}
<FadeIn> <FadeIn>
<div className="grid p-4 sm:grid-cols-3 gap-4"> <div className="flex items-center">
<Card className="sm:col-span-2"> <div className="bg-secondary p-4 rounded-lg ml-4">
<IconDisplay
server={single.grabOffline()}
className="flex items-center"
/>
</div>
<div className="block">
<BetterHeader> <BetterHeader>
<CardTitle className="flex items-center"> <CardTitle className="flex items-center">
{single.grabOnline() == undefined && {single.grabOnline() == undefined &&
@ -173,103 +189,114 @@ export default function ServerView(props: { server: string }) {
</Tooltip> </Tooltip>
)} )}
{single.getAuthor() != undefined && ( {single.getAuthor() != undefined ? (
<p>by {single.getAuthor()}</p> <p className="text-lg flex items-center">
by {single.getAuthor()}{" "}
<Button
className="h-7 ml-2"
variant={favorited ? "outline" : "favorite"}
onClick={() => {
if (!isSignedIn) {
clerk.openSignUp();
return;
}
setLoadingFavorite(true);
favoriteServer(
single.grabOffline()?.name as string
).then(() => {
setLoadingFavorite(false);
setFavorited(!favorited);
});
}}
disabled={loadingFavorite}
>
{loadingFavorite && <LoaderIcon className="mr-2" />}
{!favorited && !loadingFavorite && (
<motion.div
animate={{ opacity: 1, scale: 1 }}
initial={{ opacity: 0, scale: 0.3 }}
transition={{ duration: 0.25, ease: "linear" }}
>
<Star size={16} className="mr-2" />
</motion.div>
)}
{favorited && !loadingFavorite && (
<motion.div
animate={{ opacity: 1, scale: 1 }}
initial={{ opacity: 0, scale: 0.3 }}
transition={{ duration: 0.25, ease: "linear" }}
>
<Check size={16} className="mr-2" />
</motion.div>
)}
Favorite{favorited && "d"}
</Button>
</p>
) : (
<p className="text-lg flex items-center">
by Anonymous{" "}
<Button
className="h-7 ml-2"
variant={favorited ? "outline" : "favorite"}
onClick={() => {
if (!isSignedIn) {
clerk.openSignUp();
return;
}
setLoadingFavorite(true);
favoriteServer(
single.grabOffline()?.name as string
).then(() => {
setLoadingFavorite(false);
setFavorited(!favorited);
});
}}
disabled={loadingFavorite}
>
{loadingFavorite && <LoaderIcon className="mr-2" />}
{!favorited && !loadingFavorite && (
<motion.div
animate={{ opacity: 1, scale: 1 }}
initial={{ opacity: 0, scale: 0.3 }}
transition={{ duration: 0.25, ease: "linear" }}
>
<Star size={16} className="mr-2" />
</motion.div>
)}
{favorited && !loadingFavorite && (
<motion.div
animate={{ opacity: 1, scale: 1 }}
initial={{ opacity: 0, scale: 0.3 }}
transition={{ duration: 0.25, ease: "linear" }}
>
<Check size={16} className="mr-2" />
</motion.div>
)}
Favorite{favorited && "d"}
</Button>
</p>
)} )}
</CardDescription> </CardDescription>
</BetterHeader> </BetterHeader>
<CardContent> <CardContent>
<p> <p className="text-md font-semibold text-muted-foreground flex items-center">
<strong>Time:</strong> <>
<br /> <Heart className="mr-2" size={24} />
<i>Last online</i>{" "} {convert(communityFavorited)}
<Tooltip> <Separator orientation="vertical" className="ml-4 h-[30px]" />
<TooltipTrigger> </>
<code> <>
{timeConverter(single.grabOffline()?.last_online)} <Users className="mr-2 ml-4" size={24} />{" "}
</code> {convert(single.grabOffline()?.joins as number)}
</TooltipTrigger> <Separator orientation="vertical" className="ml-4 h-[30px]" />
<TooltipContent> </>
<code>{single.grabOffline()?.last_online}</code> in Unix <>
time <Cake className="mr-2 ml-4" size={24} />{" "}
</TooltipContent> {timeConverter(single.grabOffline()?.creation)}
</Tooltip>{" "} </>
<br />
<i>Created on</i>{" "}
<Tooltip>
<TooltipTrigger>
<code>{timeConverter(single.grabOffline()?.creation)}</code>
</TooltipTrigger>
<TooltipContent>
<code>{single.grabOffline()?.creation}</code> in Unix time
</TooltipContent>
</Tooltip>
</p> </p>
</CardContent> </CardContent>
</Card> </div>
<Card>
<CardHeader>
<CardTitle>Favorite the server?</CardTitle>
<CardDescription>
By favoriting the server, you can see it later.{" "}
<SignedOut>
<strong>You need to sign in to favorite a server.</strong>
</SignedOut>
</CardDescription>
</CardHeader>
<CardContent>
<SignedOut>
<SignInPopoverButton />
</SignedOut>
<SignedIn>
<LoadingButton
variant={resolvedTheme == "dark" ? "outline" : "default"}
loading={loadingFavorite}
onClick={() => {
setLoadingFavorite(true);
favoriteServer(single.grabOffline()?.name as string).then(
() => {
setFavorited(!favorited);
setLoadingFavorite(false);
}
);
}}
>
{favorited && (
<motion.div
animate={{ color: "yellow", fill: "yellow" }}
transition={{ duration: 2 }}
>
<Star
className="mr-2"
size="16"
color="yellow"
fill="yellow"
/>
</motion.div>
)}
{!favorited && (
<motion.div
transition={{ duration: 1 }}
animate={{ color: "yellow", fill: "yellow" }}
>
<Star className="mr-2" size="16" />
</motion.div>
)}
{favorited && "Unf"}
{!favorited && "F"}avorite Server
</LoadingButton>
</SignedIn>
</CardContent>
<CardFooter>
<small>
This is unlike voting. The{" "}
<i>amount of people who favorited are public</i>, but the server
doesn{"'"}t know who favorited, as Favorites are completely
anonymous.
</small>
</CardFooter>
</Card>
</div> </div>
</FadeIn> </FadeIn>
</> </>

@ -31,7 +31,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes"; import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types"; import { type ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
@ -45,3 +45,57 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
} }
interface UseThemeTransitionResult {
theme: string | undefined;
changeTheme: (changeTheme: string) => () => void;
mounted: boolean;
}
export function useThemeTransition(): UseThemeTransitionResult {
const { theme, setTheme, systemTheme } = useTheme();
const [mounted, setMounted] = React.useState<boolean>(false);
React.useEffect(() => {
setMounted(true);
}, []);
const changeTheme = (changeTheme: string) => {
if (!mounted) return;
const resolvedTheme = theme === "system" ? systemTheme : changeTheme;
if (document.startViewTransition) {
document.startViewTransition(() => {
const root = document.documentElement;
root.style.setProperty(
"--current-background",
`var(--${resolvedTheme}-background)`
);
root.style.setProperty(
"--current-foreground",
`var(--${resolvedTheme}-foreground)`
);
setTheme(changeTheme);
});
} else {
setTheme(changeTheme);
}
};
React.useEffect(() => {
if (mounted && theme) {
const root = document.documentElement;
root.style.setProperty(
"--current-background",
`var(--${theme}-background)`
);
root.style.setProperty(
"--current-foreground",
`var(--${theme}-foreground)`
);
}
}, [mounted, theme]);
return { theme, changeTheme, mounted };
}

@ -29,10 +29,7 @@
*/ */
"use client"; "use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -41,27 +38,28 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useThemeTransition } from "./ThemeProvider";
export function ModeToggle() { export function ModeToggle() {
const { setTheme } = useTheme(); const { changeTheme } = useThemeTransition();
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="mr-3"> <Button variant="ghost" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}> <DropdownMenuItem onClick={() => changeTheme("light")}>
Light Light
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}> <DropdownMenuItem onClick={() => changeTheme("dark")}>
Dark Dark
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}> <DropdownMenuItem onClick={() => changeTheme("system")}>
System System
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

@ -36,6 +36,7 @@ import { Card, CardContent } from "../ui/card";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import A from "../misc/Link"; import A from "../misc/Link";
import { formalNames } from "@/config/achievements"; import { formalNames } from "@/config/achievements";
import NoItems from "../misc/NoItems";
export default function AchievementList({ server }: { server: string }) { export default function AchievementList({ server }: { server: string }) {
const [achievements, setAchievements] = useState< const [achievements, setAchievements] = useState<
@ -70,6 +71,7 @@ export default function AchievementList({ server }: { server: string }) {
Achievements are earned automatically when the server is online. See{" "} Achievements are earned automatically when the server is online. See{" "}
<A alt="Achievement collection">Docs:Advanced/Achievements</A> <A alt="Achievement collection">Docs:Advanced/Achievements</A>
</span> </span>
{achievements.length === 0 && <NoItems />}
{achievements {achievements
.filter( .filter(
(value, index) => listify(achievements).indexOf(value.type) === index (value, index) => listify(achievements).indexOf(value.type) === index
@ -109,8 +111,6 @@ export default function AchievementList({ server }: { server: string }) {
); );
} }
type WithInterval<K> = K & { type WithInterval<K> = K & {
interval: number; interval: number;
}; };

@ -0,0 +1,57 @@
/*
* 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) 2024 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 QRCode from "react-qr-code";
import { DrawerFooter, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button";
import { useTheme } from "next-themes";
export default function QRCodeGenerator({ server }: { server: string }) {
const { resolvedTheme } = useTheme();
return (
<div className="w-full">
<QRCode
value={"https://mhsf.app/server/" + server + "?source=qrCode"}
className="flex flex-col items-center w-full py-4"
style={{
backgroundColor: resolvedTheme === "dark" ? "#fff" : undefined,
}}
/>
<DrawerFooter>
<DrawerTrigger asChild>
<Button>Close</Button>
</DrawerTrigger>
</DrawerFooter>
</div>
);
}

@ -0,0 +1,48 @@
/*
* 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) 2024 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 { DatabaseZap } from "lucide-react";
export default function NoItems() {
return (
<>
<div className="flex flex-col items-center justify-center p-4 pt-10">
<DatabaseZap
className="text-2xl font-semibold text-gray-600"
size={32}
/>
<p className="text-xl text-gray-600 mt-2">
Huh, we tried to find something, but nothing was found.
</p>
</div>
</>
);
}

@ -50,6 +50,8 @@ const buttonVariants = cva(
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground focus:ring-4 focus:ring-neutral-100 focus:ring-offset-current dark:focus:ring-neutral-900 duration-150 ease-in-out transition-all", "hover:bg-accent hover:text-accent-foreground focus:ring-4 focus:ring-neutral-100 focus:ring-offset-current dark:focus:ring-neutral-900 duration-150 ease-in-out transition-all",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
favorite:
"text-black rounded-lg hover:bg-primary/90 focus:ring-4 focus:ring-yellow-400/60 focus:ring-offset-current dark:focus:ring-yellow-400/60 duration-150 ease-in-out transition-all bg-gradient-to-bl from-yellow-300 via-yellow-500 to-yellow-100",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-10 px-4 py-2",

@ -55,8 +55,30 @@ const FeatureList = ({
); );
}; };
export const version = "1.4.0"; export const version = "1.6.0";
export const changelog: { name: string; id: string; changelog: ReactNode }[] = [ export const changelog: { name: string; id: string; changelog: ReactNode }[] = [
{
id: "h9jr2cbxn7qwfvt5uypsdg",
name: "v1.6.0",
changelog: (
<FeatureList
features={[
"Completely redid top of server view",
"Favorite counts are now prominent on the server view",
"New theme transition (smooth)",
"New favorite button",
"Added more padding in the server view",
"Separated the tabs on the side for sharing actions",
"Added new QR code generator",
]}
title={
<strong className="flex items-center">
Version 1.6.0 (November 17th 2024)
</strong>
}
/>
),
},
{ {
id: "r9swempc7kaqd2j84nutv5", id: "r9swempc7kaqd2j84nutv5",
name: "v1.5.0", name: "v1.5.0",

36
src/lib/head.ts Normal file

@ -0,0 +1,36 @@
/*
* 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) 2024 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.
*/
export async function getMinecraftHead(username: string) {
const uuidRequest = await fetch("https://api.mojang.com/users/profiles/minecraft/" + username);
const uuid = (await uuidRequest.json()).id;
return `https://crafatar.com/avatars/${uuid}`;
}

@ -6847,6 +6847,11 @@ punycode@^2.1.0, punycode@^2.3.0:
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
qr.js@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f"
integrity sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==
queue-microtask@^1.2.2: queue-microtask@^1.2.2:
version "1.2.3" version "1.2.3"
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
@ -6923,6 +6928,14 @@ react-markdown@^9.0.1:
unist-util-visit "^5.0.0" unist-util-visit "^5.0.0"
vfile "^6.0.0" vfile "^6.0.0"
react-qr-code@^2.0.15:
version "2.0.15"
resolved "https://registry.yarnpkg.com/react-qr-code/-/react-qr-code-2.0.15.tgz#fbfc12952c504bcd64275647e9d1ea63251742ce"
integrity sha512-MkZcjEXqVKqXEIMVE0mbcGgDpkfSdd8zhuzXEl9QzYeNcw8Hq2oVIzDLWuZN2PQBwM5PWjc2S31K8Q1UbcFMfw==
dependencies:
prop-types "^15.8.1"
qr.js "0.0.0"
react-remove-scroll-bar@^2.3.3, react-remove-scroll-bar@^2.3.4, react-remove-scroll-bar@^2.3.6: react-remove-scroll-bar@^2.3.3, react-remove-scroll-bar@^2.3.4, react-remove-scroll-bar@^2.3.6:
version "2.3.6" version "2.3.6"
resolved "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz" resolved "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz"