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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` : ""}`,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user