Phase2
This commit is contained in:
@@ -14,7 +14,8 @@
|
||||
"submit": "Absenden",
|
||||
"cancel": "Abbrechen",
|
||||
"back": "Zurück",
|
||||
"footer": "Selbstgehostete Minecraft-Server-Plattform."
|
||||
"footer": "Selbstgehostete Minecraft-Server-Plattform.",
|
||||
"servers": "Server"
|
||||
},
|
||||
"landing": {
|
||||
"badge": "Phase 0 · Control Plane",
|
||||
@@ -95,12 +96,20 @@
|
||||
"welcome": "Willkommen in Ihrem Kontrollbereich.",
|
||||
"servers": "Server",
|
||||
"serversEmpty": "Noch keine Server",
|
||||
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, sobald die Provisionierung verfügbar ist.",
|
||||
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, um loszulegen.",
|
||||
"createServer": "Server erstellen",
|
||||
"status": "Status",
|
||||
"resources": "Ressourcen",
|
||||
"activity": "Aktivität",
|
||||
"activityEmpty": "Keine aktuellen Ereignisse"
|
||||
"activityEmpty": "Keine aktuellen Ereignisse",
|
||||
"controlPlaneOnline": "Control Plane · Online",
|
||||
"activeServers": "Aktive Server",
|
||||
"resourcesSubtitle": "Zugewiesen / Kontingent",
|
||||
"allocatedRam": "Zugewiesener RAM",
|
||||
"activitySubtitle": "Übersicht",
|
||||
"totalServers": "Server gesamt",
|
||||
"viewAllServers": "Alle Server anzeigen",
|
||||
"serversSummary": "Server in Ihrem Konto"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-Mail ist erforderlich",
|
||||
@@ -111,6 +120,66 @@
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"usernameInvalid": "Benutzername: 3–32 Zeichen (Buchstaben, Zahlen, _ und -)",
|
||||
"codeRequired": "Authentifizierungscode ist erforderlich",
|
||||
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein"
|
||||
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein",
|
||||
"serverNameRequired": "Servername ist erforderlich",
|
||||
"serverNameMax": "Servername darf höchstens 64 Zeichen lang sein",
|
||||
"planRequired": "Bitte wählen Sie einen Tarif",
|
||||
"planInvalid": "Ungültiger Tarif ausgewählt",
|
||||
"versionRequired": "Minecraft-Version ist erforderlich",
|
||||
"softwareRequired": "Software-Familie ist erforderlich",
|
||||
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Server",
|
||||
"subtitle": "Verwalten Sie Ihre Minecraft-Server.",
|
||||
"create": "Server erstellen",
|
||||
"createTitle": "Neuer Server",
|
||||
"createDescription": "Konfigurieren und provisionieren Sie einen neuen Minecraft-Server.",
|
||||
"name": "Servername",
|
||||
"plan": "Tarif",
|
||||
"selectPlan": "Tarif auswählen",
|
||||
"noPlans": "Keine Tarife verfügbar. Bitten Sie einen Administrator, einen Tarif anzulegen.",
|
||||
"software": "Software",
|
||||
"version": "Minecraft-Version",
|
||||
"eula": "Ich akzeptiere die Minecraft End User License Agreement (EULA).",
|
||||
"loading": "Wird geladen…",
|
||||
"cancel": "Abbrechen",
|
||||
"emptyTitle": "Noch keine Server",
|
||||
"emptyDescription": "Erstellen Sie Ihren ersten Server, um zu spielen.",
|
||||
"address": "Adresse",
|
||||
"addressPending": "Zuweisung ausstehend",
|
||||
"ram": "RAM",
|
||||
"start": "Starten",
|
||||
"stop": "Stoppen",
|
||||
"details": "Details",
|
||||
"detailSubtitle": "Serverdetails und Lifecycle-Steuerung",
|
||||
"backToList": "Zurück zur Serverliste",
|
||||
"retry": "Erneut versuchen",
|
||||
"status": {
|
||||
"DRAFT": "Entwurf",
|
||||
"PROVISIONING": "Provisionierung",
|
||||
"INSTALLING": "Installation",
|
||||
"STOPPED": "Gestoppt",
|
||||
"STARTING": "Startet",
|
||||
"RUNNING": "Läuft",
|
||||
"STOPPING": "Stoppt",
|
||||
"ERROR": "Fehler",
|
||||
"DELETING": "Wird gelöscht",
|
||||
"DELETED": "Gelöscht"
|
||||
},
|
||||
"softwareOptions": {
|
||||
"VANILLA": "Vanilla",
|
||||
"PAPER": "Paper",
|
||||
"PURPUR": "Purpur",
|
||||
"FABRIC": "Fabric",
|
||||
"FORGE": "Forge",
|
||||
"NEOFORGE": "NeoForge",
|
||||
"QUILT": "Quilt",
|
||||
"BEDROCK_DEDICATED": "Bedrock Dedicated"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
|
||||
"loadFailed": "Server konnten nicht geladen werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"submit": "Submit",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"footer": "Self-hosted Minecraft server platform."
|
||||
"footer": "Self-hosted Minecraft server platform.",
|
||||
"servers": "Servers"
|
||||
},
|
||||
"landing": {
|
||||
"badge": "Phase 0 · Control Plane",
|
||||
@@ -95,12 +96,20 @@
|
||||
"welcome": "Welcome to your control area.",
|
||||
"servers": "Servers",
|
||||
"serversEmpty": "No servers yet",
|
||||
"serversEmptyDescription": "Create your first Minecraft server once provisioning is available.",
|
||||
"serversEmptyDescription": "Create your first Minecraft server to get started.",
|
||||
"createServer": "Create server",
|
||||
"status": "Status",
|
||||
"resources": "Resources",
|
||||
"activity": "Activity",
|
||||
"activityEmpty": "No recent events"
|
||||
"activityEmpty": "No recent events",
|
||||
"controlPlaneOnline": "Control Plane · Online",
|
||||
"activeServers": "Active servers",
|
||||
"resourcesSubtitle": "Allocated / Quota",
|
||||
"allocatedRam": "Allocated RAM",
|
||||
"activitySubtitle": "Overview",
|
||||
"totalServers": "Total servers",
|
||||
"viewAllServers": "View all servers",
|
||||
"serversSummary": "Servers in your account"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "Email is required",
|
||||
@@ -111,6 +120,66 @@
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"usernameInvalid": "Username must be 3–32 characters (letters, numbers, _ and -)",
|
||||
"codeRequired": "Authentication code is required",
|
||||
"codeInvalid": "Enter a valid 6-digit code"
|
||||
"codeInvalid": "Enter a valid 6-digit code",
|
||||
"serverNameRequired": "Server name is required",
|
||||
"serverNameMax": "Server name must be at most 64 characters",
|
||||
"planRequired": "Please select a plan",
|
||||
"planInvalid": "Invalid plan selected",
|
||||
"versionRequired": "Minecraft version is required",
|
||||
"softwareRequired": "Software family is required",
|
||||
"eulaRequired": "You must accept the Minecraft EULA"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Servers",
|
||||
"subtitle": "Manage your Minecraft servers.",
|
||||
"create": "Create server",
|
||||
"createTitle": "New server",
|
||||
"createDescription": "Configure and provision a new Minecraft server.",
|
||||
"name": "Server name",
|
||||
"plan": "Plan",
|
||||
"selectPlan": "Select a plan",
|
||||
"noPlans": "No plans are available. Ask an administrator to create a plan.",
|
||||
"software": "Software",
|
||||
"version": "Minecraft version",
|
||||
"eula": "I accept the Minecraft End User License Agreement (EULA).",
|
||||
"loading": "Loading…",
|
||||
"cancel": "Cancel",
|
||||
"emptyTitle": "No servers yet",
|
||||
"emptyDescription": "Create your first server to start playing.",
|
||||
"address": "Address",
|
||||
"addressPending": "Pending allocation",
|
||||
"ram": "RAM",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"details": "Details",
|
||||
"detailSubtitle": "Server details and lifecycle controls",
|
||||
"backToList": "Back to servers",
|
||||
"retry": "Retry",
|
||||
"status": {
|
||||
"DRAFT": "Draft",
|
||||
"PROVISIONING": "Provisioning",
|
||||
"INSTALLING": "Installing",
|
||||
"STOPPED": "Stopped",
|
||||
"STARTING": "Starting",
|
||||
"RUNNING": "Running",
|
||||
"STOPPING": "Stopping",
|
||||
"ERROR": "Error",
|
||||
"DELETING": "Deleting",
|
||||
"DELETED": "Deleted"
|
||||
},
|
||||
"softwareOptions": {
|
||||
"VANILLA": "Vanilla",
|
||||
"PAPER": "Paper",
|
||||
"PURPUR": "Purpur",
|
||||
"FABRIC": "Fabric",
|
||||
"FORGE": "Forge",
|
||||
"NEOFORGE": "NeoForge",
|
||||
"QUILT": "Quilt",
|
||||
"BEDROCK_DEDICATED": "Bedrock Dedicated"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Something went wrong. Please try again.",
|
||||
"loadFailed": "Could not load servers."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ export default async function DashboardLayout({
|
||||
{t("dashboard")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
{t("servers")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<DashboardUserInfo />
|
||||
|
||||
13
apps/web/src/app/[locale]/servers/[id]/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ServerDetailContent } from "@/components/servers/server-detail-content";
|
||||
|
||||
interface ServerDetailPageProps {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ServerDetailPage({ params }: ServerDetailPageProps) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <ServerDetailContent serverId={id} />;
|
||||
}
|
||||
45
apps/web/src/app/[locale]/servers/layout.tsx
Normal file
45
apps/web/src/app/[locale]/servers/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
|
||||
|
||||
interface ServersLayoutProps {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function ServersLayout({ children, params }: ServersLayoutProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("common");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
|
||||
<nav aria-label="App navigation">
|
||||
<ul className="flex gap-4 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
{t("dashboard")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="font-medium text-zinc-900 dark:text-zinc-50"
|
||||
aria-current="page"
|
||||
>
|
||||
{t("servers")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<DashboardUserInfo />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
apps/web/src/app/[locale]/servers/new/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/new/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { CreateServerForm } from "@/components/servers/create-server-form";
|
||||
|
||||
interface NewServerPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function NewServerPage({ params }: NewServerPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <CreateServerForm />;
|
||||
}
|
||||
34
apps/web/src/app/[locale]/servers/page.tsx
Normal file
34
apps/web/src/app/[locale]/servers/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { ServerList } from "@/components/servers/server-list";
|
||||
|
||||
interface ServersPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function ServersPage({ params }: ServersPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("servers");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/servers/new"
|
||||
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
|
||||
>
|
||||
{t("create")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ServerList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { EmptyState } from "@/components/dashboard/empty-state";
|
||||
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
|
||||
import { listServers } from "@/lib/api/servers";
|
||||
|
||||
export function DashboardContent() {
|
||||
const t = useTranslations("dashboard");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["servers"],
|
||||
queryFn: listServers,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setIsLoading(false), 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
const servers = data?.servers ?? [];
|
||||
const activeCount = servers.filter((server) =>
|
||||
["RUNNING", "STARTING", "STOPPING"].includes(server.status),
|
||||
).length;
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />;
|
||||
@@ -32,49 +38,83 @@ export function DashboardContent() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("status")}</CardTitle>
|
||||
<CardDescription>Control Plane · Online</CardDescription>
|
||||
<CardDescription>{t("controlPlaneOnline")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-mono text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
0
|
||||
{activeCount}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">Active servers</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activeServers")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("resources")}</CardTitle>
|
||||
<CardDescription>Allocated / Quota</CardDescription>
|
||||
<CardDescription>{t("resourcesSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
0 / 0
|
||||
{servers.reduce((sum, server) => sum + server.ramMb, 0)} MB
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">CPU · RAM · Disk</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("allocatedRam")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("activity")}</CardTitle>
|
||||
<CardDescription>Last 24 hours</CardDescription>
|
||||
<CardDescription>{t("activitySubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activityEmpty")}</p>
|
||||
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{servers.length}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("totalServers")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="servers-heading">
|
||||
<h2 id="servers-heading" className="mb-4 text-lg font-medium text-zinc-900 dark:text-zinc-50">
|
||||
{t("servers")}
|
||||
</h2>
|
||||
<EmptyState
|
||||
title={t("serversEmpty")}
|
||||
description={t("serversEmptyDescription")}
|
||||
actionLabel={t("createServer")}
|
||||
/>
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 id="servers-heading" className="text-lg font-medium text-zinc-900 dark:text-zinc-50">
|
||||
{t("servers")}
|
||||
</h2>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
|
||||
>
|
||||
{t("viewAllServers")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{servers.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("serversEmpty")}
|
||||
description={t("serversEmptyDescription")}
|
||||
actionLabel={t("createServer")}
|
||||
onAction={() => {
|
||||
router.push("/servers/new");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 py-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("serversSummary")}</p>
|
||||
<p className="text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{servers.length}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/servers/new"
|
||||
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
|
||||
>
|
||||
{t("createServer")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
212
apps/web/src/components/servers/create-server-form.tsx
Normal file
212
apps/web/src/components/servers/create-server-form.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"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";
|
||||
|
||||
export function CreateServerForm() {
|
||||
const t = useTranslations("servers");
|
||||
const tv = useTranslations("validation");
|
||||
const router = useRouter();
|
||||
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 {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateServerFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
planId: "",
|
||||
minecraftVersion: MINECRAFT_VERSIONS[0],
|
||||
softwareFamily: "VANILLA",
|
||||
eulaAccepted: false,
|
||||
},
|
||||
});
|
||||
|
||||
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"));
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: CreateServerFormValues) {
|
||||
setApiError(null);
|
||||
await createMutation.mutateAsync({
|
||||
...values,
|
||||
eulaAccepted: true,
|
||||
});
|
||||
}
|
||||
|
||||
const plans = plansData?.plans ?? [];
|
||||
|
||||
return (
|
||||
<Card className="max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("createTitle")}</CardTitle>
|
||||
<CardDescription>{t("createDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<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}
|
||||
aria-invalid={errors.name ? "true" : "false"}
|
||||
{...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}
|
||||
aria-invalid={errors.planId ? "true" : "false"}
|
||||
{...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>
|
||||
|
||||
<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}
|
||||
aria-invalid={errors.softwareFamily ? "true" : "false"}
|
||||
{...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}
|
||||
aria-invalid={errors.minecraftVersion ? "true" : "false"}
|
||||
{...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>
|
||||
|
||||
<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>
|
||||
|
||||
{apiError ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
113
apps/web/src/components/servers/server-card.tsx
Normal file
113
apps/web/src/components/servers/server-card.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { ApiError } from "@/lib/api/auth";
|
||||
import {
|
||||
type Server,
|
||||
startServer,
|
||||
stopServer,
|
||||
} from "@/lib/api/servers";
|
||||
import { ServerStatusBadge } from "./server-status-badge";
|
||||
|
||||
interface ServerCardProps {
|
||||
server: Server;
|
||||
showDetailsLink?: boolean;
|
||||
}
|
||||
|
||||
export function ServerCard({ server, showDetailsLink = true }: ServerCardProps) {
|
||||
const t = useTranslations("servers");
|
||||
const queryClient = useQueryClient();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => startServer(server.id),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["server", server.id] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setActionError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: () => stopServer(server.id),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["server", server.id] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setActionError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
|
||||
},
|
||||
});
|
||||
|
||||
const canStart = server.status === "STOPPED";
|
||||
const canStop = server.status === "RUNNING";
|
||||
const isBusy = ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(server.status);
|
||||
const address =
|
||||
server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="truncate text-lg">{server.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{server.softwareFamily} · {server.minecraftVersion}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<ServerStatusBadge status={server.status} label={t(`status.${server.status}`)} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-zinc-500 dark:text-zinc-400">{t("address")}</dt>
|
||||
<dd className="font-mono text-zinc-900 dark:text-zinc-100">{address}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500 dark:text-zinc-400">{t("ram")}</dt>
|
||||
<dd className="text-zinc-900 dark:text-zinc-100">{server.ramMb} MB</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{actionError ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => startMutation.mutate()}
|
||||
disabled={!canStart || isBusy || startMutation.isPending || stopMutation.isPending}
|
||||
>
|
||||
{t("start")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => stopMutation.mutate()}
|
||||
disabled={!canStop || isBusy || startMutation.isPending || stopMutation.isPending}
|
||||
>
|
||||
{t("stop")}
|
||||
</Button>
|
||||
{showDetailsLink ? (
|
||||
<Link
|
||||
href={`/servers/${server.id}`}
|
||||
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 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("details")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
73
apps/web/src/components/servers/server-detail-content.tsx
Normal file
73
apps/web/src/components/servers/server-detail-content.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { getServer } from "@/lib/api/servers";
|
||||
import { ServerCard } from "@/components/servers/server-card";
|
||||
|
||||
interface ServerDetailContentProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
|
||||
const t = useTranslations("servers");
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["server", serverId],
|
||||
queryFn: () => getServer(serverId),
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
if (!status) {
|
||||
return false;
|
||||
}
|
||||
return ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(status)
|
||||
? 3000
|
||||
: false;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 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("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
{data.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("detailSubtitle")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="inline-flex h-8 items-center text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
|
||||
>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ServerCard server={data} showDetailsLink={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
apps/web/src/components/servers/server-list.tsx
Normal file
56
apps/web/src/components/servers/server-list.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { listServers } from "@/lib/api/servers";
|
||||
import { ServerCard } from "./server-card";
|
||||
|
||||
export function ServerList() {
|
||||
const t = useTranslations("servers");
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["servers"],
|
||||
queryFn: listServers,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
|
||||
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const servers = data?.servers ?? [];
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
|
||||
<h2 className="text-lg font-medium text-zinc-900 dark:text-zinc-50">{t("emptyTitle")}</h2>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">{t("emptyDescription")}</p>
|
||||
<Link
|
||||
href="/servers/new"
|
||||
className="mt-4 inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
|
||||
>
|
||||
{t("create")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{servers.map((server) => (
|
||||
<ServerCard key={server.id} server={server} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/components/servers/server-status-badge.tsx
Normal file
37
apps/web/src/components/servers/server-status-badge.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@hexahost/ui";
|
||||
import type { GameServerStatus } from "@/lib/api/servers";
|
||||
|
||||
const statusStyles: Record<GameServerStatus, string> = {
|
||||
DRAFT: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||
PROVISIONING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||
INSTALLING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||
STOPPED: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||
STARTING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
|
||||
RUNNING: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
|
||||
STOPPING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
|
||||
ERROR: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
||||
DELETING: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
||||
DELETED: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-500",
|
||||
};
|
||||
|
||||
interface ServerStatusBadgeProps {
|
||||
status: GameServerStatus;
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ServerStatusBadge({ status, label, className }: ServerStatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
statusStyles[status],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
104
apps/web/src/lib/api/servers.ts
Normal file
104
apps/web/src/lib/api/servers.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export type GameServerStatus =
|
||||
| "DRAFT"
|
||||
| "PROVISIONING"
|
||||
| "INSTALLING"
|
||||
| "STOPPED"
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPING"
|
||||
| "ERROR"
|
||||
| "DELETING"
|
||||
| "DELETED";
|
||||
|
||||
export type SoftwareFamily =
|
||||
| "VANILLA"
|
||||
| "PAPER"
|
||||
| "PURPUR"
|
||||
| "FABRIC"
|
||||
| "FORGE"
|
||||
| "NEOFORGE"
|
||||
| "QUILT"
|
||||
| "BEDROCK_DEDICATED";
|
||||
|
||||
export interface Server {
|
||||
id: string;
|
||||
userId: string;
|
||||
planId: string | null;
|
||||
nodeId: string | null;
|
||||
name: string;
|
||||
status: GameServerStatus;
|
||||
version: number;
|
||||
edition: "JAVA" | "BEDROCK";
|
||||
softwareFamily: SoftwareFamily;
|
||||
minecraftVersion: string;
|
||||
eulaAccepted: boolean;
|
||||
hostPort: number | null;
|
||||
containerId: string | null;
|
||||
dataPath: string | null;
|
||||
ramMb: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ServerListResponse {
|
||||
servers: Server[];
|
||||
}
|
||||
|
||||
export interface ServerActionResponse {
|
||||
server: Server;
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
export interface CreateServerPayload {
|
||||
name: string;
|
||||
planId?: string;
|
||||
minecraftVersion: string;
|
||||
softwareFamily: SoftwareFamily;
|
||||
eulaAccepted: true;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
isFree: boolean;
|
||||
maxRamMb: number;
|
||||
}
|
||||
|
||||
export interface PlanListResponse {
|
||||
plans: Plan[];
|
||||
}
|
||||
|
||||
export async function listServers(): Promise<ServerListResponse> {
|
||||
return apiFetch<ServerListResponse>("/servers");
|
||||
}
|
||||
|
||||
export async function getServer(id: string): Promise<Server> {
|
||||
return apiFetch<Server>(`/servers/${id}`);
|
||||
}
|
||||
|
||||
export async function createServer(payload: CreateServerPayload): Promise<Server> {
|
||||
return apiFetch<Server>("/servers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startServer(id: string): Promise<ServerActionResponse> {
|
||||
return apiFetch<ServerActionResponse>(`/servers/${id}/start`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopServer(id: string): Promise<ServerActionResponse> {
|
||||
return apiFetch<ServerActionResponse>(`/servers/${id}/stop`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPlans(): Promise<PlanListResponse> {
|
||||
return apiFetch<PlanListResponse>("/plans");
|
||||
}
|
||||
46
apps/web/src/lib/schemas/server.ts
Normal file
46
apps/web/src/lib/schemas/server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const softwareFamilyValues = [
|
||||
"VANILLA",
|
||||
"PAPER",
|
||||
"PURPUR",
|
||||
"FABRIC",
|
||||
"FORGE",
|
||||
"NEOFORGE",
|
||||
"QUILT",
|
||||
"BEDROCK_DEDICATED",
|
||||
] as const;
|
||||
|
||||
export function createServerSchema(messages: {
|
||||
nameRequired: string;
|
||||
nameMax: string;
|
||||
planRequired: string;
|
||||
planInvalid: string;
|
||||
versionRequired: string;
|
||||
softwareRequired: string;
|
||||
eulaRequired: string;
|
||||
}) {
|
||||
return z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, messages.nameRequired)
|
||||
.max(64, messages.nameMax),
|
||||
planId: z
|
||||
.string()
|
||||
.min(1, messages.planRequired)
|
||||
.uuid(messages.planInvalid),
|
||||
minecraftVersion: z.string().min(1, messages.versionRequired),
|
||||
softwareFamily: z.enum(softwareFamilyValues, {
|
||||
errorMap: () => ({ message: messages.softwareRequired }),
|
||||
}),
|
||||
eulaAccepted: z
|
||||
.boolean()
|
||||
.refine((value) => value === true, { message: messages.eulaRequired }),
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateServerFormValues = z.infer<ReturnType<typeof createServerSchema>>;
|
||||
|
||||
export const MINECRAFT_VERSIONS = ["1.21.1", "1.21", "1.20.6", "1.20.4"] as const;
|
||||
|
||||
export const SOFTWARE_FAMILIES = softwareFamilyValues;
|
||||
@@ -4,7 +4,7 @@ import { routing } from "./i18n/routing";
|
||||
|
||||
const intlMiddleware = createMiddleware(routing);
|
||||
|
||||
const protectedPaths = ["/dashboard", "/security"];
|
||||
const protectedPaths = ["/dashboard", "/security", "/servers"];
|
||||
|
||||
function getPathWithoutLocale(pathname: string): string {
|
||||
if (pathname === "/en" || pathname.startsWith("/en/")) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user