Files
History/src/components/PresetPicker.tsx
T
kamilkrzywda 17d6cecab9
CI / build + unit + e2e (pull_request) Successful in 1m14s
feat: remove a saved game & redirect on missing/deleted game (#20)
- DELETE /api/games/:id route + Db.delete() (404 when absent)
- PresetPicker: per-saved-game trash button + confirm dialog (Cancel/Delete)
- App: runDeleteGame; useSse onError -> goHome on 'deleted'/'not found'
  (a deleted/missing game now returns the live tab and any deep link home)
- DeleteGameResponse in shared/protocol.ts; api.deleteGame()
- unit test (Db.delete) + 2 e2e tests (home-screen delete; in-flight delete
  returns the active tab home and a fresh deep link lands on home)
- version 0.7.0 -> 0.8.0 + changelog

Fixes #20
2026-10-11 20:32:55 +00:00

197 lines
7.3 KiB
TypeScript

import { useState } from 'react';
import { Play, Trash2 } from 'lucide-react';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import type { PresetMeta } from '@shared/types';
import type { GameSummary } from '@shared/protocol';
interface Props {
presets: PresetMeta[];
loading?: boolean;
onPlay: (presetId: string) => void;
/** Recent saved games (newest-updated first) for the "Saved games" section. */
savedGames?: GameSummary[];
/** The id remembered as the active game (drives the "Resume last game" button). */
activeGameId?: string | null;
/** Resume a saved game by id (shared by the resume button and saved-games rows). */
onResume?: (id: string) => void;
/** Delete a saved game by id (confirmed by a dialog in this component). */
onDelete?: (id: string) => void;
}
/**
* Home screen: a grid of scenario cards (each with a Play button), a prominent
* "Resume last game" affordance, and a "Saved games" section listing prior
* games so the user can get back into one — or delete it.
*/
export default function PresetPicker({
presets,
loading,
onPlay,
savedGames = [],
activeGameId,
onResume,
onDelete,
}: Props) {
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
// The game awaiting a delete confirmation (null = dialog closed).
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const confirmDeleteTarget = confirmDeleteId
? savedGames.find((g) => g.id === confirmDeleteId)
: undefined;
return (
<div className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">History</h1>
<p className="mt-2 max-w-2xl text-muted-foreground">
An AI-driven alternate-history simulator. Pick a scenario and a nation, then shape the
world turn by turn — the LLM is the world engine.
</p>
</header>
{/* Resume last game — prominent, shown whenever a game is remembered. */}
{activeGameId && onResume && (
<div
data-testid="resume-bar"
className="mb-8 rounded-xl border border-primary/30 bg-primary/5 p-3"
>
<Button
data-testid="resume-last"
className="w-full justify-between"
onClick={() => onResume(activeGameId)}
disabled={loading}
>
<span className="flex items-center gap-2">
<Play className="size-4" />
Resume last game
</span>
<span className="text-sm font-normal text-muted-foreground">
{activeGame ? `${activeGame.presetTitle} · ${activeGame.date}` : 'Pick up where you left off'}
</span>
</Button>
</div>
)}
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{presets.map((p) => (
<Card
key={p.id}
className="flex flex-col transition hover:ring-1 hover:ring-primary/40"
data-testid="preset-card"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-lg">{p.title}</CardTitle>
<Badge variant="secondary" className="shrink-0 text-xs">
{p.startDate}
</Badge>
</div>
<CardDescription className="text-sm leading-relaxed">{p.summary}</CardDescription>
</CardHeader>
<CardContent className="mt-auto">
<Button className="w-full" onClick={() => onPlay(p.id)} disabled={loading}>
Play
</Button>
</CardContent>
</Card>
))}
</div>
{/* Saved games: get back into any prior game (or delete it). */}
{savedGames.length > 0 && (
<section className="mt-12" data-testid="saved-games">
<h2 className="mb-4 text-xl font-bold tracking-tight">Saved games</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{savedGames.map((g) => (
<Card
key={g.id}
className="flex flex-col"
data-testid="saved-game"
data-game-id={g.id}
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">{g.presetTitle}</CardTitle>
<div className="flex shrink-0 items-center gap-1">
<Badge variant="secondary" className="text-xs">
{g.date}
</Badge>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
data-testid="delete-game"
aria-label={`Delete ${g.presetTitle}`}
title="Delete this game"
onClick={() => setConfirmDeleteId(g.id)}
disabled={loading}
>
<Trash2 />
</Button>
</div>
</div>
<CardDescription className="text-sm">Turn {g.turn}</CardDescription>
</CardHeader>
<CardContent className="mt-auto">
<Button
variant="secondary"
className="w-full"
onClick={() => onResume?.(g.id)}
disabled={loading}
>
Resume
</Button>
</CardContent>
</Card>
))}
</div>
</section>
)}
{/* Confirm before permanently deleting a saved game. */}
<Dialog open={confirmDeleteId !== null} onOpenChange={(o) => !o && setConfirmDeleteId(null)}>
<DialogContent className="max-w-sm" data-testid="delete-game-confirm-dialog">
<DialogHeader>
<DialogTitle>Delete this game?</DialogTitle>
<DialogDescription>
{confirmDeleteTarget
? `"${confirmDeleteTarget.presetTitle}" (turn ${confirmDeleteTarget.turn}, ${confirmDeleteTarget.date}) will be permanently removed. This cannot be undone.`
: 'This game will be permanently removed.'}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
data-testid="delete-game-cancel"
onClick={() => setConfirmDeleteId(null)}
>
Cancel
</Button>
<Button
variant="destructive"
data-testid="delete-game-confirm"
onClick={() => {
const id = confirmDeleteId;
setConfirmDeleteId(null);
if (id) onDelete?.(id);
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}