Add character detail, markdown rendering, wider layout, and LLM provider switch

- Character sheets gain structured combat_stats, abilities (name +
  plain-language explanation), and equipment fields, populated by the
  upsert_character_sheet tool; the detail page renders them as their
  own sections instead of one prose block, with a player-card-style
  attribute grid (full ability names + modifiers).
- Fix double-escaped unicode occasionally left in tool-call JSON
  arguments (a model quirk) and stop the app's own json.dumps calls
  from re-introducing it (ensure_ascii=False).
- Render chat messages as markdown (react-markdown + Tailwind
  typography) instead of raw text, and widen the games/characters/
  chat layout to use more of the screen on desktop.
- Sharpen the DM system prompt: state the ability + DC for each
  offered action option before the player commits, and narrate rolls
  as DC → result → pass/fail before the story consequence; also stop
  re-asking Session-0 questions already answered in the game's
  name/description.
- Make the DM's LLM provider configurable (xai default, lmstudio for
  a local OpenAI-compatible server via host.docker.internal) instead
  of hardcoded to x.ai.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thorsten
2026-09-01 15:18:15 +02:00
parent ec53ed3aba
commit 624644619c
26 changed files with 1503 additions and 119 deletions
+13 -1
View File
@@ -1,7 +1,19 @@
# Copy this file to .env and fill in real values. Never commit .env.
# x.ai API (Grok) — DM model. Get a key at https://console.x.ai
# DM model provider: "xai" (default, cloud) or "lmstudio" (local, free, no API cost)
LLM_PROVIDER=xai
# x.ai API (Grok) — used when LLM_PROVIDER=xai. Get a key at https://console.x.ai
XAI_API_KEY=
DM_MODEL=grok-4.6
# LM Studio local server — used when LLM_PROVIDER=lmstudio. Use host.docker.internal, not a
# LAN IP — the backend runs inside Docker and can't necessarily reach the host's LAN address
# (e.g. a WSL2-internal IP the host itself sees but containers can't route to).
LMSTUDIO_BASE_URL=http://host.docker.internal:1234/v1
LMSTUDIO_API_KEY=
# When LLM_PROVIDER=lmstudio, also set DM_MODEL above to the model id LM Studio reports at
# GET /v1/models, e.g. DM_MODEL=qwen2.5-14b-instruct
# Auth secrets — generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
JWT_SECRET=
@@ -0,0 +1,48 @@
"""add character combat_stats, abilities, equipment fields
Revision ID: b8e4f1a9c203
Revises: 7c3f9a2e1d44
Create Date: 2026-09-01 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'b8e4f1a9c203'
down_revision: Union[str, None] = '7c3f9a2e1d44'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'characters',
sa.Column(
'combat_stats', postgresql.JSONB(astext_type=sa.Text()), nullable=False,
server_default='{}',
),
)
op.add_column(
'characters',
sa.Column(
'abilities', postgresql.JSONB(astext_type=sa.Text()), nullable=False,
server_default='[]',
),
)
op.add_column(
'characters',
sa.Column(
'equipment', postgresql.JSONB(astext_type=sa.Text()), nullable=False,
server_default='[]',
),
)
def downgrade() -> None:
op.drop_column('characters', 'equipment')
op.drop_column('characters', 'abilities')
op.drop_column('characters', 'combat_stats')
+9
View File
@@ -6,10 +6,19 @@ class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://dnd:changeme@postgres:5432/dnd"
# "xai" (default, cloud) or "lmstudio" (local, OpenAI-compatible server)
llm_provider: str = "xai"
xai_api_key: str = ""
xai_base_url: str = "https://api.x.ai/v1"
dm_model: str = "grok-4.6"
# host.docker.internal, not a LAN IP — the backend runs inside Docker Desktop's own
# network namespace, which can reach the Windows host via this name but not necessarily
# via whatever IP the host itself sees for the machine (e.g. a WSL2-internal address).
lmstudio_base_url: str = "http://host.docker.internal:1234/v1"
lmstudio_api_key: str = ""
jwt_secret: str = "dev-insecure-secret-change-me"
reset_password_secret: str = "dev-insecure-secret-change-me"
verification_token_secret: str = "dev-insecure-secret-change-me"
+5
View File
@@ -20,4 +20,9 @@ def get_game_setup_prompt() -> str:
@lru_cache
def get_llm_client() -> AsyncOpenAI:
if settings.llm_provider == "lmstudio":
# LM Studio's local server accepts any non-empty string when no key is configured.
return AsyncOpenAI(
api_key=settings.lmstudio_api_key or "lm-studio", base_url=settings.lmstudio_base_url
)
return AsyncOpenAI(api_key=settings.xai_api_key, base_url=settings.xai_base_url)
+3 -2
View File
@@ -2,6 +2,7 @@ import json
from app.config import settings
from app.llm.client import get_game_setup_prompt, get_llm_client
from app.llm.json_utils import fix_double_escaped_unicode
from app.llm.tools import game_setup as game_setup_tool
MAX_ROUNDS = 3
@@ -39,14 +40,14 @@ async def run_game_setup_turn(client_messages: list[dict]) -> dict:
messages.append(message.model_dump(exclude_unset=True))
for tool_call in message.tool_calls:
args = json.loads(tool_call.function.arguments)
args = fix_double_escaped_unicode(json.loads(tool_call.function.arguments))
if tool_call.function.name == "propose_game_setup":
proposal = {"name": args.get("name", ""), "description": args.get("description", "")}
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps({"status": "ok"}),
"content": json.dumps({"status": "ok"}, ensure_ascii=False),
}
)
if proposal:
+25
View File
@@ -0,0 +1,25 @@
import re
# Some models occasionally leave literal backslash-u escape sequences un-decoded inside
# tool-call argument strings (a known JSON-generation quirk) — e.g. the string ends up
# containing the six characters ü instead of the character ü. json.loads() alone
# doesn't catch this because the escaping happened *inside* an already-valid JSON string,
# not at the JSON structural level.
_DOUBLE_ESCAPED_UNICODE_RE = re.compile(r"\\u([0-9a-fA-F]{4})")
def _fix_string(text: str) -> str:
if "\\u" not in text:
return text
return _DOUBLE_ESCAPED_UNICODE_RE.sub(lambda m: chr(int(m.group(1), 16)), text)
def fix_double_escaped_unicode(value):
"""Recursively repair double-escaped unicode in a parsed JSON value (dict/list/str)."""
if isinstance(value, str):
return _fix_string(value)
if isinstance(value, list):
return [fix_double_escaped_unicode(v) for v in value]
if isinstance(value, dict):
return {k: fix_double_escaped_unicode(v) for k, v in value.items()}
return value
+3 -2
View File
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.llm.client import get_dm_system_prompt, get_llm_client
from app.llm.context import build_context
from app.llm.json_utils import fix_double_escaped_unicode
from app.llm.tools import character_sheet, dice
from app.models.game import Game
from app.models.message import Message
@@ -81,13 +82,13 @@ async def run_dm_turn(
messages.append(message.model_dump(exclude_unset=True))
for tool_call in message.tool_calls:
tool_input = json.loads(tool_call.function.arguments)
tool_input = fix_double_escaped_unicode(json.loads(tool_call.function.arguments))
result = await _execute_tool_call(session, game_id, tool_call.function.name, tool_input)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result),
"content": json.dumps(result, ensure_ascii=False),
}
)
else:
+2 -2
View File
@@ -16,8 +16,8 @@ Phase 2: Das Spiel leiten
* Sobald Charaktere feststehen, sprichst du die Spieler in Spielszenen mit ihrem Charakternamen an. Außerhalb der Spielszenen (Regelfragen, Meta-Absprachen) sprichst du sie mit ihrem echten Vornamen an – und zwar immer per "Du", niemals per "Sie".
* Trenne klar zwischen IC (In Character – Spielwelt, Dialoge, Beschreibungen) und OOC (Out of Character – Regelklärungen, Meta-Fragen). Kennzeichne OOC-Kommentare deutlich, z. B. mit "(OOC: ...)".
* Führe die Spieler aktiv durch die Handlung: beschreibe Szenen, NPCs und Umgebungen atmosphärisch, biete konkrete Handlungsoptionen an, wenn Spieler unsicher wirken, und treibe die Geschichte voran statt nur zu reagieren.
* Für Würfelproben: Sage klar, welcher Wurf nötig ist (z. B. "Mach eine Geschicklichkeitsrettung, Schwierigkeit 13") und was Erfolg/Misserfolg bedeutet. Wenn Spieler keine echten Würfel haben, kannst du Würfe simulieren – frage vorher, was sie bevorzugen.
* Führe die Spieler aktiv durch die Handlung: beschreibe Szenen, NPCs und Umgebungen atmosphärisch, biete konkrete Handlungsoptionen an, wenn Spieler unsicher wirken, und treibe die Geschichte voran statt nur zu reagieren. Erfordert eine Option eine Probe, nenne die betroffene Fähigkeit und den Schwierigkeitsgrad direkt bei der Option (z. B. „B) Die Läden zuwerfen (Stärke, SG 12)"), damit der Spieler seine Chancen vorher einschätzen kann.
* Für Würfelproben: Nenne IMMER zuerst klar, welche Fähigkeit/Fertigkeit gewürfelt wird und welcher Schwierigkeitsgrad (SG) nötig ist – bevor gewürfelt wird. Nutze für jeden spielrelevanten Wurf das roll_dice-Werkzeug, nie ein erfundenes Ergebnis. Erkläre das Ergebnis danach ausführlich in dieser Reihenfolge: (1) benötigter SG, (2) gewürfeltes Ergebnis inklusive Bonus, (3) ob es reicht oder nicht – erst danach folgt die erzählerische Konsequenz. Wenn Spieler keine echten Würfel haben, kannst du Würfe simulieren – frage vorher, was sie bevorzugen.
* Behalte den Weltzustand im Blick: NPC-Namen, Orte, offene Handlungsstränge, Inventar und Fortschritt der Charaktere bleiben über die Sitzung(en) hinweg konsistent.
* Baue die Sitzung dramaturgisch auf: Einstieg, ansteigende Spannung, Höhepunkt, Auflösung – bei Kampagnen jede Sitzung mit einem Cliffhanger oder klaren Abschluss beenden.
* Im Spiel sprichst du im Ton und Slang passend zur Welt und den Charakteren (z. B. archaisch-episch in High Fantasy, derb-direkt bei Piraten). Außerhalb bleibst du locker, freundlich und klar in der Ansprache.
+39 -3
View File
@@ -11,8 +11,8 @@ TOOL_SCHEMA = {
"name": "upsert_character_sheet",
"description": (
"Create or update a player's character sheet as facts about them are confirmed or change during play "
"(name, race, class, ability stats, abilities, backstory). Call this proactively as soon as details are "
"settled — do not wait until the end of the session."
"(name, race, class, ability stats, combat stats, abilities, equipment, backstory). Call this "
"proactively as soon as details are settled — do not wait until the end of the session."
),
"parameters": {
"type": "object",
@@ -32,9 +32,39 @@ TOOL_SCHEMA = {
"type": "object",
"description": "Ability scores, e.g. {\"STR\": 15, \"DEX\": 12, \"CON\": 14, \"INT\": 10, \"WIS\": 8, \"CHA\": 13}",
},
"combat_stats": {
"type": "object",
"description": (
"Key/value readouts, e.g. {\"HP\": \"13/13\", \"RK\": \"19\", \"Initiative\": \"+1\", "
"\"Tempo\": \"7,5 m\", \"Übungsbonus\": \"+2\"}. Send the full current set each time — "
"this replaces whatever was stored before."
),
},
"abilities": {
"type": "array",
"description": (
"Every notable class feature, racial trait, feat, or spell the character has — one "
"entry per ability. Each 'description' must be a clear, beginner-friendly explanation "
"of what it does and when/how to use it, not just its rules name. Send the full current "
"list each time — this replaces whatever was stored before."
),
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["name", "description"],
},
},
"equipment": {
"type": "array",
"description": "Full current equipment/inventory list, one item per string. Replaces whatever was stored before.",
"items": {"type": "string"},
},
"description": {
"type": "string",
"description": "Free-text description: special abilities, oaths, backstory, anything relevant.",
"description": "Backstory/background narrative only — oaths, personality, motivation, current situation. Abilities and equipment go in their own fields, not here.",
},
},
"required": ["player_name"],
@@ -77,6 +107,12 @@ async def upsert(session: AsyncSession, game_id: uuid.UUID, tool_input: dict) ->
setattr(character, field, tool_input[field])
if "stats" in tool_input and tool_input["stats"] is not None:
character.stats = {**(character.stats or {}), **tool_input["stats"]}
if "combat_stats" in tool_input and tool_input["combat_stats"] is not None:
character.combat_stats = {**(character.combat_stats or {}), **tool_input["combat_stats"]}
if "abilities" in tool_input and tool_input["abilities"] is not None:
character.abilities = tool_input["abilities"]
if "equipment" in tool_input and tool_input["equipment"] is not None:
character.equipment = tool_input["equipment"]
await session.flush()
+3
View File
@@ -23,6 +23,9 @@ class Character(Base):
level: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
stats: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
combat_stats: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
abilities: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
equipment: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
+3
View File
@@ -12,6 +12,9 @@ class CharacterRead(BaseModel):
char_class: str | None
level: int
stats: dict
combat_stats: dict
abilities: list
equipment: list
description: str
created_at: datetime
updated_at: datetime
+1201 -79
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -9,8 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.20",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
+8
View File
@@ -1,5 +1,10 @@
import { apiFetch } from "./client";
export interface CharacterAbility {
name: string;
description: string;
}
export interface Character {
id: string;
owner_id: string;
@@ -8,6 +13,9 @@ export interface Character {
char_class: string | null;
level: number;
stats: Record<string, number>;
combat_stats: Record<string, string>;
abilities: CharacterAbility[];
equipment: string[];
description: string;
created_at: string;
updated_at: string;
+3 -2
View File
@@ -1,6 +1,7 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { createGame, Game, GameSetupProposal, runGameSetupChat } from "../api/games";
import MarkdownContent from "./MarkdownContent";
import SendMessageForm from "./SendMessageForm";
interface DisplayTurn {
@@ -94,8 +95,8 @@ export default function GameSetupChat({ onCreated }: { onCreated: (game: Game) =
}
>
{turn.role === "assistant" ? "Dungeon Master" : "Du"}:
</span>{" "}
<span className="whitespace-pre-wrap">{turn.content}</span>
</span>
<MarkdownContent content={turn.content} />
</div>
))}
{loading && <p className="text-sm text-slate-500">Der Dungeon Master überlegt…</p>}
@@ -0,0 +1,9 @@
import ReactMarkdown from "react-markdown";
export default function MarkdownContent({ content }: { content: string }) {
return (
<div className="prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-headings:my-1">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
);
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import { Message } from "../api/games";
import MarkdownContent from "./MarkdownContent";
function senderLabel(message: Message): string {
if (message.sender_type === "dm") return "Dungeon Master";
@@ -36,8 +37,8 @@ export default function TranscriptPane({
}
>
{senderLabel(m)}:
</span>{" "}
<span className="whitespace-pre-wrap">{m.content}</span>
</span>
<MarkdownContent content={m.content} />
</div>
))}
{typing && (
+110 -14
View File
@@ -3,6 +3,30 @@ import { Link, useParams, useSearchParams } from "react-router-dom";
import { Character, getCharacter } from "../api/characters";
const ABILITY_NAMES: Record<string, string> = {
STR: "Stärke",
DEX: "Geschicklichkeit",
CON: "Konstitution",
INT: "Intelligenz",
WIS: "Weisheit",
CHA: "Charisma",
};
const ABILITY_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
function orderedStats(stats: Record<string, number>): [string, number][] {
const known = ABILITY_ORDER.filter((key) => key in stats).map(
(key) => [key, stats[key]] as [string, number],
);
const unknown = Object.entries(stats).filter(([key]) => !ABILITY_ORDER.includes(key));
return [...known, ...unknown];
}
function modifier(score: number): string {
const mod = Math.floor((score - 10) / 2);
return mod >= 0 ? `+${mod}` : `${mod}`;
}
export default function CharacterDetail() {
const { characterId } = useParams<{ characterId: string }>();
const [searchParams] = useSearchParams();
@@ -22,7 +46,7 @@ export default function CharacterDetail() {
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">
<div className="mx-auto max-w-6xl px-4 py-8">
{backGameId && (
<Link
to={`/games/${backGameId}`}
@@ -32,24 +56,96 @@ export default function CharacterDetail() {
</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>
<div className="rounded-lg border border-amber-500/30 bg-slate-900 p-6">
<h1 className="text-3xl font-bold text-amber-400">{character.name}</h1>
<p className="mt-1 text-slate-400">
{character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level}
</p>
</div>
{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 className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Attribute
</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{orderedStats(character.stats).map(([key, value]) => (
<div
key={key}
className="flex flex-col items-center rounded-lg border border-slate-700 bg-slate-800 py-4"
>
<div className="text-xs font-semibold uppercase tracking-wide text-slate-400">
{ABILITY_NAMES[key] ?? key}
</div>
<div className="mt-1 text-3xl font-bold text-slate-100">{value}</div>
<div className="mt-1 rounded-full bg-slate-700 px-2 py-0.5 text-sm font-semibold text-amber-400">
{modifier(value)}
</div>
</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."}
{Object.keys(character.combat_stats).length > 0 && (
<div className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Kampfwerte
</h2>
<div className="flex flex-wrap gap-3">
{Object.entries(character.combat_stats).map(([key, value]) => (
<div
key={key}
className="rounded-lg border border-slate-700 bg-slate-800 px-4 py-2 text-center"
>
<div className="text-xs font-semibold uppercase tracking-wide text-slate-400">
{key}
</div>
<div className="text-lg font-bold text-slate-100">{value}</div>
</div>
))}
</div>
</div>
)}
{character.abilities.length > 0 && (
<div className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Fähigkeiten &amp; Merkmale
</h2>
<div className="flex flex-col gap-3">
{character.abilities.map((ability, i) => (
<div key={i} className="rounded-lg border border-slate-800 bg-slate-900 p-4">
<h3 className="font-semibold text-amber-400">{ability.name}</h3>
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-300">
{ability.description}
</p>
</div>
))}
</div>
</div>
)}
{character.equipment.length > 0 && (
<div className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Ausrüstung
</h2>
<ul className="list-disc rounded-lg border border-slate-800 bg-slate-900 p-4 pl-8 text-slate-300">
{character.equipment.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
)}
<div className="mt-6">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Hintergrundgeschichte
</h2>
<div className="whitespace-pre-wrap rounded-lg border border-slate-800 bg-slate-900 p-4 text-slate-300">
{character.description || "Noch keine Beschreibung."}
</div>
</div>
</div>
);
+2 -2
View File
@@ -14,7 +14,7 @@ export default function CharactersList() {
}, []);
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="mx-auto max-w-6xl 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>
@@ -23,7 +23,7 @@ export default function CharactersList() {
Noch keine Charaktere — sie entstehen im Laufe eines Spiels.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{characters.map((c) => (
<CharacterCard key={c.id} character={c} />
))}
+1 -1
View File
@@ -14,7 +14,7 @@ export default function ForgotPassword() {
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<div className="mx-auto mt-16 max-w-md px-4">
<h1 className="mb-6 text-2xl font-bold text-amber-400">Passwort vergessen</h1>
{sent ? (
<p className="text-slate-300">
+2 -2
View File
@@ -114,7 +114,7 @@ export default function GameSession() {
if (!game.is_participant) {
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<div className="mx-auto mt-16 max-w-md 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">
@@ -152,7 +152,7 @@ export default function GameSession() {
}
return (
<div className="mx-auto flex h-[calc(100vh-56px)] max-w-3xl flex-col px-4 py-4">
<div className="mx-auto flex h-[calc(100vh-56px)] max-w-6xl 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>
+2 -2
View File
@@ -25,7 +25,7 @@ export default function GamesList() {
}, []);
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="mx-auto max-w-6xl 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">
@@ -45,7 +45,7 @@ export default function GamesList() {
) : games.length === 0 ? (
<p className="text-slate-400">Noch keine Spiele vorhanden.</p>
) : (
<div className="flex flex-col gap-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{games.map((game) => (
<GameCard key={game.id} game={game} />
))}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function Login() {
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<div className="mx-auto mt-16 max-w-md 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
+1 -1
View File
@@ -27,7 +27,7 @@ export default function Register() {
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<div className="mx-auto mt-16 max-w-md 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
+2 -2
View File
@@ -25,7 +25,7 @@ export default function ResetPassword() {
if (!token) {
return (
<div className="mx-auto mt-16 max-w-sm px-4 text-slate-300">
<div className="mx-auto mt-16 max-w-md px-4 text-slate-300">
Kein gültiger Reset-Link.{" "}
<Link to="/forgot-password" className="text-amber-400">
Neu anfordern
@@ -35,7 +35,7 @@ export default function ResetPassword() {
}
return (
<div className="mx-auto mt-16 max-w-sm px-4">
<div className="mx-auto mt-16 max-w-md 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>
+3 -1
View File
@@ -1,8 +1,10 @@
import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
plugins: [typography],
};