mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 18:59:25 +00:00
✨ feat: refine server management workflows
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { HelpTooltip } from "@/components/form-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Field } from "./fields";
|
||||
import type { ServerFormPanelProps } from "./types";
|
||||
|
||||
interface AdvancedPanelProps extends ServerFormPanelProps {
|
||||
addEnvVar: () => void;
|
||||
removeEnvVar: (index: number) => void;
|
||||
updateEnvVar: (index: number, field: "key" | "value", value: string) => void;
|
||||
}
|
||||
|
||||
export function AdvancedPanel({
|
||||
formData,
|
||||
updateField,
|
||||
addEnvVar,
|
||||
removeEnvVar,
|
||||
updateEnvVar,
|
||||
}: AdvancedPanelProps) {
|
||||
return (
|
||||
<TabsContent value="advanced" className="space-y-4 mt-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field id="timezone" label="Timezone" tooltip="Timezone for server logs and scheduling (e.g., America/New_York)">
|
||||
<Input
|
||||
id="timezone"
|
||||
value={formData.timezone}
|
||||
onChange={(event) => updateField("timezone", event.target.value)}
|
||||
placeholder="UTC"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="uid" label="User ID (UID)" tooltip="Linux user ID for the Minecraft process">
|
||||
<Input
|
||||
id="uid"
|
||||
value={formData.uid}
|
||||
onChange={(event) => updateField("uid", event.target.value)}
|
||||
placeholder="1000"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="gid" label="Group ID (GID)" tooltip="Linux group ID for the Minecraft process">
|
||||
<Input
|
||||
id="gid"
|
||||
value={formData.gid}
|
||||
onChange={(event) => updateField("gid", event.target.value)}
|
||||
placeholder="1000"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
id="stopDuration"
|
||||
label="Graceful Shutdown Timeout (seconds)"
|
||||
tooltip="How long to wait for graceful shutdown before forcing stop"
|
||||
>
|
||||
<Input
|
||||
id="stopDuration"
|
||||
type="number"
|
||||
value={formData.stopDuration}
|
||||
onChange={(event) => updateField("stopDuration", event.target.value)}
|
||||
min="1"
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Custom Environment Variables
|
||||
<HelpTooltip content="Additional environment variables to pass to the container" />
|
||||
</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addEnvVar}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
{formData.envVars.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{formData.envVars.map((envVar, index) => (
|
||||
<div key={envVar.id} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="KEY"
|
||||
value={envVar.key}
|
||||
onChange={(event) => updateEnvVar(index, "key", event.target.value)}
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={envVar.value}
|
||||
onChange={(event) => updateEnvVar(index, "value", event.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => removeEnvVar(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { FormNotice } from "@/components/form-controls";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckboxField, Field } from "./fields";
|
||||
import type { ServerFormPanelProps } from "./types";
|
||||
|
||||
export function AutomationPanel({ formData, updateField }: ServerFormPanelProps) {
|
||||
return (
|
||||
<TabsContent value="automation" className="space-y-4 mt-4">
|
||||
{formData.type === "CUSTOM" && (
|
||||
<FormNotice>
|
||||
Automation hooks depend on container scripts; custom jars may ignore them.
|
||||
</FormNotice>
|
||||
)}
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<AccordionItem value="rcon">
|
||||
<AccordionTrigger>RCON Configuration</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<CheckboxField
|
||||
id="enableRcon"
|
||||
checked={formData.enableRcon}
|
||||
onCheckedChange={(value) => updateField("enableRcon", value)}
|
||||
tooltip="Remote console for server management. Enabled by default for graceful shutdown."
|
||||
>
|
||||
Enable RCON
|
||||
</CheckboxField>
|
||||
{formData.enableRcon && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field id="rconPassword" label="RCON Password">
|
||||
<Input
|
||||
id="rconPassword"
|
||||
type="password"
|
||||
value={formData.rconPassword || ""}
|
||||
onChange={(event) => updateField("rconPassword", event.target.value)}
|
||||
placeholder="Auto-generated if empty"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="rconPort" label="RCON Port">
|
||||
<Input
|
||||
id="rconPort"
|
||||
type="number"
|
||||
value={formData.rconPort}
|
||||
onChange={(event) => updateField("rconPort", event.target.value)}
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
id="rconCmdsStartup"
|
||||
label="RCON Commands on Startup"
|
||||
tooltip="Semicolon-separated commands to run when server starts"
|
||||
>
|
||||
<Textarea
|
||||
id="rconCmdsStartup"
|
||||
value={formData.rconCmdsStartup || ""}
|
||||
onChange={(event) => updateField("rconCmdsStartup", event.target.value)}
|
||||
placeholder="say Server starting...;gamerule doDaylightCycle false"
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<Field id="rconCmdsOnConnect" label="Commands on Player Connect">
|
||||
<Textarea
|
||||
id="rconCmdsOnConnect"
|
||||
value={formData.rconCmdsOnConnect || ""}
|
||||
onChange={(event) => updateField("rconCmdsOnConnect", event.target.value)}
|
||||
placeholder="tell {{player}} Welcome to the server!"
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="query">
|
||||
<AccordionTrigger>Query Protocol</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<CheckboxField
|
||||
id="enableQuery"
|
||||
checked={formData.enableQuery}
|
||||
onCheckedChange={(value) => updateField("enableQuery", value)}
|
||||
tooltip="Allows external tools to query server status and player list"
|
||||
>
|
||||
Enable Query Protocol
|
||||
</CheckboxField>
|
||||
{formData.enableQuery && (
|
||||
<Field id="queryPort" label="Query Port">
|
||||
<Input
|
||||
id="queryPort"
|
||||
type="number"
|
||||
value={formData.queryPort}
|
||||
onChange={(event) => updateField("queryPort", event.target.value)}
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="autopause">
|
||||
<AccordionTrigger>Auto-Pause</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<CheckboxField
|
||||
id="enableAutopause"
|
||||
checked={formData.enableAutopause}
|
||||
onCheckedChange={(value) => updateField("enableAutopause", value)}
|
||||
tooltip="Automatically pauses the server when no players are online to save resources"
|
||||
>
|
||||
Enable Auto-Pause
|
||||
</CheckboxField>
|
||||
{formData.enableAutopause && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field id="autopauseTimeoutEst" label="Timeout After Disconnect (seconds)">
|
||||
<Input
|
||||
id="autopauseTimeoutEst"
|
||||
type="number"
|
||||
value={formData.autopauseTimeoutEst}
|
||||
onChange={(event) => updateField("autopauseTimeoutEst", event.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="autopauseTimeoutInit" label="Timeout After Startup (seconds)">
|
||||
<Input
|
||||
id="autopauseTimeoutInit"
|
||||
type="number"
|
||||
value={formData.autopauseTimeoutInit}
|
||||
onChange={(event) => updateField("autopauseTimeoutInit", event.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field id="autopauseKnockInterface" label="Network Interface">
|
||||
<Input
|
||||
id="autopauseKnockInterface"
|
||||
value={formData.autopauseKnockInterface}
|
||||
onChange={(event) => updateField("autopauseKnockInterface", event.target.value)}
|
||||
placeholder="eth0"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="autostop">
|
||||
<AccordionTrigger>Auto-Stop</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<CheckboxField
|
||||
id="enableAutostop"
|
||||
checked={formData.enableAutostop}
|
||||
onCheckedChange={(value) => updateField("enableAutostop", value)}
|
||||
tooltip="Automatically stops the server when no players are online"
|
||||
>
|
||||
Enable Auto-Stop
|
||||
</CheckboxField>
|
||||
{formData.enableAutostop && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field id="autostopTimeoutEst" label="Timeout After Player Departure (seconds)">
|
||||
<Input
|
||||
id="autostopTimeoutEst"
|
||||
type="number"
|
||||
value={formData.autostopTimeoutEst}
|
||||
onChange={(event) => updateField("autostopTimeoutEst", event.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="autostopTimeoutInit" label="Timeout After Launch (seconds)">
|
||||
<Input
|
||||
id="autostopTimeoutInit"
|
||||
type="number"
|
||||
value={formData.autostopTimeoutInit}
|
||||
onChange={(event) => updateField("autostopTimeoutInit", event.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckboxField, Field } from "./fields";
|
||||
import type { ServerFormPanelProps } from "./types";
|
||||
|
||||
export function BasicPanel({ formData, updateField }: ServerFormPanelProps) {
|
||||
return (
|
||||
<TabsContent value="basic" className="space-y-4 mt-4">
|
||||
<Field id="id" label="Server ID *">
|
||||
<Input
|
||||
id="id"
|
||||
value={formData.id}
|
||||
onChange={(event) => updateField("id", event.target.value)}
|
||||
placeholder="my-server"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field id="description" label="Description">
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(event) => updateField("description", event.target.value)}
|
||||
placeholder="A brief description of this server"
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Field
|
||||
id="memoryRequest"
|
||||
label="Memory Request (MB) *"
|
||||
tooltip="Kubernetes guaranteed memory. Java heap sizes from the limit."
|
||||
>
|
||||
<Input
|
||||
id="memoryRequest"
|
||||
type="number"
|
||||
value={formData.memoryRequest}
|
||||
onChange={(event) => updateField("memoryRequest", event.target.value)}
|
||||
min="256"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="memoryLimit"
|
||||
label="Memory Limit (MB) *"
|
||||
tooltip="Kubernetes max memory. Java heap scales from this."
|
||||
>
|
||||
<Input
|
||||
id="memoryLimit"
|
||||
type="number"
|
||||
value={formData.memoryLimit}
|
||||
onChange={(event) => updateField("memoryLimit", event.target.value)}
|
||||
min="256"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="cpuRequest"
|
||||
label="CPU Request *"
|
||||
tooltip="Guaranteed CPU allocation (e.g., 500m = 0.5 cores)"
|
||||
>
|
||||
<Input
|
||||
id="cpuRequest"
|
||||
value={formData.cpuRequest}
|
||||
onChange={(event) => updateField("cpuRequest", event.target.value)}
|
||||
placeholder="500m"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="cpuLimit" label="CPU Limit *" tooltip="Maximum CPU the server can use">
|
||||
<Input
|
||||
id="cpuLimit"
|
||||
value={formData.cpuLimit}
|
||||
onChange={(event) => updateField("cpuLimit", event.target.value)}
|
||||
placeholder="2"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="advanced-memory">
|
||||
<AccordionTrigger>Java Heap Overrides</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 text-sm text-muted-foreground">
|
||||
Heap sizes are derived from the memory limit. Override only if needed.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<CheckboxField
|
||||
id="eula"
|
||||
checked={formData.eula}
|
||||
onCheckedChange={(checked) => updateField("eula", checked)}
|
||||
labelClassName="cursor-pointer"
|
||||
>
|
||||
I accept the{" "}
|
||||
<a
|
||||
href="https://www.minecraft.net/eula"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Minecraft EULA
|
||||
</a>{" "}
|
||||
*
|
||||
</CheckboxField>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { HelpTooltip } from "@/components/form-controls";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface FieldProps {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
tooltip?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Field({ id, label, tooltip, children }: FieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>
|
||||
{label}
|
||||
{tooltip && <HelpTooltip content={tooltip} />}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
children: ReactNode;
|
||||
tooltip?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
export function CheckboxField({
|
||||
id,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
children,
|
||||
tooltip,
|
||||
labelClassName,
|
||||
}: CheckboxFieldProps) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => onCheckedChange(value as boolean)}
|
||||
/>
|
||||
<Label htmlFor={id} className={labelClassName}>
|
||||
{children}
|
||||
{tooltip && <HelpTooltip content={tooltip} />}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FormNotice } from "@/components/form-controls";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckboxField, Field } from "./fields";
|
||||
import type { ServerFormPanelProps } from "./types";
|
||||
|
||||
export function ModsPanel({ formData, updateField }: ServerFormPanelProps) {
|
||||
return (
|
||||
<TabsContent value="mods" className="space-y-4 mt-4">
|
||||
{formData.type === "CUSTOM" && (
|
||||
<FormNotice>Mods/plugins automation is intended for Vanilla/Paper workflows.</FormNotice>
|
||||
)}
|
||||
<Field id="plugins" label="Plugins" tooltip="Comma-separated list of plugin URLs or filenames">
|
||||
<Textarea
|
||||
id="plugins"
|
||||
value={formData.plugins || ""}
|
||||
onChange={(event) => updateField("plugins", event.target.value)}
|
||||
placeholder="https://example.com/plugin1.jar,plugin2.jar"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For Paper/Spigot/Bukkit servers. URLs or local filenames.
|
||||
</p>
|
||||
</Field>
|
||||
<Field
|
||||
id="spigetResources"
|
||||
label="Spiget Resource IDs"
|
||||
tooltip="Comma-separated Spiget resource IDs to auto-download from SpigotMC"
|
||||
>
|
||||
<Input
|
||||
id="spigetResources"
|
||||
value={formData.spigetResources || ""}
|
||||
onChange={(event) => updateField("spigetResources", event.target.value)}
|
||||
placeholder="1234,5678,9012"
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-3">
|
||||
<CheckboxField
|
||||
id="removeOldPlugins"
|
||||
checked={formData.removeOldPlugins}
|
||||
onCheckedChange={(value) => updateField("removeOldPlugins", value)}
|
||||
tooltip="Automatically remove old versions of plugins when updating"
|
||||
>
|
||||
Remove Old Plugins
|
||||
</CheckboxField>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { FormNotice } from "@/components/form-controls";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Field } from "./fields";
|
||||
import type { ServerFormPanelProps, ServiceType } from "./types";
|
||||
|
||||
export function NetworkPanel({ formData, updateField }: ServerFormPanelProps) {
|
||||
return (
|
||||
<TabsContent value="network" className="space-y-4 mt-4">
|
||||
{formData.type === "CUSTOM" && (
|
||||
<FormNotice>
|
||||
Network settings apply at the container level and still work with custom jars.
|
||||
</FormNotice>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field id="listenPort" label="Listen Port">
|
||||
<Input
|
||||
id="listenPort"
|
||||
type="number"
|
||||
value={formData.listenPort}
|
||||
onChange={(event) => updateField("listenPort", event.target.value)}
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="serviceType" label="Service Type" tooltip="How the server is exposed in Kubernetes">
|
||||
<Select
|
||||
value={formData.serviceType}
|
||||
onValueChange={(value) => updateField("serviceType", value as ServiceType)}
|
||||
>
|
||||
<SelectTrigger id="serviceType"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CLUSTER_IP">ClusterIP (Internal Only)</SelectItem>
|
||||
<SelectItem value="NODE_PORT">NodePort (External Access)</SelectItem>
|
||||
<SelectItem value="LOAD_BALANCER">LoadBalancer (Cloud/MetalLB)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
{formData.serviceType === "NODE_PORT" && (
|
||||
<Field
|
||||
id="nodePort"
|
||||
label="Node Port"
|
||||
tooltip="Specific port on the node (30000-32767). Leave empty for auto-assignment."
|
||||
>
|
||||
<Input
|
||||
id="nodePort"
|
||||
type="number"
|
||||
value={formData.nodePort || ""}
|
||||
onChange={(event) => updateField("nodePort", event.target.value)}
|
||||
placeholder="30000-32767"
|
||||
min="30000"
|
||||
max="32767"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { FormNotice } from "@/components/form-controls";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckboxField, Field } from "./fields";
|
||||
import type { ServerFormPanelProps } from "./types";
|
||||
|
||||
export function PerformancePanel({ formData, updateField }: ServerFormPanelProps) {
|
||||
return (
|
||||
<TabsContent value="performance" className="space-y-4 mt-4">
|
||||
{formData.type === "CUSTOM" && (
|
||||
<FormNotice>
|
||||
Custom jars may not honor built-in tuning flags. Use custom env vars if required.
|
||||
</FormNotice>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<CheckboxField
|
||||
id="useAikarFlags"
|
||||
checked={formData.useAikarFlags}
|
||||
onCheckedChange={(value) => updateField("useAikarFlags", value)}
|
||||
tooltip="Optimized G1GC garbage collection flags. Recommended for servers with 1GB+ RAM."
|
||||
>
|
||||
Use Aikar's Flags
|
||||
</CheckboxField>
|
||||
<CheckboxField
|
||||
id="useMeowiceFlags"
|
||||
checked={formData.useMeowiceFlags}
|
||||
onCheckedChange={(value) => updateField("useMeowiceFlags", value)}
|
||||
tooltip="Modern optimization flags for Java 17+. Based on Aikar's flags with improvements."
|
||||
>
|
||||
Use Meowice Flags
|
||||
</CheckboxField>
|
||||
<CheckboxField
|
||||
id="enableJmx"
|
||||
checked={formData.enableJmx}
|
||||
onCheckedChange={(value) => updateField("enableJmx", value)}
|
||||
tooltip="Enables remote JMX monitoring for advanced profiling"
|
||||
>
|
||||
Enable JMX
|
||||
</CheckboxField>
|
||||
</div>
|
||||
<Field
|
||||
id="jvmOpts"
|
||||
label="Custom JVM Options"
|
||||
tooltip="Space-separated JVM arguments (e.g., -Xms2G -Xmx4G)"
|
||||
>
|
||||
<Textarea
|
||||
id="jvmOpts"
|
||||
value={formData.jvmOpts || ""}
|
||||
onChange={(event) => updateField("jvmOpts", event.target.value)}
|
||||
placeholder="-XX:+UseG1GC -XX:MaxGCPauseMillis=200"
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field id="jvmXxOpts" label="JVM -XX Options" tooltip="Space-separated -XX JVM flags for advanced tuning">
|
||||
<Textarea
|
||||
id="jvmXxOpts"
|
||||
value={formData.jvmXxOpts || ""}
|
||||
onChange={(event) => updateField("jvmXxOpts", event.target.value)}
|
||||
placeholder="+UnlockExperimentalVMOptions +UseZGC"
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<Field id="jvmDdOpts" label="JVM -D System Properties" tooltip="Comma-separated key=value pairs for system properties">
|
||||
<Textarea
|
||||
id="jvmDdOpts"
|
||||
value={formData.jvmDdOpts || ""}
|
||||
onChange={(event) => updateField("jvmDdOpts", event.target.value)}
|
||||
placeholder="property1=value1,property2=value2"
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { FormNotice } from "@/components/form-controls";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckboxField, Field } from "./fields";
|
||||
import type { ServerFormPanelProps } from "./types";
|
||||
|
||||
export function PlayersPanel({ formData, updateField }: ServerFormPanelProps) {
|
||||
return (
|
||||
<TabsContent value="players" className="space-y-4 mt-4">
|
||||
{formData.type === "CUSTOM" && (
|
||||
<FormNotice>Some custom jars do not support managed whitelist/ops settings.</FormNotice>
|
||||
)}
|
||||
<CheckboxField
|
||||
id="enableWhitelist"
|
||||
checked={formData.enableWhitelist}
|
||||
onCheckedChange={(value) => updateField("enableWhitelist", value)}
|
||||
tooltip="Only whitelisted players can join the server"
|
||||
>
|
||||
Enable Whitelist
|
||||
</CheckboxField>
|
||||
{formData.enableWhitelist && (
|
||||
<>
|
||||
<Field
|
||||
id="whitelist"
|
||||
label="Whitelisted Players"
|
||||
tooltip="Comma-separated list of usernames or UUIDs"
|
||||
>
|
||||
<Textarea
|
||||
id="whitelist"
|
||||
value={formData.whitelist || ""}
|
||||
onChange={(event) => updateField("whitelist", event.target.value)}
|
||||
placeholder="Player1,Player2,UUID-here"
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="whitelistFile"
|
||||
label="Whitelist File URL"
|
||||
tooltip="URL or path to a whitelist.json file. Overrides the whitelist field."
|
||||
>
|
||||
<Input
|
||||
id="whitelistFile"
|
||||
value={formData.whitelistFile || ""}
|
||||
onChange={(event) => updateField("whitelistFile", event.target.value)}
|
||||
placeholder="https://example.com/whitelist.json"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field
|
||||
id="ops"
|
||||
label="Operators (Ops)"
|
||||
tooltip="Comma-separated list of usernames or UUIDs with full server permissions"
|
||||
>
|
||||
<Textarea
|
||||
id="ops"
|
||||
value={formData.ops || ""}
|
||||
onChange={(event) => updateField("ops", event.target.value)}
|
||||
placeholder="Admin1,Admin2"
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field id="opsFile" label="Ops File URL">
|
||||
<Input
|
||||
id="opsFile"
|
||||
value={formData.opsFile || ""}
|
||||
onChange={(event) => updateField("opsFile", event.target.value)}
|
||||
placeholder="https://example.com/ops.json"
|
||||
/>
|
||||
</Field>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { FormNotice } from "@/components/form-controls";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { CheckboxField, Field } from "./fields";
|
||||
import type { ServerFormPanelProps } from "./types";
|
||||
|
||||
export function ResourcesPanel({ formData, updateField }: ServerFormPanelProps) {
|
||||
return (
|
||||
<TabsContent value="resources" className="space-y-4 mt-4">
|
||||
{formData.type === "CUSTOM" && (
|
||||
<FormNotice>Resource pack settings may not apply to custom jars.</FormNotice>
|
||||
)}
|
||||
<Field id="resourcePack" label="Resource Pack URL" tooltip="URL or path to a resource pack ZIP file">
|
||||
<Input
|
||||
id="resourcePack"
|
||||
value={formData.resourcePack || ""}
|
||||
onChange={(event) => updateField("resourcePack", event.target.value)}
|
||||
placeholder="https://example.com/resourcepack.zip"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="resourcePackSha1" label="Resource Pack SHA1" tooltip="SHA1 checksum of the resource pack for verification">
|
||||
<Input
|
||||
id="resourcePackSha1"
|
||||
value={formData.resourcePackSha1 || ""}
|
||||
onChange={(event) => updateField("resourcePackSha1", event.target.value)}
|
||||
placeholder="a1b2c3d4e5f6..."
|
||||
/>
|
||||
</Field>
|
||||
<CheckboxField
|
||||
id="resourcePackEnforce"
|
||||
checked={formData.resourcePackEnforce}
|
||||
onCheckedChange={(value) => updateField("resourcePackEnforce", value)}
|
||||
tooltip="Require players to accept the resource pack to join"
|
||||
>
|
||||
Enforce Resource Pack
|
||||
</CheckboxField>
|
||||
<Field id="serverIcon" label="Server Icon URL" tooltip="URL or path to a server icon image (PNG, 64x64 recommended)">
|
||||
<Input
|
||||
id="serverIcon"
|
||||
value={formData.serverIcon || ""}
|
||||
onChange={(event) => updateField("serverIcon", event.target.value)}
|
||||
placeholder="https://example.com/icon.png"
|
||||
/>
|
||||
</Field>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { FormNotice } from "@/components/form-controls";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckboxField, Field } from "./fields";
|
||||
import type { ServerFormData, ServerFormPanelProps, ServerType } from "./types";
|
||||
|
||||
const toMode = (value: string): ServerFormData["mode"] => {
|
||||
if (value === "creative" || value === "adventure" || value === "spectator") return value;
|
||||
return "survival";
|
||||
};
|
||||
|
||||
const toDifficulty = (value: string): ServerFormData["difficulty"] => {
|
||||
if (value === "peaceful" || value === "normal" || value === "hard") return value;
|
||||
return "easy";
|
||||
};
|
||||
|
||||
export function ServerPanel({ formData, updateField }: ServerFormPanelProps) {
|
||||
return (
|
||||
<TabsContent value="server" className="space-y-4 mt-4">
|
||||
{formData.type === "CUSTOM" && (
|
||||
<FormNotice>
|
||||
Custom jars may ignore built-in server settings. Configure extra values via custom
|
||||
environment variables if needed.
|
||||
</FormNotice>
|
||||
)}
|
||||
<Field
|
||||
id="type"
|
||||
label="Server Type *"
|
||||
tooltip="Choose the server software (Vanilla, Paper, or Custom Jar)."
|
||||
>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value) => updateField("type", value as ServerType)}
|
||||
>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="VANILLA">Vanilla (Official Mojang)</SelectItem>
|
||||
<SelectItem value="PAPER">Paper (Recommended - High Performance)</SelectItem>
|
||||
<SelectItem value="CUSTOM">Custom Jar</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{formData.type !== "CUSTOM" && (
|
||||
<Field
|
||||
id="version"
|
||||
label="Minecraft Version"
|
||||
tooltip="Use LATEST for newest stable, SNAPSHOT for snapshots, or specific version like 1.20.4"
|
||||
>
|
||||
<Input
|
||||
id="version"
|
||||
value={formData.version || ""}
|
||||
onChange={(event) => updateField("version", event.target.value)}
|
||||
placeholder="LATEST"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{formData.type === "PAPER" && (
|
||||
<Field id="paperBuild" label="Paper Build Number">
|
||||
<Input
|
||||
id="paperBuild"
|
||||
value={formData.paperBuild || ""}
|
||||
onChange={(event) => updateField("paperBuild", event.target.value)}
|
||||
placeholder="Leave empty for latest"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{formData.type === "CUSTOM" && (
|
||||
<Field id="customJarUrl" label="Custom Server Jar URL/Path">
|
||||
<Input
|
||||
id="customJarUrl"
|
||||
value={formData.customJarUrl || ""}
|
||||
onChange={(event) => updateField("customJarUrl", event.target.value)}
|
||||
placeholder="https://example.com/server.jar"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sets CUSTOM_SERVER for the itzg/minecraft-server image.
|
||||
</p>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field id="motd" label="Message of the Day (MOTD)">
|
||||
<Textarea
|
||||
id="motd"
|
||||
value={formData.motd || ""}
|
||||
onChange={(event) => updateField("motd", event.target.value)}
|
||||
placeholder="A Minecraft Server"
|
||||
rows={2}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Supports formatting codes like §l for bold, §c for red
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field id="mode" label="Game Mode">
|
||||
<Select value={formData.mode} onValueChange={(value) => updateField("mode", toMode(value))}>
|
||||
<SelectTrigger id="mode"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="survival">Survival</SelectItem>
|
||||
<SelectItem value="creative">Creative</SelectItem>
|
||||
<SelectItem value="adventure">Adventure</SelectItem>
|
||||
<SelectItem value="spectator">Spectator</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field id="difficulty" label="Difficulty">
|
||||
<Select
|
||||
value={formData.difficulty}
|
||||
onValueChange={(value) => updateField("difficulty", toDifficulty(value))}
|
||||
>
|
||||
<SelectTrigger id="difficulty"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="peaceful">Peaceful</SelectItem>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
<SelectItem value="hard">Hard</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field id="maxPlayers" label="Max Players">
|
||||
<Input
|
||||
id="maxPlayers"
|
||||
type="number"
|
||||
value={formData.maxPlayers}
|
||||
onChange={(event) => updateField("maxPlayers", event.target.value)}
|
||||
min="1"
|
||||
max="1000"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="viewDistance" label="View Distance (chunks)">
|
||||
<Input
|
||||
id="viewDistance"
|
||||
type="number"
|
||||
value={formData.viewDistance}
|
||||
onChange={(event) => updateField("viewDistance", event.target.value)}
|
||||
min="3"
|
||||
max="32"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<CheckboxField id="pvp" checked={formData.pvp} onCheckedChange={(value) => updateField("pvp", value)}>
|
||||
Enable PvP (Player vs Player)
|
||||
</CheckboxField>
|
||||
<CheckboxField id="onlineMode" checked={formData.onlineMode} onCheckedChange={(value) => updateField("onlineMode", value)}>
|
||||
Online Mode (Requires authenticated Minecraft accounts)
|
||||
</CheckboxField>
|
||||
<CheckboxField id="allowFlight" checked={formData.allowFlight} onCheckedChange={(value) => updateField("allowFlight", value)}>
|
||||
Allow Flight
|
||||
</CheckboxField>
|
||||
<CheckboxField id="enableCommandBlock" checked={formData.enableCommandBlock} onCheckedChange={(value) => updateField("enableCommandBlock", value)}>
|
||||
Enable Command Blocks
|
||||
</CheckboxField>
|
||||
<CheckboxField id="hardcore" checked={formData.hardcore} onCheckedChange={(value) => updateField("hardcore", value)}>
|
||||
Hardcore Mode (Permanent Death)
|
||||
</CheckboxField>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { GameMode, ServiceType as PrismaServiceType, ServerDifficulty } from "@minikura/db";
|
||||
|
||||
export type ServerType = "VANILLA" | "PAPER" | "SPIGOT" | "PURPUR" | "FABRIC" | "CUSTOM";
|
||||
export type ServiceType = PrismaServiceType;
|
||||
export type Difficulty = ServerDifficulty;
|
||||
export type Mode = GameMode;
|
||||
|
||||
export interface EnvVar {
|
||||
id?: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ServerFormData {
|
||||
id: string;
|
||||
description: string;
|
||||
memoryLimit: string;
|
||||
memoryRequest: string;
|
||||
cpuRequest: string;
|
||||
cpuLimit: string;
|
||||
type: ServerType;
|
||||
version?: string;
|
||||
customJarUrl?: string;
|
||||
eula: boolean;
|
||||
listenPort: string;
|
||||
serviceType: ServiceType;
|
||||
nodePort?: string;
|
||||
motd?: string;
|
||||
difficulty: "peaceful" | "easy" | "normal" | "hard";
|
||||
mode: "survival" | "creative" | "adventure" | "spectator";
|
||||
maxPlayers: string;
|
||||
pvp: boolean;
|
||||
onlineMode: boolean;
|
||||
allowFlight: boolean;
|
||||
enableCommandBlock: boolean;
|
||||
spawnProtection: string;
|
||||
viewDistance: string;
|
||||
simulationDistance: string;
|
||||
levelName: string;
|
||||
levelSeed?: string;
|
||||
levelType?: string;
|
||||
generatorSettings?: string;
|
||||
hardcore: boolean;
|
||||
spawnAnimals: boolean;
|
||||
spawnMonsters: boolean;
|
||||
spawnNpcs: boolean;
|
||||
enableWhitelist: boolean;
|
||||
whitelist?: string;
|
||||
whitelistFile?: string;
|
||||
ops?: string;
|
||||
opsFile?: string;
|
||||
useAikarFlags: boolean;
|
||||
useMeowiceFlags: boolean;
|
||||
jvmOpts?: string;
|
||||
jvmXxOpts?: string;
|
||||
jvmDdOpts?: string;
|
||||
resourcePack?: string;
|
||||
resourcePackSha1?: string;
|
||||
resourcePackEnforce: boolean;
|
||||
enableRcon: boolean;
|
||||
rconPassword?: string;
|
||||
rconPort: string;
|
||||
rconCmdsStartup?: string;
|
||||
rconCmdsOnConnect?: string;
|
||||
rconCmdsFirstConnect?: string;
|
||||
rconCmdsOnDisconnect?: string;
|
||||
rconCmdsLastDisconnect?: string;
|
||||
enableQuery: boolean;
|
||||
queryPort: string;
|
||||
enableAutopause: boolean;
|
||||
autopauseTimeoutEst: string;
|
||||
autopauseTimeoutInit: string;
|
||||
autopauseTimeoutKn: string;
|
||||
autopausePeriod: string;
|
||||
autopauseKnockInterface: string;
|
||||
enableAutostop: boolean;
|
||||
autostopTimeoutEst: string;
|
||||
autostopTimeoutInit: string;
|
||||
autostopPeriod: string;
|
||||
plugins?: string;
|
||||
removeOldPlugins: boolean;
|
||||
spigetResources?: string;
|
||||
paperBuild?: string;
|
||||
timezone: string;
|
||||
uid: string;
|
||||
gid: string;
|
||||
enableJmx: boolean;
|
||||
stopDuration: string;
|
||||
serverIcon?: string;
|
||||
envVars: EnvVar[];
|
||||
}
|
||||
|
||||
export type UpdateServerField = <K extends keyof ServerFormData>(
|
||||
key: K,
|
||||
value: ServerFormData[K],
|
||||
) => void;
|
||||
|
||||
export interface ServerFormPanelProps {
|
||||
formData: ServerFormData;
|
||||
updateField: UpdateServerField;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { FormNotice } from "@/components/form-controls";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { CheckboxField, Field } from "./fields";
|
||||
import type { ServerFormPanelProps } from "./types";
|
||||
|
||||
export function WorldPanel({ formData, updateField }: ServerFormPanelProps) {
|
||||
return (
|
||||
<TabsContent value="world" className="space-y-4 mt-4">
|
||||
{formData.type === "CUSTOM" && (
|
||||
<FormNotice>
|
||||
World options apply to standard server properties and may be ignored by custom jars.
|
||||
</FormNotice>
|
||||
)}
|
||||
<Field id="levelName" label="World Name">
|
||||
<Input
|
||||
id="levelName"
|
||||
value={formData.levelName}
|
||||
onChange={(event) => updateField("levelName", event.target.value)}
|
||||
placeholder="world"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="levelSeed"
|
||||
label="World Seed"
|
||||
tooltip="Leave empty for random generation. Can be numeric or text."
|
||||
>
|
||||
<Input
|
||||
id="levelSeed"
|
||||
value={formData.levelSeed || ""}
|
||||
onChange={(event) => updateField("levelSeed", event.target.value)}
|
||||
placeholder="Leave empty for random"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="levelType" label="Level Type">
|
||||
<Select
|
||||
value={formData.levelType || "default"}
|
||||
onValueChange={(value) => updateField("levelType", value === "default" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger id="levelType"><SelectValue placeholder="Default" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="flat">Flat/Superflat</SelectItem>
|
||||
<SelectItem value="largeBiomes">Large Biomes</SelectItem>
|
||||
<SelectItem value="amplified">Amplified</SelectItem>
|
||||
<SelectItem value="buffet">Buffet (Single Biome)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field
|
||||
id="generatorSettings"
|
||||
label="Generator Settings"
|
||||
tooltip="JSON configuration for custom world generation. Advanced users only."
|
||||
>
|
||||
<Textarea
|
||||
id="generatorSettings"
|
||||
value={formData.generatorSettings || ""}
|
||||
onChange={(event) => updateField("generatorSettings", event.target.value)}
|
||||
placeholder={'{"structures": {...}, "layers": [...]}'}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
id="spawnProtection"
|
||||
label="Spawn Protection (blocks)"
|
||||
tooltip="Radius around spawn where only ops can build. Set to 0 to disable."
|
||||
>
|
||||
<Input
|
||||
id="spawnProtection"
|
||||
type="number"
|
||||
value={formData.spawnProtection}
|
||||
onChange={(event) => updateField("spawnProtection", event.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
id="simulationDistance"
|
||||
label="Simulation Distance"
|
||||
tooltip="Distance in chunks where game logic runs (redstone, crops, etc.)"
|
||||
>
|
||||
<Input
|
||||
id="simulationDistance"
|
||||
type="number"
|
||||
value={formData.simulationDistance}
|
||||
onChange={(event) => updateField("simulationDistance", event.target.value)}
|
||||
min="3"
|
||||
max="32"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<CheckboxField id="spawnAnimals" checked={formData.spawnAnimals} onCheckedChange={(value) => updateField("spawnAnimals", value)}>
|
||||
Spawn Animals
|
||||
</CheckboxField>
|
||||
<CheckboxField id="spawnMonsters" checked={formData.spawnMonsters} onCheckedChange={(value) => updateField("spawnMonsters", value)}>
|
||||
Spawn Monsters
|
||||
</CheckboxField>
|
||||
<CheckboxField id="spawnNpcs" checked={formData.spawnNpcs} onCheckedChange={(value) => updateField("spawnNpcs", value)}>
|
||||
Spawn NPCs (Villagers)
|
||||
</CheckboxField>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user