feat: add Minecraft server page with copyable address, map integration, and supporting UI components
This commit is contained in:
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user