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

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|.*\\..*).*)"],
};