initial commit
Some checks failed
CI / Go — node-agent tests (push) Has been cancelled
CI / Go — edge-gateway build (push) Has been cancelled
CI / Node — lint, typecheck, test, build (push) Has been cancelled

This commit is contained in:
smueller
2026-06-26 10:45:08 +02:00
commit e37ea87b35
118 changed files with 7726 additions and 0 deletions

3
apps/web/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Public environment variables (exposed to the browser)
NEXT_PUBLIC_APP_NAME=HexaHost GameCloud
NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1

3
apps/web/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

75
apps/web/messages/de.json Normal file
View File

@@ -0,0 +1,75 @@
{
"common": {
"appName": "HexaHost GameCloud",
"login": "Anmelden",
"register": "Registrieren",
"dashboard": "Dashboard",
"logout": "Abmelden",
"theme": "Design",
"themeLight": "Hell",
"themeDark": "Dunkel",
"themeSystem": "System",
"language": "Sprache",
"loading": "Wird geladen…",
"submit": "Absenden",
"cancel": "Abbrechen",
"back": "Zurück",
"footer": "Selbstgehostete Minecraft-Server-Plattform."
},
"landing": {
"badge": "Phase 0 · Control Plane",
"title": "Minecraft-Server on demand",
"subtitle": "Erstellen, konfigurieren und betreiben Sie Minecraft-Server über ein zentrales Webpanel — mandantenfähig, skalierbar und produktionsbereit.",
"ctaPrimary": "Jetzt starten",
"ctaSecondary": "Anmelden",
"features": {
"control": {
"title": "Zentrale Steuerung",
"description": "Server-Lifecycle, Quotas und Scheduling über eine einheitliche Control Plane."
},
"nodes": {
"title": "Multi-Node",
"description": "Horizontale Skalierung über dedizierte Game-Nodes mit Agent-basierter Orchestrierung."
},
"security": {
"title": "Enterprise-Sicherheit",
"description": "mTLS, granulare Berechtigungen und auditierbare Zustandsübergänge von Anfang an."
}
}
},
"auth": {
"loginTitle": "Anmelden",
"loginSubtitle": "Melden Sie sich mit Ihrem Konto an.",
"registerTitle": "Konto erstellen",
"registerSubtitle": "Registrieren Sie sich für die Plattform.",
"email": "E-Mail",
"emailPlaceholder": "name@beispiel.de",
"password": "Passwort",
"passwordPlaceholder": "••••••••",
"confirmPassword": "Passwort bestätigen",
"noAccount": "Noch kein Konto?",
"hasAccount": "Bereits registriert?",
"apiPending": "Authentifizierung wird in Phase 1 angebunden. Formularstruktur ist vorbereitet."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Willkommen in Ihrem Kontrollbereich.",
"servers": "Server",
"serversEmpty": "Noch keine Server",
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, sobald die Provisionierung verfügbar ist.",
"createServer": "Server erstellen",
"status": "Status",
"resources": "Ressourcen",
"activity": "Aktivität",
"activityEmpty": "Keine aktuellen Ereignisse",
"protectedNote": "Geschützter Bereich — Authentifizierung wird in Phase 1 aktiviert."
},
"validation": {
"emailRequired": "E-Mail ist erforderlich",
"emailInvalid": "Ungültige E-Mail-Adresse",
"passwordRequired": "Passwort ist erforderlich",
"passwordMin": "Passwort muss mindestens 8 Zeichen haben",
"confirmPasswordRequired": "Passwortbestätigung ist erforderlich",
"passwordMismatch": "Passwörter stimmen nicht überein"
}
}

75
apps/web/messages/en.json Normal file
View File

@@ -0,0 +1,75 @@
{
"common": {
"appName": "HexaHost GameCloud",
"login": "Sign in",
"register": "Register",
"dashboard": "Dashboard",
"logout": "Sign out",
"theme": "Theme",
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"language": "Language",
"loading": "Loading…",
"submit": "Submit",
"cancel": "Cancel",
"back": "Back",
"footer": "Self-hosted Minecraft server platform."
},
"landing": {
"badge": "Phase 0 · Control Plane",
"title": "Minecraft servers on demand",
"subtitle": "Create, configure, and operate Minecraft servers through a central web panel — multi-tenant, scalable, and production-ready.",
"ctaPrimary": "Get started",
"ctaSecondary": "Sign in",
"features": {
"control": {
"title": "Central control",
"description": "Server lifecycle, quotas, and scheduling through a unified control plane."
},
"nodes": {
"title": "Multi-node",
"description": "Horizontal scaling across dedicated game nodes with agent-based orchestration."
},
"security": {
"title": "Enterprise security",
"description": "mTLS, granular permissions, and auditable state transitions from day one."
}
}
},
"auth": {
"loginTitle": "Sign in",
"loginSubtitle": "Sign in with your account.",
"registerTitle": "Create account",
"registerSubtitle": "Register for the platform.",
"email": "Email",
"emailPlaceholder": "name@example.com",
"password": "Password",
"passwordPlaceholder": "••••••••",
"confirmPassword": "Confirm password",
"noAccount": "No account yet?",
"hasAccount": "Already registered?",
"apiPending": "Authentication will be connected in Phase 1. Form structure is ready."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome to your control area.",
"servers": "Servers",
"serversEmpty": "No servers yet",
"serversEmptyDescription": "Create your first Minecraft server once provisioning is available.",
"createServer": "Create server",
"status": "Status",
"resources": "Resources",
"activity": "Activity",
"activityEmpty": "No recent events",
"protectedNote": "Protected area — authentication will be enabled in Phase 1."
},
"validation": {
"emailRequired": "Email is required",
"emailInvalid": "Invalid email address",
"passwordRequired": "Password is required",
"passwordMin": "Password must be at least 8 characters",
"confirmPasswordRequired": "Password confirmation is required",
"passwordMismatch": "Passwords do not match"
}
}

5
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

10
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = {
transpilePackages: ["@hexahost/ui"],
};
export default withNextIntl(nextConfig);

35
apps/web/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@hexahost/web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hexahost/ui": "workspace:*",
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.67.2",
"next": "^15.2.3",
"next-intl": "^4.0.2",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.21",
"eslint": "^9.22.0",
"eslint-config-next": "^15.2.3",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -0,0 +1,41 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation";
interface DashboardLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function DashboardLayout({
children,
params,
}: DashboardLayoutProps) {
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="Dashboard navigation">
<ul className="flex gap-4 text-sm">
<li>
<Link
href="/dashboard"
className="font-medium text-zinc-900 dark:text-zinc-50"
aria-current="page"
>
{t("dashboard")}
</Link>
</li>
</ul>
</nav>
<p className="font-mono text-xs text-zinc-500">
auth: pending · session: none
</p>
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { DashboardContent } from "@/components/dashboard/dashboard-content";
interface DashboardPageProps {
params: Promise<{ locale: string }>;
}
export default async function DashboardPage({ params }: DashboardPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return <DashboardContent />;
}

View File

@@ -0,0 +1,38 @@
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { SiteLayout } from "@/components/layout/site-layout";
import { QueryProvider } from "@/components/providers/query-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { routing, type Locale } from "@/i18n/routing";
interface LocaleLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) {
notFound();
}
setRequestLocale(locale);
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
<ThemeProvider>
<QueryProvider>
<SiteLayout>{children}</SiteLayout>
</QueryProvider>
</ThemeProvider>
</NextIntlClientProvider>
);
}

View File

@@ -0,0 +1,17 @@
import { setRequestLocale } from "next-intl/server";
import { LoginForm } from "@/components/auth/login-form";
interface LoginPageProps {
params: Promise<{ locale: string }>;
}
export default async function LoginPage({ params }: LoginPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
<LoginForm />
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import { getAppName } from "@/lib/env";
interface HomePageProps {
params: Promise<{ locale: string }>;
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("landing");
const appName = getAppName();
const features = [
{ key: "control" as const, title: t("features.control.title"), description: t("features.control.description") },
{ key: "nodes" as const, title: t("features.nodes.title"), description: t("features.nodes.description") },
{ key: "security" as const, title: t("features.security.title"), description: t("features.security.description") },
];
return (
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<section className="mx-auto max-w-3xl text-center">
<p className="mb-4 inline-flex rounded-full border border-zinc-200 bg-white px-3 py-1 font-mono text-xs text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
{t("badge")}
</p>
<h1 className="text-4xl font-semibold tracking-tight text-zinc-900 sm:text-5xl dark:text-zinc-50">
{appName}
</h1>
<p className="mt-4 text-lg text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link
href="/register"
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-6 text-sm font-medium text-white transition-colors hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("ctaPrimary")}
</Link>
<Link
href="/login"
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-6 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("ctaSecondary")}
</Link>
</div>
</section>
<section className="mt-20 grid gap-6 md:grid-cols-3">
{features.map((feature) => (
<Card key={feature.key}>
<CardHeader>
<CardTitle>{feature.title}</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="h-1 w-12 rounded-full bg-sky-600 dark:bg-sky-500" aria-hidden="true" />
</CardContent>
</Card>
))}
</section>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { setRequestLocale } from "next-intl/server";
import { RegisterForm } from "@/components/auth/register-form";
interface RegisterPageProps {
params: Promise<{ locale: string }>;
}
export default async function RegisterPage({ params }: RegisterPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
<RegisterForm />
</div>
);
}

View File

@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--surface: 250 250 250;
--surface-muted: 244 244 245;
--border: 228 228 231;
}
.dark {
--surface: 9 9 11;
--surface-muted: 24 24 27;
--border: 39 39 42;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-zinc-50 text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-50;
}
}
@layer utilities {
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
}

View File

@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud",
template: `%s · ${process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud"}`,
},
description: "Self-hosted Minecraft server platform control plane.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} font-sans`}>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link } from "@/i18n/navigation";
import { submitLogin } from "@/lib/api/auth";
import { createLoginSchema, type LoginFormValues } from "@/lib/schemas/auth";
export function LoginForm() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const [submitted, setSubmitted] = useState(false);
const schema = createLoginSchema({
emailRequired: tv("emailRequired"),
emailInvalid: tv("emailInvalid"),
passwordRequired: tv("passwordRequired"),
passwordMin: tv("passwordMin"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "" },
});
async function onSubmit(values: LoginFormValues) {
await submitLogin(values);
setSubmitted(true);
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("loginTitle")}</CardTitle>
<CardDescription>{t("loginSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("email")}
</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder={t("emailPlaceholder")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
className="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"
{...register("email")}
/>
{errors.email ? (
<p id="email-error" className="text-sm text-red-600" role="alert">
{errors.email.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")}
</label>
<input
id="password"
type="password"
autoComplete="current-password"
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.password ? "true" : "false"}
aria-describedby={errors.password ? "password-error" : undefined}
className="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"
{...register("password")}
/>
{errors.password ? (
<p id="password-error" className="text-sm text-red-600" role="alert">
{errors.password.message}
</p>
) : null}
</div>
{submitted ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
{t("apiPending")}
</p>
) : null}
<Button type="submit" className="w-full" isLoading={isSubmitting}>
{t("loginTitle")}
</Button>
</form>
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
{t("noAccount")}{" "}
<Link href="/register" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("registerTitle")}
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link } from "@/i18n/navigation";
import { submitRegister } from "@/lib/api/auth";
import { createRegisterSchema, type RegisterFormValues } from "@/lib/schemas/auth";
export function RegisterForm() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const [submitted, setSubmitted] = useState(false);
const schema = createRegisterSchema({
emailRequired: tv("emailRequired"),
emailInvalid: tv("emailInvalid"),
passwordRequired: tv("passwordRequired"),
passwordMin: tv("passwordMin"),
confirmPasswordRequired: tv("confirmPasswordRequired"),
passwordMismatch: tv("passwordMismatch"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "", confirmPassword: "" },
});
async function onSubmit(values: RegisterFormValues) {
await submitRegister({ email: values.email, password: values.password });
setSubmitted(true);
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("registerTitle")}</CardTitle>
<CardDescription>{t("registerSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("email")}
</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder={t("emailPlaceholder")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
className="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"
{...register("email")}
/>
{errors.email ? (
<p id="email-error" className="text-sm text-red-600" role="alert">
{errors.email.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")}
</label>
<input
id="password"
type="password"
autoComplete="new-password"
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.password ? "true" : "false"}
aria-describedby={errors.password ? "password-error" : undefined}
className="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"
{...register("password")}
/>
{errors.password ? (
<p id="password-error" className="text-sm text-red-600" role="alert">
{errors.password.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="confirmPassword"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("confirmPassword")}
</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.confirmPassword ? "true" : "false"}
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined}
className="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"
{...register("confirmPassword")}
/>
{errors.confirmPassword ? (
<p id="confirm-password-error" className="text-sm text-red-600" role="alert">
{errors.confirmPassword.message}
</p>
) : null}
</div>
{submitted ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
{t("apiPending")}
</p>
) : null}
<Button type="submit" className="w-full" isLoading={isSubmitting}>
{t("registerTitle")}
</Button>
</form>
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
{t("hasAccount")}{" "}
<Link href="/login" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("loginTitle")}
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { EmptyState } from "@/components/dashboard/empty-state";
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
export function DashboardContent() {
const t = useTranslations("dashboard");
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = window.setTimeout(() => setIsLoading(false), 600);
return () => window.clearTimeout(timer);
}, []);
if (isLoading) {
return <DashboardSkeleton />;
}
return (
<div className="space-y-6">
<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("welcome")}</p>
<p className="mt-2 font-mono text-xs text-zinc-500">{t("protectedNote")}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>{t("status")}</CardTitle>
<CardDescription>Control Plane · Online</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
0
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">Active servers</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("resources")}</CardTitle>
<CardDescription>Allocated / Quota</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
0 / 0
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">CPU · RAM · Disk</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("activity")}</CardTitle>
<CardDescription>Last 24 hours</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activityEmpty")}</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")}
/>
</section>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
interface EmptyStateProps {
title: string;
description: string;
actionLabel: string;
onAction?: () => void;
}
export function EmptyState({
title,
description,
actionLabel,
onAction,
}: EmptyStateProps) {
return (
<Card className="border-dashed">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="secondary" onClick={onAction} disabled={!onAction}>
{actionLabel}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,29 @@
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className = "" }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-md bg-zinc-200 dark:bg-zinc-800 ${className}`}
aria-hidden="true"
/>
);
}
export function DashboardSkeleton() {
return (
<div className="space-y-6" aria-busy="true" aria-label="Loading dashboard">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96 max-w-full" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<Skeleton className="h-64 w-full" />
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { useTranslations } from "next-intl";
import { getAppName } from "@/lib/env";
export function Footer() {
const t = useTranslations("common");
const appName = getAppName();
const year = new Date().getFullYear();
return (
<footer className="border-t border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950">
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-4 py-8 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:text-zinc-400">
<p>
© {year} {appName}. {t("footer")}
</p>
<p className="font-mono text-xs text-zinc-500 dark:text-zinc-500">
Phase 0 · Frontend Foundation
</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { getAppName } from "@/lib/env";
import { LocaleSwitcher } from "./locale-switcher";
import { ThemeToggle } from "./theme-toggle";
export function Header() {
const t = useTranslations("common");
const appName = getAppName();
return (
<header className="sticky top-0 z-50 border-b border-zinc-200 bg-white/80 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/80">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
<Link
href="/"
className="text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"
>
{appName}
</Link>
<nav
className="flex items-center gap-2 sm:gap-4"
aria-label="Main navigation"
>
<Link
href="/dashboard"
className="hidden text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 sm:inline"
>
{t("dashboard")}
</Link>
<LocaleSwitcher />
<ThemeToggle />
<Link
href="/login"
className="inline-flex h-8 items-center rounded-md px-3 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
{t("login")}
</Link>
<Link
href="/register"
className="inline-flex h-8 items-center rounded-md bg-sky-600 px-3 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
>
{t("register")}
</Link>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { routing, type Locale } from "@/i18n/routing";
export function LocaleSwitcher() {
const t = useTranslations("common");
const locale = useLocale() as Locale;
const router = useRouter();
const pathname = usePathname();
function onChange(nextLocale: string) {
router.replace(pathname, { locale: nextLocale as Locale });
}
return (
<label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<span className="sr-only">{t("language")}</span>
<select
value={locale}
onChange={(event) => onChange(event.target.value)}
className="h-8 rounded-md border border-zinc-300 bg-white px-2 text-sm text-zinc-900 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
aria-label={t("language")}
>
{routing.locales.map((item) => (
<option key={item} value={item}>
{item.toUpperCase()}
</option>
))}
</select>
</label>
);
}

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
import { Footer } from "./footer";
import { Header } from "./header";
interface SiteLayoutProps {
children: ReactNode;
}
export function SiteLayout({ children }: SiteLayoutProps) {
return (
<div className="flex min-h-screen flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@hexahost/ui";
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant="ghost" size="sm" aria-label="Theme" disabled>
</Button>
);
}
const isDark = (theme === "system" ? resolvedTheme : theme) === "dark";
return (
<Button
variant="ghost"
size="sm"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? "☀" : "☾"}
</Button>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
interface QueryProviderProps {
children: ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ReactNode } from "react";
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,5 @@
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);

View File

@@ -0,0 +1,15 @@
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as "de" | "en")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});

View File

@@ -0,0 +1,9 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["de", "en"],
defaultLocale: "de",
localePrefix: "as-needed",
});
export type Locale = (typeof routing.locales)[number];

View File

@@ -0,0 +1,45 @@
import { getApiUrl } from "./env";
export interface AuthCredentials {
email: string;
password: string;
}
export interface AuthResponse {
accessToken?: string;
refreshToken?: string;
}
/**
* Prepared API client for Phase 1 authentication.
* Currently validates structure only; no network request is performed.
*/
export async function submitLogin(
credentials: AuthCredentials,
): Promise<AuthResponse> {
const endpoint = `${getApiUrl()}/auth/login`;
if (!endpoint.startsWith("http")) {
throw new Error("Invalid API URL configuration");
}
void credentials;
void endpoint;
return Promise.resolve({});
}
export async function submitRegister(
credentials: AuthCredentials,
): Promise<AuthResponse> {
const endpoint = `${getApiUrl()}/auth/register`;
if (!endpoint.startsWith("http")) {
throw new Error("Invalid API URL configuration");
}
void credentials;
void endpoint;
return Promise.resolve({});
}

7
apps/web/src/lib/env.ts Normal file
View File

@@ -0,0 +1,7 @@
export function getAppName(): string {
return process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud";
}
export function getApiUrl(): string {
return process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001/api/v1";
}

View File

@@ -0,0 +1,48 @@
import { z } from "zod";
export function createLoginSchema(messages: {
emailRequired: string;
emailInvalid: string;
passwordRequired: string;
passwordMin: string;
}) {
return z.object({
email: z
.string()
.min(1, messages.emailRequired)
.email(messages.emailInvalid),
password: z
.string()
.min(1, messages.passwordRequired)
.min(8, messages.passwordMin),
});
}
export function createRegisterSchema(messages: {
emailRequired: string;
emailInvalid: string;
passwordRequired: string;
passwordMin: string;
confirmPasswordRequired: string;
passwordMismatch: string;
}) {
return z
.object({
email: z
.string()
.min(1, messages.emailRequired)
.email(messages.emailInvalid),
password: z
.string()
.min(1, messages.passwordRequired)
.min(8, messages.passwordMin),
confirmPassword: z.string().min(1, messages.confirmPasswordRequired),
})
.refine((data) => data.password === data.confirmPassword, {
message: messages.passwordMismatch,
path: ["confirmPassword"],
});
}
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
export type RegisterFormValues = z.infer<ReturnType<typeof createRegisterSchema>>;

View File

@@ -0,0 +1,8 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./src/i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/", "/(de|en)/:path*", "/((?!_next|_vercel|.*\\..*).*)"],
};

View File

@@ -0,0 +1,34 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"../../packages/ui/src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
sans: [
"var(--font-geist-sans)",
"ui-sans-serif",
"system-ui",
"sans-serif",
],
mono: ["var(--font-geist-mono)", "ui-monospace", "monospace"],
},
colors: {
surface: {
DEFAULT: "rgb(var(--surface) / <alpha-value>)",
muted: "rgb(var(--surface-muted) / <alpha-value>)",
},
border: {
DEFAULT: "rgb(var(--border) / <alpha-value>)",
},
},
},
},
plugins: [],
};
export default config;

23
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}