feat: add Minecraft server page with copyable address, map integration, and supporting UI components

This commit is contained in:
2026-06-26 18:44:02 +07:00 Unverified
parent 5121229b93
commit 91e9d9c54e
9 changed files with 225 additions and 1 deletions
+8
View File
@@ -3,6 +3,14 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AdminSidebar } from "./client";
import { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "Admin",
template: "%s | Erika!",
},
}
export default async function AdminLayout({
children,
+20 -1
View File
@@ -4,11 +4,30 @@ import { FormLayoutClient } from "./client";
import LoginDialog from "@/components/login-dialog";
import { Metadata } from "next";
const siteUrl = process.env.BASE_URL;
export const metadata: Metadata = {
title: {
default: "Erika! - กรอกแบบฟอร์ม",
default: "กรอกแบบฟอร์ม",
template: "%s | Erika!",
},
openGraph: {
type: "website",
locale: "th_TH",
url: siteUrl,
siteName: "Form",
title: "กรอกแบบฟอร์ม",
description:
"Custom form ของเว็บไซด์",
images: [
{
url: "/avatar.webp",
width: 512,
height: 512,
alt: "Erika! avatar",
},
],
},
}
export default async function FormLayout({
+12
View File
@@ -0,0 +1,12 @@
"use client";
export function MapIframe() {
return (
<iframe
src="https://file.gunshiz.top/erika/minecraft/ErikaSMP"
className="w-full h-full border-0"
title="Minecraft Server View"
allowFullScreen
/>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { Metadata } from "next";
const siteUrl = process.env.BASE_URL;
export const metadata: Metadata = {
title: {
default: "Erika's SMP",
template: "%s | Erika!",
},
openGraph: {
type: "website",
locale: "th_TH",
url: siteUrl,
siteName: "Erika's SMP",
title: "หน้าแรกของ Erika's SMP website",
description:
"Minecraft server ของช่อง Erika",
images: [
{
url: "/avatar.webp",
width: 512,
height: 512,
alt: "Erika! avatar",
},
],
},
}
export default function MinecraftLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
+47
View File
@@ -0,0 +1,47 @@
import { CopyButton } from "@/components/copybutton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MapIframe } from "./client";
import { Kbd } from "@/components/ui/kbd"
export default function MinecraftJoinPage() {
return (
<div className="container max-w-4xl py-10 mx-auto">
<Card className="mb-8">
<CardHeader>
<CardTitle>Erika's SMP</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/50">
<div>
<p className="text-sm font-medium text-muted-foreground">Server Address</p>
<p className="font-mono text-lg">erique.sudloh.com</p>
</div>
<CopyButton text="erique.sudloh.com" iconOnly={false} variant="outline">
คัดลอง Ip
</CopyButton>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/50">
<div>
<p className="text-sm font-medium text-muted-foreground">Port</p> <Kbd> ใช้แค่ Bedrock </Kbd>
<p className="font-mono text-lg">30652</p>
</div>
<CopyButton text="30652" iconOnly={false} variant="outline">
คัดลอง Port
</CopyButton>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>หลังเข้าเซิร์ฟต้องทำยังไง?</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-lg border aspect-video bg-muted">
<MapIframe />
</div>
</CardContent>
</Card>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import React, { useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/utils";
import { cn } from "@/lib/utils";
export interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text?: string | (() => string);
onCopy?: () => void;
iconOnly?: boolean;
variant?: any;
}
export const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
({ text, onCopy, iconOnly = true, className, variant = "link", children, onClick, ...props }, ref) => {
const [copied, setCopied] = useState(false);
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (text) {
const copyContent = typeof text === "function" ? text() : text;
copyToClipboard(copyContent);
}
if (onCopy) {
onCopy();
}
if (onClick) {
onClick(e);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Button
ref={ref}
onClick={handleCopy}
variant={variant}
className={cn(iconOnly ? "h-auto p-0 text-green-400 hover:text-green-300" : "", className)}
{...props}
>
{copied ? <Check className={iconOnly ? "w-3 h-3" : "w-4 h-4 mr-2 text-green-400"} /> : <Copy className={iconOnly ? "w-3 h-3" : "w-4 h-4 mr-2"} />}
{!iconOnly && children}
</Button>
);
}
);
CopyButton.displayName = "CopyButton";
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+10
View File
@@ -8,3 +8,13 @@ export function cn(...inputs: ClassValue[]) {
export const stripHtml = (html: string) => {
return (html || "").replace(/<[^>]*>?/gm, '').trim();
};
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (error) {
console.error("Copy error:", error);
return false;
}
};
+13
View File
@@ -10,6 +10,19 @@ const nextConfig: NextConfig = {
},
],
},
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: "frame-src 'self' https://file.gunshiz.top; ",
},
],
},
];
},
};
export default nextConfig;