291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { useTranslations } from "next-intl";
|
|
import { useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { Link, useRouter } from "@/i18n/navigation";
|
|
import { ApiError } from "@/lib/api/auth";
|
|
import { createServer, listPlans } from "@/lib/api/servers";
|
|
import {
|
|
createServerSchema,
|
|
MINECRAFT_VERSIONS,
|
|
SOFTWARE_FAMILIES,
|
|
type CreateServerFormValues,
|
|
} from "@/lib/schemas/server";
|
|
|
|
const inputClassName =
|
|
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
|
|
|
const STEPS = ["basics", "software", "review"] as const;
|
|
type WizardStep = (typeof STEPS)[number];
|
|
|
|
export function CreateServerWizard() {
|
|
const t = useTranslations("servers");
|
|
const tw = useTranslations("servers.wizard");
|
|
const tv = useTranslations("validation");
|
|
const router = useRouter();
|
|
const [step, setStep] = useState<WizardStep>("basics");
|
|
const [apiError, setApiError] = useState<string | null>(null);
|
|
|
|
const { data: plansData, isLoading: plansLoading } = useQuery({
|
|
queryKey: ["plans"],
|
|
queryFn: listPlans,
|
|
retry: false,
|
|
});
|
|
|
|
const schema = createServerSchema({
|
|
nameRequired: tv("serverNameRequired"),
|
|
nameMax: tv("serverNameMax"),
|
|
planRequired: tv("planRequired"),
|
|
planInvalid: tv("planInvalid"),
|
|
versionRequired: tv("versionRequired"),
|
|
softwareRequired: tv("softwareRequired"),
|
|
eulaRequired: tv("eulaRequired"),
|
|
});
|
|
|
|
const form = useForm<CreateServerFormValues>({
|
|
resolver: zodResolver(schema),
|
|
defaultValues: {
|
|
name: "",
|
|
planId: "",
|
|
minecraftVersion: MINECRAFT_VERSIONS[0],
|
|
softwareFamily: "VANILLA",
|
|
eulaAccepted: false,
|
|
},
|
|
mode: "onChange",
|
|
});
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
trigger,
|
|
watch,
|
|
formState: { errors, isSubmitting },
|
|
} = form;
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: createServer,
|
|
onSuccess: (server) => {
|
|
router.push(`/servers/${server.id}`);
|
|
},
|
|
onError: (error: unknown) => {
|
|
setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
|
|
},
|
|
});
|
|
|
|
const plans = plansData?.plans ?? [];
|
|
const values = watch();
|
|
const stepIndex = STEPS.indexOf(step);
|
|
|
|
async function goNext() {
|
|
setApiError(null);
|
|
if (step === "basics") {
|
|
const valid = await trigger(["name", "planId"]);
|
|
if (valid) setStep("software");
|
|
return;
|
|
}
|
|
if (step === "software") {
|
|
const valid = await trigger(["softwareFamily", "minecraftVersion"]);
|
|
if (valid) setStep("review");
|
|
}
|
|
}
|
|
|
|
function goBack() {
|
|
setApiError(null);
|
|
if (step === "software") setStep("basics");
|
|
if (step === "review") setStep("software");
|
|
}
|
|
|
|
async function onSubmit(formValues: CreateServerFormValues) {
|
|
setApiError(null);
|
|
await createMutation.mutateAsync({
|
|
...formValues,
|
|
eulaAccepted: true,
|
|
});
|
|
}
|
|
|
|
const selectedPlan = plans.find((plan) => plan.id === values.planId);
|
|
|
|
return (
|
|
<Card className="max-w-xl">
|
|
<CardHeader>
|
|
<CardTitle>{t("createTitle")}</CardTitle>
|
|
<CardDescription>{t("createDescription")}</CardDescription>
|
|
<ol className="mt-4 flex gap-2" aria-label={tw("stepsLabel")}>
|
|
{STEPS.map((stepKey, index) => (
|
|
<li
|
|
key={stepKey}
|
|
className={[
|
|
"flex-1 rounded-full py-1 text-center text-xs font-medium",
|
|
index <= stepIndex
|
|
? "bg-sky-600 text-white dark:bg-sky-500"
|
|
: "bg-zinc-200 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
|
|
].join(" ")}
|
|
>
|
|
{tw(`steps.${stepKey}`)}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
|
{step === "basics" ? (
|
|
<>
|
|
<div className="space-y-2">
|
|
<label htmlFor="name" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{t("name")}
|
|
</label>
|
|
<input id="name" type="text" className={inputClassName} {...register("name")} />
|
|
{errors.name ? (
|
|
<p className="text-sm text-red-600 dark:text-red-400">{errors.name.message}</p>
|
|
) : null}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label htmlFor="planId" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{t("plan")}
|
|
</label>
|
|
<select
|
|
id="planId"
|
|
className={inputClassName}
|
|
disabled={plansLoading}
|
|
{...register("planId")}
|
|
>
|
|
<option value="">{plansLoading ? t("loading") : t("selectPlan")}</option>
|
|
{plans.map((plan) => (
|
|
<option key={plan.id} value={plan.id}>
|
|
{plan.name} ({plan.maxRamMb} MB)
|
|
</option>
|
|
))}
|
|
</select>
|
|
{errors.planId ? (
|
|
<p className="text-sm text-red-600 dark:text-red-400">{errors.planId.message}</p>
|
|
) : null}
|
|
{!plansLoading && plans.length === 0 ? (
|
|
<p className="text-sm text-amber-700 dark:text-amber-300">{t("noPlans")}</p>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{step === "software" ? (
|
|
<>
|
|
<div className="space-y-2">
|
|
<label
|
|
htmlFor="softwareFamily"
|
|
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
|
|
>
|
|
{t("software")}
|
|
</label>
|
|
<select id="softwareFamily" className={inputClassName} {...register("softwareFamily")}>
|
|
{SOFTWARE_FAMILIES.map((family) => (
|
|
<option key={family} value={family}>
|
|
{t(`softwareOptions.${family}`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{errors.softwareFamily ? (
|
|
<p className="text-sm text-red-600 dark:text-red-400">
|
|
{errors.softwareFamily.message}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label
|
|
htmlFor="minecraftVersion"
|
|
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
|
|
>
|
|
{t("version")}
|
|
</label>
|
|
<select id="minecraftVersion" className={inputClassName} {...register("minecraftVersion")}>
|
|
{MINECRAFT_VERSIONS.map((version) => (
|
|
<option key={version} value={version}>
|
|
{version}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{errors.minecraftVersion ? (
|
|
<p className="text-sm text-red-600 dark:text-red-400">
|
|
{errors.minecraftVersion.message}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{step === "review" ? (
|
|
<>
|
|
<dl className="space-y-2 rounded-lg border border-zinc-200 p-4 text-sm dark:border-zinc-800">
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-zinc-600 dark:text-zinc-400">{t("name")}</dt>
|
|
<dd className="font-medium text-zinc-900 dark:text-zinc-50">{values.name}</dd>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-zinc-600 dark:text-zinc-400">{t("plan")}</dt>
|
|
<dd className="font-medium text-zinc-900 dark:text-zinc-50">
|
|
{selectedPlan?.name ?? "—"}
|
|
</dd>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-zinc-600 dark:text-zinc-400">{t("software")}</dt>
|
|
<dd className="font-medium text-zinc-900 dark:text-zinc-50">
|
|
{t(`softwareOptions.${values.softwareFamily}`)}
|
|
</dd>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<dt className="text-zinc-600 dark:text-zinc-400">{t("version")}</dt>
|
|
<dd className="font-medium text-zinc-900 dark:text-zinc-50">
|
|
{values.minecraftVersion}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
<div className="space-y-2">
|
|
<label className="flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
|
<input type="checkbox" className="mt-1" {...register("eulaAccepted")} />
|
|
<span>{t("eula")}</span>
|
|
</label>
|
|
{errors.eulaAccepted ? (
|
|
<p className="text-sm text-red-600 dark:text-red-400">
|
|
{errors.eulaAccepted.message}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{apiError ? (
|
|
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
|
{apiError}
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="flex gap-2">
|
|
{step !== "basics" ? (
|
|
<Button type="button" variant="secondary" onClick={goBack}>
|
|
{tw("back")}
|
|
</Button>
|
|
) : null}
|
|
{step !== "review" ? (
|
|
<Button type="button" onClick={() => void goNext()}>
|
|
{tw("next")}
|
|
</Button>
|
|
) : (
|
|
<Button type="submit" isLoading={isSubmitting || createMutation.isPending}>
|
|
{t("create")}
|
|
</Button>
|
|
)}
|
|
<Link
|
|
href="/servers"
|
|
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
|
>
|
|
{t("cancel")}
|
|
</Link>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|