diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md
index 12cf7b6..f900393 100644
--- a/docs/IMPLEMENTATION_PLAN.md
+++ b/docs/IMPLEMENTATION_PLAN.md
@@ -955,7 +955,7 @@ Web: Store/GameState/, Components/Game/, Hubs/GameHub
| ✓ | H5 | Games | Scheiss-Spiel + Trigger-Integration | 6 |
| ✓ | H6 | UI | Game Setup Dialog | 4 |
| ✓ | H7 | Integration | DayDetails Tabs | 3 |
-| ☐ | H8 | Features | Undo (unbegrenzt, ohne Redo) | 2 |
+| ✓ | H8 | Features | Undo (unbegrenzt, ohne Redo) | 2 |
| ☐ | H9 | Persistenz | DB Persistence & Recovery | 4 |
| ☐ | H9b | Sync | SignalR komplett + Auto-Reconnect | 5 |
| ☐ | H10 | Testing | Kern-Logik Tests | 2 |
diff --git a/src/Koogle.Web/Components/Game/GameInputPanel.razor b/src/Koogle.Web/Components/Game/GameInputPanel.razor
index 91822f6..ffb622d 100644
--- a/src/Koogle.Web/Components/Game/GameInputPanel.razor
+++ b/src/Koogle.Web/Components/Game/GameInputPanel.razor
@@ -51,18 +51,9 @@
@* Undo button *@
- @if (GameState.Value.UndoStack.Count > 0)
- {
-
-
- Letzten Wurf rückgängig (@GameState.Value.UndoStack.Count)
-
-
- }
+
+
+
@* Error display *@
@if (!string.IsNullOrEmpty(GameState.Value.Error))
@@ -271,18 +262,6 @@
await OnThrowCompleted.InvokeAsync(result);
}
- private void HandleUndo()
- {
- if (GameState.Value.UndoStack.Count > 0)
- {
- var lastSnapshot = GameState.Value.UndoStack[^1];
- Dispatcher.Dispatch(new UndoThrowSuccessAction(
- lastSnapshot.ThrowPanel,
- lastSnapshot.Participants,
- lastSnapshot.GameModel));
- }
- }
-
private async Task ShowPlayerSelector()
{
await OnShowPlayerSelector.InvokeAsync();
diff --git a/src/Koogle.Web/Components/Game/UndoButton.razor b/src/Koogle.Web/Components/Game/UndoButton.razor
new file mode 100644
index 0000000..e8519b3
--- /dev/null
+++ b/src/Koogle.Web/Components/Game/UndoButton.razor
@@ -0,0 +1,103 @@
+@using Koogle.Web.Store.GameState
+@inherits FluxorComponent
+
+@inject IState GameState
+@inject IDispatcher Dispatcher
+
+@* Only show when undo is available *@
+@if (CanUndo)
+{
+
+ @ButtonText
+
+}
+
+@code {
+ ///
+ /// Whether to show the button even when no undo is available.
+ ///
+ [Parameter]
+ public bool AlwaysShow { get; set; }
+
+ ///
+ /// Button variant (default: Outlined).
+ ///
+ [Parameter]
+ public Variant Variant { get; set; } = Variant.Outlined;
+
+ ///
+ /// Whether button should be full width.
+ ///
+ [Parameter]
+ public bool FullWidth { get; set; } = true;
+
+ ///
+ /// Button size.
+ ///
+ [Parameter]
+ public Size Size { get; set; } = Size.Medium;
+
+ ///
+ /// Additional CSS classes.
+ ///
+ [Parameter]
+ public string? Class { get; set; }
+
+ ///
+ /// Optional custom button text. If not set, shows count.
+ ///
+ [Parameter]
+ public string? CustomText { get; set; }
+
+ ///
+ /// Whether to show the undo count in the button text.
+ ///
+ [Parameter]
+ public bool ShowCount { get; set; } = true;
+
+ ///
+ /// Callback when undo is triggered.
+ ///
+ [Parameter]
+ public EventCallback OnUndoTriggered { get; set; }
+
+ private int UndoCount => GameState.Value.UndoStack.Count;
+ private bool CanUndo => UndoCount > 0 || AlwaysShow;
+ private bool IsDisabled => UndoCount == 0 || GameState.Value.IsSaving;
+
+ private string ButtonText
+ {
+ get
+ {
+ if (!string.IsNullOrEmpty(CustomText))
+ return CustomText;
+
+ var baseText = "Letzten Wurf rückgängig";
+ return ShowCount && UndoCount > 0
+ ? $"{baseText} ({UndoCount})"
+ : baseText;
+ }
+ }
+
+ private async Task HandleUndo()
+ {
+ if (UndoCount == 0)
+ return;
+
+ var lastSnapshot = GameState.Value.UndoStack[^1];
+
+ Dispatcher.Dispatch(new UndoThrowSuccessAction(
+ lastSnapshot.ThrowPanel,
+ lastSnapshot.Participants,
+ lastSnapshot.GameModel));
+
+ await OnUndoTriggered.InvokeAsync();
+ }
+}
diff --git a/src/Koogle.Web/Store/GameState/GameEffects.cs b/src/Koogle.Web/Store/GameState/GameEffects.cs
index 4a35682..5f9e112 100644
--- a/src/Koogle.Web/Store/GameState/GameEffects.cs
+++ b/src/Koogle.Web/Store/GameState/GameEffects.cs
@@ -10,24 +10,44 @@ namespace Koogle.Web.Store.GameState;
public class GameEffects
{
private readonly ILogger _logger;
+ private readonly IState _gameState;
///
/// Initializes a new instance of the GameEffects class.
///
- public GameEffects(ILogger logger)
+ public GameEffects(ILogger logger, IState gameState)
{
_logger = logger;
+ _gameState = gameState;
}
///
- /// Handles UndoThrowAction - pops last snapshot from stack.
+ /// Handles UndoThrowAction - pops last snapshot from stack and restores state.
///
[EffectMethod]
public Task HandleUndoThrow(UndoThrowAction action, IDispatcher dispatcher)
{
- // Note: Actual state access will be handled via IState injection in later phases
- // For now, this is a placeholder that the reducer handles directly
- _logger.LogDebug("Undo throw action received");
+ var state = _gameState.Value;
+
+ if (state.UndoStack.Count == 0)
+ {
+ _logger.LogWarning("Undo requested but undo stack is empty");
+ dispatcher.Dispatch(new UndoThrowFailureAction("Keine Würfe zum Rückgängigmachen vorhanden"));
+ return Task.CompletedTask;
+ }
+
+ var lastSnapshot = state.UndoStack[^1];
+
+ _logger.LogDebug(
+ "Undoing throw. Stack size before: {StackSize}, Restoring to throw counter: {ThrowCounter}",
+ state.UndoStack.Count,
+ lastSnapshot.ThrowPanel.TotalThrowCounter);
+
+ dispatcher.Dispatch(new UndoThrowSuccessAction(
+ lastSnapshot.ThrowPanel,
+ lastSnapshot.Participants,
+ lastSnapshot.GameModel));
+
return Task.CompletedTask;
}