feat(mana/web): play Who games inline on workbench page

Embed PlayView directly in the ListView so games can be started
and played without navigating away from the workbench. PlayView
now accepts an onBack callback instead of hardcoded goto('/who').

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 17:37:42 +02:00
parent 05f4da5db3
commit d3b9805341
3 changed files with 107 additions and 99 deletions

View file

@ -2,23 +2,27 @@
Who — main module view.
Shows the four decks at the top, the user's past games below.
Picking a deck calls whoGamesStore.start() and navigates to the
play view. Past games can be reopened (won/surrendered show the
full chat read-only) or deleted.
Picking a deck starts a game and shows PlayView inline. Past
games can be reopened (won/surrendered show the full chat
read-only) or deleted.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { allGames$, gameStatusLabel } from './queries';
import { whoGamesStore } from './stores/games.svelte';
import type { WhoDeckId, WhoGame, WhoDeckMeta } from './types';
import type { ViewProps } from '$lib/app-registry';
import { authStore } from '$lib/stores/auth.svelte';
import PlayView from './views/PlayView.svelte';
let { navigate, goBack, params }: ViewProps = $props();
let games = $state<WhoGame[]>([]);
let decks = $state<WhoDeckMeta[]>([]);
let loadingDecks = $state(true);
let starting = $state<WhoDeckId | null>(null);
let error = $state<string | null>(null);
let activeGameId = $state<string | null>(null);
$effect(() => {
const sub = allGames$.subscribe((val) => {
@ -56,7 +60,7 @@
error = null;
try {
const gameId = await whoGamesStore.start(deckId);
await goto(`/who/play/${gameId}`);
activeGameId = gameId;
} catch (e) {
error = e instanceof Error ? e.message : 'Spiel konnte nicht gestartet werden';
} finally {
@ -64,6 +68,10 @@
}
}
function openGame(gameId: string) {
activeGameId = gameId;
}
async function deleteGame(gameId: string) {
if (!confirm('Spiel wirklich löschen?')) return;
await whoGamesStore.deleteGame(gameId);
@ -105,104 +113,104 @@
}
</script>
<div class="flex h-full flex-col gap-6 p-3 sm:p-4">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-white/90">Who?</h1>
<p class="mt-1 text-sm text-white/60">
Errate die historische Persönlichkeit. Eine KI verkörpert sie ohne den Namen zu verraten.
</p>
</div>
{#if activeGameId}
<PlayView gameId={activeGameId} onBack={() => (activeGameId = null)} />
{:else}
<div class="flex h-full flex-col gap-6 p-3 sm:p-4">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-white/90">Who?</h1>
<p class="mt-1 text-sm text-white/60">
Errate die historische Persönlichkeit. Eine KI verkörpert sie ohne den Namen zu verraten.
</p>
</div>
<!-- Deck picker -->
<section>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-white/50">
Neues Spiel starten
</h2>
{#if loadingDecks}
<div class="grid gap-3 sm:grid-cols-2">
{#each Array(4) as _, i (i)}
<div class="h-24 animate-pulse rounded-lg bg-white/5"></div>
{/each}
</div>
{:else if decks.length === 0}
<p class="text-sm text-white/40">Keine Decks verfügbar.</p>
{:else}
<div class="grid gap-3 sm:grid-cols-2">
{#each decks as deck (deck.id)}
<button
type="button"
onclick={() => startGame(deck.id)}
disabled={starting !== null}
class="group flex flex-col items-start gap-2 rounded-lg border border-white/10 bg-white/[0.02] p-4 text-left transition hover:border-white/20 hover:bg-white/[0.05] disabled:cursor-wait disabled:opacity-50"
style="border-left: 3px solid {deckColor(deck.id)}"
>
<div class="flex w-full items-center justify-between">
<span class="text-base font-medium text-white/90">{deck.name.de}</span>
<span
class="rounded-full bg-white/5 px-2 py-0.5 text-[10px] uppercase tracking-wide text-white/50"
>
{difficultyLabel(deck.difficulty)}
</span>
</div>
<p class="text-xs text-white/60">{deck.description.de}</p>
<p class="text-[11px] text-white/40">
{deck.characterCount} Personen · {deck.categories.join(', ')}
</p>
{#if starting === deck.id}
<p class="text-xs text-white/70">Starte…</p>
{/if}
</button>
{/each}
</div>
{/if}
</section>
<!-- Past games -->
{#if games.length > 0}
<!-- Deck picker -->
<section>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-white/50">
Vergangene Spiele
Neues Spiel starten
</h2>
<ul class="divide-y divide-white/5 rounded-lg border border-white/10 bg-white/[0.02]">
{#each games as game (game.id)}
<li class="flex items-center gap-3 px-3 py-2.5">
<span class="text-lg">{statusEmoji(game.status)}</span>
{#if loadingDecks}
<div class="grid gap-3 sm:grid-cols-2">
{#each Array(4) as _, i (i)}
<div class="h-24 animate-pulse rounded-lg bg-white/5"></div>
{/each}
</div>
{:else if decks.length === 0}
<p class="text-sm text-white/40">Keine Decks verfügbar.</p>
{:else}
<div class="grid gap-3 sm:grid-cols-2">
{#each decks as deck (deck.id)}
<button
type="button"
class="flex-1 text-left"
onclick={() => goto(`/who/play/${game.id}`)}
onclick={() => startGame(deck.id)}
disabled={starting !== null}
class="group flex flex-col items-start gap-2 rounded-lg border border-white/10 bg-white/[0.02] p-4 text-left transition hover:border-white/20 hover:bg-white/[0.05] disabled:cursor-wait disabled:opacity-50"
style="border-left: 3px solid {deckColor(deck.id)}"
>
<div class="text-sm text-white/90">
{#if game.revealedName}
<span class="font-medium">{game.revealedName}</span>
{:else if game.status === 'playing'}
<span class="text-white/60">Laufendes Spiel</span>
{:else}
<span class="text-white/60">Aufgegeben</span>
{/if}
</div>
<div class="text-[11px] text-white/40">
{game.deckId} · {game.messageCount} Nachrichten · {gameStatusLabel(game.status)}
<div class="flex w-full items-center justify-between">
<span class="text-base font-medium text-white/90">{deck.name.de}</span>
<span
class="rounded-full bg-white/5 px-2 py-0.5 text-[10px] uppercase tracking-wide text-white/50"
>
{difficultyLabel(deck.difficulty)}
</span>
</div>
<p class="text-xs text-white/60">{deck.description.de}</p>
<p class="text-[11px] text-white/40">
{deck.characterCount} Personen · {deck.categories.join(', ')}
</p>
{#if starting === deck.id}
<p class="text-xs text-white/70">Starte…</p>
{/if}
</button>
<button
type="button"
class="rounded p-1 text-white/30 hover:bg-white/5 hover:text-white/60"
onclick={() => deleteGame(game.id)}
title="Löschen"
>
</button>
</li>
{/each}
</ul>
{/each}
</div>
{/if}
</section>
{/if}
{#if error}
<div class="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300">
{error}
</div>
{/if}
</div>
<!-- Past games -->
{#if games.length > 0}
<section>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-white/50">
Vergangene Spiele
</h2>
<ul class="divide-y divide-white/5 rounded-lg border border-white/10 bg-white/[0.02]">
{#each games as game (game.id)}
<li class="flex items-center gap-3 px-3 py-2.5">
<span class="text-lg">{statusEmoji(game.status)}</span>
<button type="button" class="flex-1 text-left" onclick={() => openGame(game.id)}>
<div class="text-sm text-white/90">
{#if game.revealedName}
<span class="font-medium">{game.revealedName}</span>
{:else if game.status === 'playing'}
<span class="text-white/60">Laufendes Spiel</span>
{:else}
<span class="text-white/60">Aufgegeben</span>
{/if}
</div>
<div class="text-[11px] text-white/40">
{game.deckId} · {game.messageCount} Nachrichten · {gameStatusLabel(game.status)}
</div>
</button>
<button
type="button"
class="rounded p-1 text-white/30 hover:bg-white/5 hover:text-white/60"
onclick={() => deleteGame(game.id)}
title="Löschen"
>
</button>
</li>
{/each}
</ul>
</section>
{/if}
{#if error}
<div class="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300">
{error}
</div>
{/if}
</div>
{/if}

View file

@ -9,12 +9,11 @@
-->
<script lang="ts">
import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { whoGamesStore } from '../stores/games.svelte';
import { gameByIdLive, messagesForGameLive } from '../queries';
import type { WhoGame, WhoMessage } from '../types';
let { gameId }: { gameId: string } = $props();
let { gameId, onBack }: { gameId: string; onBack: () => void } = $props();
let game = $state<WhoGame | null>(null);
let messages = $state<WhoMessage[]>([]);
@ -121,7 +120,7 @@
<button
type="button"
class="rounded p-1.5 text-white/60 hover:bg-white/5 hover:text-white/90"
onclick={() => goto('/who')}
onclick={onBack}
aria-label="Zurück"
>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import PlayView from '$lib/modules/who/views/PlayView.svelte';
@ -10,5 +11,5 @@
</svelte:head>
{#if gameId}
<PlayView {gameId} />
<PlayView {gameId} onBack={() => goto('/who')} />
{/if}