Add Phase 1 MVP: D&D text-adventure server

FastAPI + Postgres backend with fastapi-users auth, games/characters
CRUD, and a WebSocket chat endpoint where an x.ai Grok DM narrates
play, rolls dice, and maintains character sheets via tool calls.
React + Tailwind frontend covers registration/login, game list and
creation, participation-code join flow, character viewer, and a
real-time chat session view. Docker Compose skeleton (Postgres +
backend) included; Caddy/frontend container wiring is deferred to
the deploy milestone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-08-31 18:43:08 +02:00
commit f37dc9fa76
75 changed files with 106988 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
import { Navigate, Route, Routes } from "react-router-dom";
import Navbar from "./components/Navbar";
import { AuthProvider, useAuth } from "./hooks/useAuth";
import CharacterDetail from "./pages/CharacterDetail";
import CharactersList from "./pages/CharactersList";
import ForgotPassword from "./pages/ForgotPassword";
import GameSession from "./pages/GameSession";
import GamesList from "./pages/GamesList";
import Login from "./pages/Login";
import Register from "./pages/Register";
import ResetPassword from "./pages/ResetPassword";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return <p className="p-8 text-slate-400">Lädt…</p>;
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function AppRoutes() {
return (
<>
<Navbar />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route
path="/games"
element={
<ProtectedRoute>
<GamesList />
</ProtectedRoute>
}
/>
<Route
path="/games/:gameId"
element={
<ProtectedRoute>
<GameSession />
</ProtectedRoute>
}
/>
<Route
path="/characters"
element={
<ProtectedRoute>
<CharactersList />
</ProtectedRoute>
}
/>
<Route
path="/characters/:characterId"
element={
<ProtectedRoute>
<CharacterDetail />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/games" replace />} />
</Routes>
</>
);
}
export default function App() {
return (
<AuthProvider>
<AppRoutes />
</AuthProvider>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { apiFetch, setToken } from "./client";
export interface User {
id: string;
email: string;
name: string;
is_active: boolean;
is_superuser: boolean;
is_verified: boolean;
}
export async function login(email: string, password: string): Promise<void> {
const body = new URLSearchParams();
body.set("username", email);
body.set("password", password);
const response = await fetch("/auth/jwt/login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
throw new Error("Login fehlgeschlagen — bitte E-Mail und Passwort prüfen.");
}
const data = await response.json();
setToken(data.access_token);
}
export async function register(email: string, password: string, name: string): Promise<void> {
await apiFetch("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password, name }),
});
}
export async function forgotPassword(email: string): Promise<void> {
await apiFetch("/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function resetPassword(token: string, password: string): Promise<void> {
await apiFetch("/auth/reset-password", {
method: "POST",
body: JSON.stringify({ token, password }),
});
}
export async function getMe(): Promise<User> {
return apiFetch<User>("/users/me");
}
+18
View File
@@ -0,0 +1,18 @@
import { apiFetch } from "./client";
export interface Character {
id: string;
owner_id: string;
name: string;
race: string | null;
char_class: string | null;
level: number;
stats: Record<string, number>;
description: string;
created_at: string;
updated_at: string;
}
export const listCharacters = () => apiFetch<Character[]>("/api/characters");
export const getCharacter = (id: string) => apiFetch<Character>(`/api/characters/${id}`);
+46
View File
@@ -0,0 +1,46 @@
const TOKEN_KEY = "dnd_token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers = new Headers(options.headers);
if (token) headers.set("Authorization", `Bearer ${token}`);
if (options.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(path, { ...options, headers });
if (!response.ok) {
let detail = response.statusText;
try {
const body = await response.json();
detail = body.detail ? JSON.stringify(body.detail) : detail;
} catch {
// ignore — keep statusText
}
throw new ApiError(response.status, detail);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
+47
View File
@@ -0,0 +1,47 @@
import { apiFetch } from "./client";
export interface Game {
id: string;
name: string;
description: string;
creator_id: string;
creator_name: string;
player_count: number;
player_names: string[];
participation_code: string | null;
created_at: string;
is_participant: boolean;
my_character_id: string | null;
}
export interface Message {
id: number;
sender_type: "player" | "dm" | "system";
user_id: string | null;
player_name: string | null;
character_id: string | null;
character_name: string | null;
content: string;
created_at: string;
}
export const listGames = () => apiFetch<Game[]>("/api/games");
export const createGame = (name: string, description: string) =>
apiFetch<Game>("/api/games", { method: "POST", body: JSON.stringify({ name, description }) });
export const getGame = (id: string) => apiFetch<Game>(`/api/games/${id}`);
export const joinGame = (id: string, participation_code: string, character_id?: string) =>
apiFetch<Game>(`/api/games/${id}/join`, {
method: "POST",
body: JSON.stringify({ participation_code, character_id }),
});
export const getRecentMessages = (id: string, limit = 5) =>
apiFetch<Message[]>(`/api/games/${id}/messages?limit=${limit}`);
export const getFullMessages = (id: string, beforeId?: number, limit = 100) =>
apiFetch<Message[]>(
`/api/games/${id}/messages/full?limit=${limit}${beforeId ? `&before_id=${beforeId}` : ""}`,
);
+17
View File
@@ -0,0 +1,17 @@
import { Link } from "react-router-dom";
import { Character } from "../api/characters";
export default function CharacterCard({ character }: { character: Character }) {
return (
<Link
to={`/characters/${character.id}`}
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
>
<h2 className="text-lg font-semibold text-slate-100">{character.name}</h2>
<p className="text-sm text-slate-400">
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
</p>
</Link>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Link } from "react-router-dom";
import { Game } from "../api/games";
export default function GameCard({ game }: { game: Game }) {
const created = new Date(game.created_at).toLocaleString("de-DE");
return (
<Link
to={`/games/${game.id}`}
className="block rounded-lg border border-slate-800 bg-slate-900 p-4 hover:border-amber-500"
>
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold text-slate-100">{game.name}</h2>
{game.participation_code && (
<span className="shrink-0 rounded bg-amber-500/20 px-2 py-1 font-mono text-xs text-amber-300">
Code: {game.participation_code}
</span>
)}
</div>
<p className="mt-1 text-sm text-slate-400">{game.description}</p>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
<span>Erstellt: {created}</span>
<span>Von: {game.creator_name}</span>
<span>
{game.player_count} Spieler
{game.player_names.length > 0 && `: ${game.player_names.join(", ")}`}
</span>
</div>
</Link>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
export default function Navbar() {
const { user, logout } = useAuth();
const navigate = useNavigate();
if (!user) return null;
function handleLogout() {
logout();
navigate("/login");
}
return (
<nav className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-3">
<div className="flex gap-4">
<Link to="/games" className="font-semibold text-slate-100 hover:text-amber-400">
Spiele
</Link>
<Link to="/characters" className="font-semibold text-slate-100 hover:text-amber-400">
Charaktere
</Link>
</div>
<div className="flex items-center gap-3 text-sm text-slate-400">
<span>{user.name}</span>
<button onClick={handleLogout} className="rounded bg-slate-800 px-3 py-1 hover:bg-slate-700">
Abmelden
</button>
</div>
</nav>
);
}
@@ -0,0 +1,45 @@
import { useState } from "react";
export default function SendMessageForm({
onSend,
disabled,
}: {
onSend: (content: string) => Promise<void>;
disabled?: boolean;
}) {
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
async function handleSend() {
const trimmed = content.trim();
if (!trimmed || sending) return;
setSending(true);
try {
await onSend(trimmed);
setContent("");
} finally {
setSending(false);
}
}
return (
<div className="flex gap-2 border-t border-slate-800 p-3">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={disabled || sending}
placeholder="Was tust du?"
rows={2}
className="flex-1 resize-none rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400 disabled:opacity-50"
/>
<button
type="button"
onClick={handleSend}
disabled={disabled || sending || !content.trim()}
className="shrink-0 self-end rounded bg-amber-500 px-4 py-2 font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
>
{sending ? "…" : "Senden"}
</button>
</div>
);
}
@@ -0,0 +1,40 @@
import { useEffect, useRef } from "react";
import { Message } from "../api/games";
function senderLabel(message: Message): string {
if (message.sender_type === "dm") return "Dungeon Master";
if (message.sender_type === "system") return "System";
if (message.character_name) return `${message.player_name} (${message.character_name})`;
return message.player_name ?? "Spieler";
}
export default function TranscriptPane({ messages }: { messages: Message[] }) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [messages]);
return (
<div className="flex h-full flex-col gap-3 overflow-y-auto p-4">
{messages.map((m) => (
<div key={m.id} className={m.sender_type === "system" ? "text-red-400 italic" : m.sender_type === "dm" ? "text-slate-100" : "text-slate-300"}>
<span
className={
m.sender_type === "dm"
? "font-semibold text-amber-400"
: m.sender_type === "system"
? "font-semibold text-red-400"
: "font-semibold text-sky-400"
}
>
{senderLabel(m)}:
</span>{" "}
<span className="whitespace-pre-wrap">{m.content}</span>
</div>
))}
<div ref={bottomRef} />
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { createContext, ReactNode, useContext, useEffect, useState } from "react";
import * as authApi from "../api/auth";
import { clearToken, getToken } from "../api/client";
interface AuthContextValue {
user: authApi.User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name: string) => Promise<void>;
logout: () => void;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<authApi.User | null>(null);
const [loading, setLoading] = useState(true);
async function refresh() {
if (!getToken()) {
setUser(null);
setLoading(false);
return;
}
try {
const me = await authApi.getMe();
setUser(me);
} catch {
clearToken();
setUser(null);
} finally {
setLoading(false);
}
}
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function login(email: string, password: string) {
await authApi.login(email, password);
await refresh();
}
async function register(email: string, password: string, name: string) {
await authApi.register(email, password, name);
await login(email, password);
}
function logout() {
clearToken();
setUser(null);
}
return (
<AuthContext.Provider value={{ user, loading, login, register, logout, refresh }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useRef } from "react";
import { apiFetch } from "../api/client";
import { Message } from "../api/games";
export function useGameSocket(
gameId: string | undefined,
onMessage: (message: Message) => void,
onError: (detail: string) => void,
) {
const wsRef = useRef<WebSocket | null>(null);
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
useEffect(() => {
if (!gameId) return;
let cancelled = false;
let intentionalClose = false;
(async () => {
const { ticket } = await apiFetch<{ ticket: string }>(`/api/games/${gameId}/ws-ticket`, {
method: "POST",
});
if (cancelled) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/games/${gameId}?ticket=${ticket}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "message") onMessageRef.current(data.message);
else if (data.type === "error") onErrorRef.current(data.detail);
};
ws.onclose = () => {
if (!intentionalClose) onErrorRef.current("connection_lost");
};
wsRef.current = ws;
})();
return () => {
cancelled = true;
intentionalClose = true;
wsRef.current?.close();
wsRef.current = null;
};
}, [gameId]);
function send(content: string) {
if (wsRef.current?.readyState !== WebSocket.OPEN) {
onErrorRef.current("connection_lost");
return;
}
wsRef.current.send(JSON.stringify({ type: "message", content }));
}
return { send };
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+14
View File
@@ -0,0 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { Character, getCharacter } from "../api/characters";
export default function CharacterDetail() {
const { characterId } = useParams<{ characterId: string }>();
const [searchParams] = useSearchParams();
const backGameId = searchParams.get("back");
const [character, setCharacter] = useState<Character | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!characterId) return;
getCharacter(characterId)
.then(setCharacter)
.finally(() => setLoading(false));
}, [characterId]);
if (loading) return <p className="p-8 text-slate-400">Lädt…</p>;
if (!character) return <p className="p-8 text-slate-400">Charakter nicht gefunden.</p>;
return (
<div className="mx-auto max-w-2xl px-4 py-8">
{backGameId && (
<Link
to={`/games/${backGameId}`}
className="mb-4 inline-block rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
>
← Zurück zum Spiel
</Link>
)}
<h1 className="text-2xl font-bold text-amber-400">{character.name}</h1>
<p className="mt-1 text-slate-400">
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
</p>
{Object.keys(character.stats).length > 0 && (
<div className="mt-4 grid grid-cols-3 gap-3 sm:grid-cols-6">
{Object.entries(character.stats).map(([stat, value]) => (
<div key={stat} className="rounded bg-slate-800 p-2 text-center">
<div className="text-xs uppercase text-slate-500">{stat}</div>
<div className="text-lg font-semibold text-slate-100">{value}</div>
</div>
))}
</div>
)}
<div className="mt-6 whitespace-pre-wrap rounded-lg border border-slate-800 bg-slate-900 p-4 text-slate-300">
{character.description || "Noch keine Beschreibung."}
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { Character, listCharacters } from "../api/characters";
import CharacterCard from "../components/CharacterCard";
export default function CharactersList() {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
listCharacters()
.then(setCharacters)
.finally(() => setLoading(false));
}, []);
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Charaktere</h1>
{loading ? (
<p className="text-slate-400">Lädt…</p>
) : characters.length === 0 ? (
<p className="text-slate-400">
Noch keine Charaktere — sie entstehen im Laufe eines Spiels.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{characters.map((c) => (
<CharacterCard key={c.id} character={c} />
))}
</div>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { FormEvent, useState } from "react";
import { Link } from "react-router-dom";
import { forgotPassword } from "../api/auth";
export default function ForgotPassword() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
await forgotPassword(email).catch(() => undefined);
setSent(true);
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Passwort vergessen</h1>
{sent ? (
<p className="text-slate-300">
Falls ein Konto mit dieser E-Mail existiert, wurde eine Nachricht mit einem
Reset-Link verschickt.
</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="email"
required
placeholder="E-Mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
<button
type="submit"
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400"
>
Link anfordern
</button>
</form>
)}
<div className="mt-4 text-sm text-slate-400">
<Link to="/login" className="hover:text-amber-400">
Zurück zur Anmeldung
</Link>
</div>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { FormEvent, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { listCharacters, Character } from "../api/characters";
import { Game, getFullMessages, getGame, getRecentMessages, joinGame, Message } from "../api/games";
import SendMessageForm from "../components/SendMessageForm";
import TranscriptPane from "../components/TranscriptPane";
import { useGameSocket } from "../hooks/useGameSocket";
export default function GameSession() {
const { gameId } = useParams<{ gameId: string }>();
const [game, setGame] = useState<Game | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [showingFull, setShowingFull] = useState(false);
const [loading, setLoading] = useState(true);
const [characters, setCharacters] = useState<Character[]>([]);
const [joinCode, setJoinCode] = useState("");
const [selectedCharacterId, setSelectedCharacterId] = useState("");
const [joinError, setJoinError] = useState<string | null>(null);
async function loadGame() {
if (!gameId) return;
const g = await getGame(gameId);
setGame(g);
return g;
}
async function loadRecent() {
if (!gameId) return;
setMessages(await getRecentMessages(gameId, 5));
setShowingFull(false);
}
async function loadFull() {
if (!gameId) return;
setMessages(await getFullMessages(gameId));
setShowingFull(true);
}
useEffect(() => {
(async () => {
setLoading(true);
const g = await loadGame();
if (g?.is_participant) {
await loadRecent();
} else {
setCharacters(await listCharacters());
}
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameId]);
async function handleJoin(e: FormEvent) {
e.preventDefault();
if (!gameId) return;
setJoinError(null);
try {
const g = await joinGame(gameId, joinCode, selectedCharacterId || undefined);
setGame(g);
await loadRecent();
} catch {
setJoinError("Beitritt fehlgeschlagen — Teilnahmecode prüfen.");
}
}
const { send } = useGameSocket(
game?.is_participant ? gameId : undefined,
(incoming) => {
setMessages((prev) => {
const next = [...prev, incoming];
return showingFull ? next : next.slice(-5);
});
},
(detail) => {
const text =
detail === "connection_lost"
? "Verbindung zum Server verloren. Bitte lade die Seite neu."
: "Der Dungeon Master antwortet gerade nicht. Bitte versuche es erneut.";
const errorMessage: Message = {
id: -Date.now(),
sender_type: "system",
user_id: null,
player_name: null,
character_id: null,
character_name: null,
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => {
const next = [...prev, errorMessage];
return showingFull ? next : next.slice(-5);
});
},
);
async function handleSend(content: string) {
send(content);
}
if (loading) {
return <p className="p-8 text-slate-400">Lädt…</p>;
}
if (!game) {
return <p className="p-8 text-slate-400">Spiel nicht gefunden.</p>;
}
if (!game.is_participant) {
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<h1 className="mb-2 text-2xl font-bold text-amber-400">{game.name}</h1>
<p className="mb-6 text-sm text-slate-400">{game.description}</p>
<form onSubmit={handleJoin} className="flex flex-col gap-4">
<input
required
placeholder="Teilnahmecode"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
className="rounded bg-slate-800 px-3 py-2 font-mono outline-none focus:ring-2 focus:ring-amber-400"
/>
{characters.length > 0 && (
<select
value={selectedCharacterId}
onChange={(e) => setSelectedCharacterId(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
>
<option value="">Neuen Charakter im Spiel erstellen</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.race} {c.char_class})
</option>
))}
</select>
)}
{joinError && <p className="text-sm text-red-400">{joinError}</p>}
<button
type="submit"
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400"
>
Beitreten
</button>
</form>
</div>
);
}
return (
<div className="mx-auto flex h-[calc(100vh-56px)] max-w-3xl flex-col px-4 py-4">
<div className="mb-3 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-amber-400">{game.name}</h1>
<p className="text-xs text-slate-500">{game.description}</p>
</div>
<div className="flex gap-2">
{game.my_character_id && (
<Link
to={`/characters/${game.my_character_id}?back=${gameId}`}
className="rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
>
Mein Charakter
</Link>
)}
<button
onClick={showingFull ? loadRecent : loadFull}
className="rounded bg-slate-800 px-3 py-2 text-sm hover:bg-slate-700"
>
{showingFull ? "Nur letzte 5" : "Volltext laden"}
</button>
</div>
</div>
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-slate-800 bg-slate-900">
<TranscriptPane messages={messages} />
<SendMessageForm onSend={handleSend} />
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createGame, Game, listGames } from "../api/games";
import GameCard from "../components/GameCard";
export default function GamesList() {
const navigate = useNavigate();
const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
try {
setGames(await listGames());
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
async function handleCreate(e: FormEvent) {
e.preventDefault();
setError(null);
try {
const game = await createGame(name, description);
navigate(`/games/${game.id}`);
} catch {
setError("Spiel konnte nicht erstellt werden.");
}
}
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-amber-400">Spiele</h1>
<div className="flex gap-2">
<button
onClick={() => setShowCreate((v) => !v)}
className="rounded bg-amber-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400"
>
Neues Spiel
</button>
</div>
</div>
{showCreate && (
<form onSubmit={handleCreate} className="mb-6 flex flex-col gap-3 rounded-lg border border-slate-800 bg-slate-900 p-4">
<input
required
placeholder="Name des Spiels"
value={name}
onChange={(e) => setName(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
<textarea
placeholder="Kurzbeschreibung (Genre, Welt, ...)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
rows={3}
/>
<button
type="submit"
className="self-start rounded bg-amber-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-amber-400"
>
Erstellen
</button>
</form>
)}
{error && <p className="mb-4 text-sm text-red-400">{error}</p>}
{loading ? (
<p className="text-slate-400">Lädt…</p>
) : games.length === 0 ? (
<p className="text-slate-400">Noch keine Spiele vorhanden.</p>
) : (
<div className="flex flex-col gap-3">
{games.map((game) => (
<GameCard key={game.id} game={game} />
))}
</div>
)}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { FormEvent, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await login(email, password);
navigate("/games");
} catch {
setError("Anmeldung fehlgeschlagen. E-Mail oder Passwort falsch.");
} finally {
setSubmitting(false);
}
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Anmelden</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="email"
required
placeholder="E-Mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
<input
type="password"
required
placeholder="Passwort"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={submitting}
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
>
Anmelden
</button>
</form>
<div className="mt-4 flex justify-between text-sm text-slate-400">
<Link to="/register" className="hover:text-amber-400">
Registrieren
</Link>
<Link to="/forgot-password" className="hover:text-amber-400">
Passwort vergessen?
</Link>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { FormEvent, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
export default function Register() {
const { register } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await register(email, password, name);
navigate("/games");
} catch {
setError("Registrierung fehlgeschlagen. E-Mail evtl. schon vergeben oder Passwort zu schwach.");
} finally {
setSubmitting(false);
}
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Registrieren</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
required
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
<input
type="email"
required
placeholder="E-Mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
<input
type="password"
required
minLength={8}
placeholder="Passwort (mind. 8 Zeichen)"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={submitting}
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400 disabled:opacity-50"
>
Konto erstellen
</button>
</form>
<div className="mt-4 text-sm text-slate-400">
<Link to="/login" className="hover:text-amber-400">
Schon registriert? Anmelden
</Link>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { FormEvent, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { resetPassword } from "../api/auth";
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token") ?? "";
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
try {
await resetPassword(token, password);
setDone(true);
setTimeout(() => navigate("/login"), 1500);
} catch {
setError("Zurücksetzen fehlgeschlagen. Der Link ist evtl. abgelaufen.");
}
}
if (!token) {
return (
<div className="mx-auto mt-16 max-w-sm px-4 text-slate-300">
Kein gültiger Reset-Link.{" "}
<Link to="/forgot-password" className="text-amber-400">
Neu anfordern
</Link>
</div>
);
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Neues Passwort setzen</h1>
{done ? (
<p className="text-slate-300">Passwort geändert. Du wirst weitergeleitet…</p>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="password"
required
minLength={8}
placeholder="Neues Passwort"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded bg-slate-800 px-3 py-2 outline-none focus:ring-2 focus:ring-amber-400"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
className="rounded bg-amber-500 px-3 py-2 font-semibold text-slate-950 hover:bg-amber-400"
>
Passwort setzen
</button>
</form>
)}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />