diff --git a/backend/app/api/routes_characters.py b/backend/app/api/routes_characters.py index 2e60677..315fdb9 100644 --- a/backend/app/api/routes_characters.py +++ b/backend/app/api/routes_characters.py @@ -1,12 +1,14 @@ import uuid from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.auth.users import current_active_user from app.db import get_async_session from app.models.character import Character +from app.models.game import GameParticipant +from app.models.message import Message from app.models.user import User from app.schemas.character import CharacterRead @@ -40,3 +42,30 @@ async def get_character( if character.owner_id != user.id: raise HTTPException(status_code=403, detail="Not your character") return character + + +@router.delete("/{character_id}", status_code=204) +async def delete_character( + character_id: uuid.UUID, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +) -> None: + character = await session.get(Character, character_id) + if character is None: + raise HTTPException(status_code=404, detail="Character not found") + if character.owner_id != user.id: + raise HTTPException(status_code=403, detail="Not your character") + + # game_participants.character_id and messages.character_id have no ON DELETE cascade + # (a character can outlive its games and vice versa) — detach them first so the delete + # doesn't hit a FK violation. Games and message history stay intact either way. + await session.execute( + update(GameParticipant) + .where(GameParticipant.character_id == character_id) + .values(character_id=None) + ) + await session.execute( + update(Message).where(Message.character_id == character_id).values(character_id=None) + ) + await session.delete(character) + await session.commit() diff --git a/backend/app/api/routes_games.py b/backend/app/api/routes_games.py index 42dd132..4346ac3 100644 --- a/backend/app/api/routes_games.py +++ b/backend/app/api/routes_games.py @@ -140,6 +140,23 @@ async def get_game( return serialized[0] +@router.delete("/{game_id}", status_code=204) +async def delete_game( + game_id: uuid.UUID, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +) -> None: + game = await session.get(Game, game_id) + if game is None: + raise HTTPException(status_code=404, detail="Game not found") + if game.creator_id != user.id: + raise HTTPException(status_code=403, detail="Only the creator can delete this game") + + # game_participants, messages, world_state, and adventure_chunks all cascade on games.id. + await session.delete(game) + await session.commit() + + async def _serialize_messages(session: AsyncSession, messages: list[Message]) -> list[MessageRead]: if not messages: return [] diff --git a/frontend/src/api/characters.ts b/frontend/src/api/characters.ts index 93039cf..48428f3 100644 --- a/frontend/src/api/characters.ts +++ b/frontend/src/api/characters.ts @@ -26,3 +26,6 @@ export interface Character { export const listCharacters = () => apiFetch("/api/characters"); export const getCharacter = (id: string) => apiFetch(`/api/characters/${id}`); + +export const deleteCharacter = (id: string) => + apiFetch(`/api/characters/${id}`, { method: "DELETE" }); diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index c6044ff..cbd9f70 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -60,6 +60,8 @@ export const createGame = (name: string, description: string, adventureText?: st export const getGame = (id: string) => apiFetch(`/api/games/${id}`); +export const deleteGame = (id: string) => apiFetch(`/api/games/${id}`, { method: "DELETE" }); + export const joinGame = (id: string, participation_code: string, character_id?: string) => apiFetch(`/api/games/${id}/join`, { method: "POST", diff --git a/frontend/src/components/CharacterCard.tsx b/frontend/src/components/CharacterCard.tsx index 1520133..b197787 100644 --- a/frontend/src/components/CharacterCard.tsx +++ b/frontend/src/components/CharacterCard.tsx @@ -1,17 +1,60 @@ +import { useState } from "react"; import { Link } from "react-router-dom"; import { Character } from "../api/characters"; +import { useAuth } from "../hooks/useAuth"; +import ConfirmDialog from "./ConfirmDialog"; + +export default function CharacterCard({ + character, + onDelete, +}: { + character: Character; + onDelete?: (id: string) => void; +}) { + const { user } = useAuth(); + const [confirming, setConfirming] = useState(false); + const isOwner = user?.id === character.owner_id; + + function handleDeleteClick(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + setConfirming(true); + } -export default function CharacterCard({ character }: { character: Character }) { return ( - -

{character.name}

-

- {character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level} -

- + <> + +
+

{character.name}

+ {isOwner && onDelete && ( + + )} +
+

+ {character.race ?? "?"} · {character.char_class ?? "?"} · Stufe {character.level} +

+ + + setConfirming(false)} + onConfirm={() => { + setConfirming(false); + onDelete?.(character.id); + }} + /> + ); } diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..7209120 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,46 @@ +export default function ConfirmDialog({ + open, + title, + message, + confirmLabel = "Löschen", + onConfirm, + onCancel, +}: { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + onConfirm: () => void; + onCancel: () => void; +}) { + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +

{title}

+

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/GameCard.tsx b/frontend/src/components/GameCard.tsx index 72955dc..fab98c4 100644 --- a/frontend/src/components/GameCard.tsx +++ b/frontend/src/components/GameCard.tsx @@ -2,10 +2,21 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import { Game } from "../api/games"; +import { useAuth } from "../hooks/useAuth"; +import ConfirmDialog from "./ConfirmDialog"; -export default function GameCard({ game }: { game: Game }) { +export default function GameCard({ + game, + onDelete, +}: { + game: Game; + onDelete?: (id: string) => void; +}) { + const { user } = useAuth(); const created = new Date(game.created_at).toLocaleString("de-DE"); const [copied, setCopied] = useState(false); + const [confirming, setConfirming] = useState(false); + const isOwner = user?.id === game.creator_id; async function handleCopyLink(e: React.MouseEvent) { e.preventDefault(); @@ -16,50 +27,78 @@ export default function GameCard({ game }: { game: Game }) { setTimeout(() => setCopied(false), 1500); } + function handleDeleteClick(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + setConfirming(true); + } + return ( - -
-

{game.name}

- {game.status === "ended" && ( - - Beendet - - )} - {game.participation_code && ( - - )} -
-

{game.description}

-
- Erstellt: {created} - Von: {game.creator_name} - - {game.player_count} Spieler - {game.player_names.length > 0 && `: ${game.player_names.join(", ")}`} - - {game.has_adventure && 📖 Eigenes Abenteuer} -
- + Code: {game.participation_code} + + + + + {copied && Kopiert!} + + )} + +

{game.description}

+
+ Erstellt: {created} + Von: {game.creator_name} + + {game.player_count} Spieler + {game.player_names.length > 0 && `: ${game.player_names.join(", ")}`} + + {game.has_adventure && 📖 Eigenes Abenteuer} + {isOwner && onDelete && ( + + )} +
+ + + setConfirming(false)} + onConfirm={() => { + setConfirming(false); + onDelete?.(game.id); + }} + /> + ); } diff --git a/frontend/src/pages/CharactersList.tsx b/frontend/src/pages/CharactersList.tsx index 2b3b24d..5eb3a9c 100644 --- a/frontend/src/pages/CharactersList.tsx +++ b/frontend/src/pages/CharactersList.tsx @@ -1,11 +1,12 @@ import { useEffect, useState } from "react"; -import { Character, listCharacters } from "../api/characters"; +import { Character, deleteCharacter, listCharacters } from "../api/characters"; import CharacterCard from "../components/CharacterCard"; export default function CharactersList() { const [characters, setCharacters] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { listCharacters() @@ -13,9 +14,20 @@ export default function CharactersList() { .finally(() => setLoading(false)); }, []); + async function handleDelete(id: string) { + setError(null); + try { + await deleteCharacter(id); + setCharacters((prev) => prev.filter((c) => c.id !== id)); + } catch { + setError("Charakter konnte nicht gelöscht werden."); + } + } + return (

Charaktere

+ {error &&

{error}

} {loading ? (

Lädt…

) : characters.length === 0 ? ( @@ -25,7 +37,7 @@ export default function CharactersList() { ) : (
{characters.map((c) => ( - + ))}
)} diff --git a/frontend/src/pages/GamesList.tsx b/frontend/src/pages/GamesList.tsx index 3e6dc48..a1b2d60 100644 --- a/frontend/src/pages/GamesList.tsx +++ b/frontend/src/pages/GamesList.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Game, listGames } from "../api/games"; +import { deleteGame, Game, listGames } from "../api/games"; import GameCard from "../components/GameCard"; import GameSetupChat from "../components/GameSetupChat"; @@ -10,6 +10,7 @@ export default function GamesList() { const [games, setGames] = useState([]); const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); + const [error, setError] = useState(null); async function load() { setLoading(true); @@ -24,6 +25,16 @@ export default function GamesList() { load(); }, []); + async function handleDelete(id: string) { + setError(null); + try { + await deleteGame(id); + setGames((prev) => prev.filter((g) => g.id !== id)); + } catch { + setError("Spiel konnte nicht gelöscht werden."); + } + } + return (
@@ -40,6 +51,8 @@ export default function GamesList() { {showCreate && navigate(`/games/${game.id}`)} />} + {error &&

{error}

} + {loading ? (

Lädt…

) : games.length === 0 ? ( @@ -47,7 +60,7 @@ export default function GamesList() { ) : (
{games.map((game) => ( - + ))}
)}