diff --git a/Components/App.razor b/Components/App.razor deleted file mode 100644 index 0cae009..0000000 --- a/Components/App.razor +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/Components/Pages/Game.razor b/Components/Pages/Game.razor deleted file mode 100644 index 33b3aca..0000000 --- a/Components/Pages/Game.razor +++ /dev/null @@ -1,79 +0,0 @@ -@page "/Game/{gameId}" -@using System.ComponentModel.DataAnnotations -@inject IJSRuntime JSRuntime -@rendermode InteractiveServer - -@GameId Pointing Party - -

- @GameId -

- -@if (_inGame) -{ - -} -else -{ - - -
- -
-
- Enter game -
-
-} - -
- To invite participants, send them this link:
- - pointingparty.com/Game/@Uri.EscapeDataString(GameId) - -
- -@code { - - [Parameter] - public string GameId { get; set; } = string.Empty; - - private bool _inGame; - - protected class FormData - { - [Required] - public string Name { get; set; } = ""; - } - - [SupplyParameterFromForm] - protected FormData GameAndName { get; set; } = new(); - - [SupplyParameterFromQuery] - public string? PlayerName { get; set; } - - private void EnterGame() - { - _inGame = true; - } - - protected override void OnInitialized() - { - if (string.IsNullOrWhiteSpace(PlayerName)) return; - - GameAndName.Name = PlayerName; - _inGame = true; - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!string.IsNullOrWhiteSpace(PlayerName)) - await JSRuntime.InvokeVoidAsync("window.replaceURL", $"/Game/{GameId}"); - } - -} diff --git a/Components/Routes.razor b/Components/Routes.razor deleted file mode 100644 index c8a36f2..0000000 --- a/Components/Routes.razor +++ /dev/null @@ -1,6 +0,0 @@ -@using PointingParty.Components.Layout - - - - - diff --git a/Dockerfile b/Dockerfile index a743300..b3a9714 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ # Adjust DOTNET_OS_VERSION as desired -ARG DOTNET_SDK_VERSION=8.0 +ARG DOTNET_SDK_VERSION=9.0-preview FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_SDK_VERSION}-alpine AS build +RUN dotnet workload install wasm-tools WORKDIR /src # tailwind install @@ -16,19 +17,25 @@ RUN set -ex; \ chmod +x tailwindcss; \ ls -al tailwindcss ; +RUN apk add python3 + # restore -COPY *.csproj . -RUN dotnet restore -r linux-musl-amd64 -p:PublishReadyToRun=true +COPY *.sln . +COPY PointingParty/PointingParty.csproj PointingParty/PointingParty.csproj +COPY PointingParty.Client/PointingParty.Client.csproj PointingParty.Client/PointingParty.Client.csproj +COPY PointingParty.Client.Tests/PointingParty.Client.Tests.csproj PointingParty.Client.Tests/PointingParty.Client.Tests.csproj +COPY PointingParty.Domain/PointingParty.Domain.csproj PointingParty.Domain/PointingParty.Domain.csproj +RUN dotnet restore # copy everything COPY . ./ # tailwind build RUN set -ex; \ - ./tailwindcss -i wwwroot/app.css -o wwwroot/app.min.css --minify && rm wwwroot/app.css + ./tailwindcss -i PointingParty/wwwroot/app.css -o PointingParty/wwwroot/app.min.css --minify && rm PointingParty/wwwroot/app.css # build -RUN dotnet publish --no-restore -c Release -r linux-musl-amd64 -p:PublishReadyToRun=true -o /app +RUN dotnet publish --no-restore -c Release -o /app PointingParty # final stage/image FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_SDK_VERSION}-alpine diff --git a/Domain/Events/GameReset.cs b/Domain/Events/GameReset.cs deleted file mode 100644 index 0a6555a..0000000 --- a/Domain/Events/GameReset.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace PointingParty.Domain.Events; - -public record GameReset(string GameId) : IGameEvent; diff --git a/Domain/Events/IGameEvent.cs b/Domain/Events/IGameEvent.cs deleted file mode 100644 index bc0be5d..0000000 --- a/Domain/Events/IGameEvent.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace PointingParty.Domain.Events; - -public interface IGameEvent -{ - public string GameId { get; init; } -} diff --git a/Domain/Events/VotesShown.cs b/Domain/Events/VotesShown.cs deleted file mode 100644 index 2634ccb..0000000 --- a/Domain/Events/VotesShown.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace PointingParty.Domain.Events; - -public record VotesShown(string GameId) : IGameEvent; diff --git a/Infrastructure/DebugConsumer.cs b/Infrastructure/DebugConsumer.cs deleted file mode 100644 index 696f7f6..0000000 --- a/Infrastructure/DebugConsumer.cs +++ /dev/null @@ -1,44 +0,0 @@ -using MassTransit; -using PointingParty.Domain.Events; - -namespace PointingParty.Infrastructure; - -public class DebugConsumer(ILogger logger) : IConsumer, IConsumer, - IConsumer, IConsumer -{ - public Task Consume(ConsumeContext context) - { - logger.LogInformation( - "Game {GameId}: game was reset", - context.Message.GameId); - return Task.CompletedTask; - } - - public Task Consume(ConsumeContext context) - { - logger.LogInformation( - "Game {GameId}: {playerName} joined", - context.Message.GameId, - context.Message.PlayerName); - return Task.CompletedTask; - } - - public Task Consume(ConsumeContext context) - { - logger.LogInformation( - "Game {GameId}: {playerName} left", - context.Message.GameId, - context.Message.PlayerName); - return Task.CompletedTask; - } - - public Task Consume(ConsumeContext context) - { - logger.LogInformation( - "Player {playerName} voted {score} in {GameId}", - context.Message.PlayerName, - context.Message.Vote, - context.Message.GameId); - return Task.CompletedTask; - } -} diff --git a/Infrastructure/EventHub.cs b/Infrastructure/EventHub.cs deleted file mode 100644 index d4c9dad..0000000 --- a/Infrastructure/EventHub.cs +++ /dev/null @@ -1,46 +0,0 @@ -using MassTransit; -using PointingParty.Domain.Events; - -namespace PointingParty.Infrastructure; - -public class EventHub : IConsumer, IConsumer, IConsumer, - IConsumer, IConsumer, IConsumer -{ - public Task Consume(ConsumeContext context) - { - OnEvent?.Invoke(context.Message); - return Task.CompletedTask; - } - - public Task Consume(ConsumeContext context) - { - OnEvent?.Invoke(context.Message); - return Task.CompletedTask; - } - - public Task Consume(ConsumeContext context) - { - OnEvent?.Invoke(context.Message); - return Task.CompletedTask; - } - - public Task Consume(ConsumeContext context) - { - OnEvent?.Invoke(context.Message); - return Task.CompletedTask; - } - - public Task Consume(ConsumeContext context) - { - OnEvent?.Invoke(context.Message); - return Task.CompletedTask; - } - - public Task Consume(ConsumeContext context) - { - OnEvent?.Invoke(context.Message); - return Task.CompletedTask; - } - - public event Action? OnEvent; -} diff --git a/Infrastructure/GameContext.cs b/Infrastructure/GameContext.cs deleted file mode 100644 index 5fadb07..0000000 --- a/Infrastructure/GameContext.cs +++ /dev/null @@ -1,85 +0,0 @@ -using MassTransit; -using PointingParty.Domain; -using PointingParty.Domain.Events; - -namespace PointingParty.Infrastructure; - -public sealed class GameContext : IDisposable -{ - private readonly EventHub _hub; - private readonly Guid _id; - private readonly ILogger _logger; - private readonly IPublishEndpoint _publishEndpoint; - - private string? _playerName; - - public GameContext(IPublishEndpoint publishEndpoint, EventHub hub, ILogger logger) - { - _publishEndpoint = publishEndpoint; - _hub = hub; - _logger = logger; - - _id = Guid.NewGuid(); - - _logger.LogDebug("GameContext {_id}: Started", _id); - } - - private GameAggregate? Game { get; set; } - - public void Dispose() - { - if (Game is not null && _playerName is not null) - { - Game.PlayerLeft(); - PublishEvents(); - } - - _logger.LogDebug("GameContext {_id}: Disposing", _id); - - _hub.OnEvent -= HandleGameEvent; - } - - public event Action? OnStateChange; - - public GameAggregate Start(string gameId, string playerName, Action? stateChangeHandler) - { - Game = new GameAggregate(gameId, playerName); - _playerName = playerName; - OnStateChange += stateChangeHandler; - _hub.OnEvent += HandleGameEvent; - - _logger.LogDebug("GameContext {_id}: Loaded gameId {gameId} for {_playerName}", _id, gameId, _playerName); - - return Game; - } - - private void HandleGameEvent(IGameEvent e) - { - if (e.GameId != Game!.State.GameId) return; - - Game?.Handle(e); - PublishEvents(); - OnStateChange?.Invoke(); - } - - public void PublishEvents() - { - if (!Game!.EventsToPublish.Any()) return; - - var publishTasks = Game.EventsToPublish.Select(gameEvent => - _publishEndpoint.Publish(gameEvent switch - { - GameReset e => e, - PlayerJoinedGame e => e, - PlayerLeftGame e => e, - Sync e => e, - VoteCast e => e, - VotesShown e => e, - _ => throw new NotImplementedException($"Missing PublishEvent handler for event {gameEvent}") - }) - ).ToArray(); - - Game.EventsToPublish.Clear(); - Task.WaitAll(publishTasks); - } -} diff --git a/PointingParty.Client.Tests/GameStateExtensionsTests.cs b/PointingParty.Client.Tests/GameStateExtensionsTests.cs new file mode 100644 index 0000000..ea1057a --- /dev/null +++ b/PointingParty.Client.Tests/GameStateExtensionsTests.cs @@ -0,0 +1,59 @@ +using System.Collections.Immutable; +using PointingParty.Domain; + +namespace PointingParty.Client.Tests; + +public class GameStateExtensionsTests +{ + [Fact] + public void Calculates_Average() + { + var state = new GameState( + string.Empty, + new Dictionary() + { + { "a", 1 }, + { "b", 2 } + }.ToImmutableDictionary(), + true); + + Assert.Equal(1.5, state.AverageVote()); + } + + [Theory] + [InlineData(VoteStatus.Pending)] + [InlineData(VoteStatus.Coffee)] + [InlineData(VoteStatus.Question)] + public void Ignores_Non_Voters(VoteStatus vote) + { + var state = new GameState( + string.Empty, + new Dictionary() + { + { "a", 1 }, + { "b", 2 }, + { "c", vote } + }.ToImmutableDictionary(), + true); + + Assert.Equal(1.5, state.AverageVote()); + } + + [Theory] + [InlineData(VoteStatus.Pending)] + [InlineData(VoteStatus.Coffee)] + [InlineData(VoteStatus.Question)] + public void Returns_Default_When_Nobody_Voted(VoteStatus vote) + { + var state = new GameState( + string.Empty, + new Dictionary() + { + { "a", vote }, + { "b", vote}, + }.ToImmutableDictionary(), + true); + + Assert.Equal(default, state.AverageVote()); + } +} diff --git a/PointingParty.Client.Tests/GameUiTests.razor b/PointingParty.Client.Tests/GameUiTests.razor new file mode 100644 index 0000000..4131087 --- /dev/null +++ b/PointingParty.Client.Tests/GameUiTests.razor @@ -0,0 +1,116 @@ +@using PointingParty.Domain.Events +@inherits TestContext + +@code { + private readonly ITestOutputHelper _testOutputHelper; + private readonly GameAggregate _game; + private readonly IGameContext _gameContext; + + private const string PlayerName = "Player"; + private const string GameId = "TestGame"; + + public GameUiTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + _game = new GameAggregate(GameId, PlayerName); + _gameContext = Substitute.For(); + _gameContext.Game = _game; + + JSInterop.Mode = JSRuntimeMode.Loose; + } + + [Fact] + public void Renders_PlayerName() + { + _gameContext.PlayerName.Returns(PlayerName); + + var cut = Render(@); + cut.Find("h3").TextContent.MarkupMatches($"Your vote, {PlayerName}:"); + } + + [Fact] + public void Publishes_PlayerJoined_Event() + { + Render(@); + + Assert.Collection(_game.EventsToPublish, e => Assert.IsType(e)); + _gameContext.Received(1).PublishEvents(); + } + + [Fact] + public void Publishes_Vote_Event() + { + var cut = Render(@); + + _gameContext.ClearReceivedCalls(); + _game.EventsToPublish.Clear(); + + cut.FindComponent().Find("button").Click(); + + Assert.Collection(_game.EventsToPublish, e => + { + Assert.IsType(e); + Assert.Equal(((VoteCast)e).Vote, 1); + }); + + _gameContext.Received(1).PublishEvents(); + } + + [Fact] + public void Publishes_VotesShown_Event() + { + var cut = Render(@); + + _gameContext.ClearReceivedCalls(); + _game.EventsToPublish.Clear(); + + cut.FindComponent().Find("button").Click(); + + Assert.Collection(_game.EventsToPublish, e => + { + Assert.IsType(e); + }); + + _gameContext.Received(1).PublishEvents(); + } + + [Fact] + public void Shows_Players_Alphabetized() + { + _game.Handle(new PlayerJoinedGame(GameId, "Player Two")); + _game.Handle(new PlayerJoinedGame(GameId, "Player Three")); + + var cut = Render(@); + + var results = cut.FindAll(@"table[data-testid=""results""] tbody tr td:first-child"); + + Assert.Collection(results, + e => { Assert.Equal(e.GetInnerText(), PlayerName); }, + e => { Assert.Equal(e.GetInnerText(), "Player Three"); }, + e => { Assert.Equal(e.GetInnerText(), "Player Two"); } + ); + } + + [Fact] + public void Hides_Votes_By_Default() + { + _game.Handle(new PlayerJoinedGame(GameId, "Player Two")); + _game.Handle(new VoteCast(GameId, "Player Two", 8)); + + var cut = Render(@); + var results = cut.Find(@"[data-testid=""vote-for-Player Two""]"); + Assert.Equal(results.GetInnerText(), "Voted!"); + } + + [Fact] + public void Shows_Votes_After_VotesShown_Event() + { + _game.Handle(new PlayerJoinedGame(GameId, "Player Two")); + _game.Handle(new VoteCast(GameId, "Player Two", 8)); + _game.Handle(new VotesShown(GameId)); + + var cut = Render(@); + var results = cut.Find(@"[data-testid=""vote-for-Player Two""]"); + Assert.Equal(results.GetInnerText(), "8"); + } +} diff --git a/PointingParty.Client.Tests/GlobalUsings.cs b/PointingParty.Client.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/PointingParty.Client.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/PointingParty.Client.Tests/PointingParty.Client.Tests.csproj b/PointingParty.Client.Tests/PointingParty.Client.Tests.csproj new file mode 100644 index 0000000..d72b932 --- /dev/null +++ b/PointingParty.Client.Tests/PointingParty.Client.Tests.csproj @@ -0,0 +1,31 @@ + + + + net9.0 + enable + enable + + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/PointingParty.Client.Tests/_Imports.razor b/PointingParty.Client.Tests/_Imports.razor new file mode 100644 index 0000000..1e63b14 --- /dev/null +++ b/PointingParty.Client.Tests/_Imports.razor @@ -0,0 +1,12 @@ +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop +@using Microsoft.Extensions.DependencyInjection +@using AngleSharp.Dom +@using Bunit +@using Bunit.TestDoubles +@using NSubstitute +@using Xunit +@using Xunit.Abstractions +@using PointingParty.Client +@using PointingParty.Domain diff --git a/PointingParty.Client/ConnectionStatus.cs b/PointingParty.Client/ConnectionStatus.cs new file mode 100644 index 0000000..2bfef93 --- /dev/null +++ b/PointingParty.Client/ConnectionStatus.cs @@ -0,0 +1,8 @@ +namespace PointingParty.Client; + +public enum ConnectionStatus +{ + Connecting, + Connected, + Failed +} \ No newline at end of file diff --git a/Components/FatButton.razor b/PointingParty.Client/FatButton.razor similarity index 100% rename from Components/FatButton.razor rename to PointingParty.Client/FatButton.razor diff --git a/PointingParty.Client/GameContext.cs b/PointingParty.Client/GameContext.cs new file mode 100644 index 0000000..c0f74b3 --- /dev/null +++ b/PointingParty.Client/GameContext.cs @@ -0,0 +1,126 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; +using PointingParty.Domain; +using PointingParty.Domain.Events; + +namespace PointingParty.Client; + +public sealed class GameContext(ILogger logger, NavigationManager navigationManager) + : IGameEventClient, IGameContext +{ + private IGameEventHub? _hub; + private HubConnection? _hubConnection; + + public GameAggregate? Game { get; set; } + + public string? PlayerName { get; set; } + + public async Task Initialize(Action? stateChangeHandler) + { + OnStateChange += stateChangeHandler; + await StartHubConnection(); + } + + public GameAggregate CreateGame(string gameId, string playerName) + { + Game = new GameAggregate(gameId, playerName); + PlayerName = playerName; + + return Game; + } + + public void PublishEvents() + { + if (!Game!.EventsToPublish.Any()) return; + + logger.LogDebug("Publishing {count} events", Game.EventsToPublish.Count); + + foreach (var gameEvent in Game!.EventsToPublish) + _hub!.BroadcastGameEvent(gameEvent.GetType().ToString(), gameEvent); + + Game.EventsToPublish.Clear(); + } + + public ConnectionStatus Status => _hubConnection?.State switch + { + HubConnectionState.Connected => ConnectionStatus.Connected, + HubConnectionState.Connecting => ConnectionStatus.Connecting, + HubConnectionState.Reconnecting => ConnectionStatus.Connecting, + HubConnectionState.Disconnected => ConnectionStatus.Failed, + _ => ConnectionStatus.Failed + }; + + public async ValueTask DisposeAsync() + { + if (_hubConnection != null) await _hubConnection.DisposeAsync(); + } + + public Task ReceiveGameEvent(IGameEvent gameEvent) + { + logger.LogDebug("Received {eventType}: {e}", gameEvent.GetType(), gameEvent); + HandleGameEvent(gameEvent); + return Task.CompletedTask; + } + + private event Action? OnStateChange; + + private void HandleGameEvent(IGameEvent e) + { + if (e.GameId != Game!.State.GameId) return; + + Game?.Handle(e); + PublishEvents(); + OnStateChange?.Invoke(); + } + + private async Task StartHubConnection() + { + var hubUriBuilder = new UriBuilder(navigationManager.ToAbsoluteUri("/events")); + + _hubConnection = new HubConnectionBuilder() + .WithUrl(hubUriBuilder.Uri) + .WithAutomaticReconnect() + .Build(); + + _hubConnection.Closed += OnHubConnectionClosed; + _hubConnection.Reconnecting += OnHubConnectionReconnecting; + _hubConnection.Reconnected += OnHubConnectionReconnected; + + _hub = _hubConnection.ServerProxy(); + _hubConnection.ClientRegistration(this); + + await _hubConnection.StartAsync(); + logger.LogInformation("Hub connection State: {state} id: {id}", _hubConnection.State, + _hubConnection.ConnectionId); + } + + private Task OnHubConnectionReconnected(string? s) + { + if (Game is not null) + { + // On rejoin, clear user vote and game status then publish join + // event so other players sync up + Game.Handle(new GameReset(Game.State.GameId)); + Game.PlayerJoined(); + PublishEvents(); + } + + OnStateChange?.Invoke(); + logger.LogInformation("Hub reconnected: {s}", s); + return Task.CompletedTask; + } + + private Task OnHubConnectionReconnecting(Exception? s) + { + logger.LogInformation("Hub reconnecting: {s}", s); + OnStateChange?.Invoke(); + return Task.CompletedTask; + } + + private Task OnHubConnectionClosed(Exception? s) + { + logger.LogInformation("Hub connection closed: {s}", s); + OnStateChange?.Invoke(); + return Task.CompletedTask; + } +} diff --git a/PointingParty.Client/GameStateExtensions.cs b/PointingParty.Client/GameStateExtensions.cs new file mode 100644 index 0000000..337aa04 --- /dev/null +++ b/PointingParty.Client/GameStateExtensions.cs @@ -0,0 +1,15 @@ +using PointingParty.Domain; + +namespace PointingParty.Client; + +public static class GameStateExtensions +{ + public static double AverageVote(this GameState gameState) + { + var scoredVotes = gameState.PlayerVotes + .Where(x => x.Value.Status == VoteStatus.Scored) + .Select(x => x.Value.Score) + .ToList(); + return scoredVotes.Any() ? scoredVotes.Average() : default; + } +} diff --git a/Components/GameUi.razor b/PointingParty.Client/GameUi.razor similarity index 60% rename from Components/GameUi.razor rename to PointingParty.Client/GameUi.razor index 0f9367c..74bd216 100644 --- a/Components/GameUi.razor +++ b/PointingParty.Client/GameUi.razor @@ -1,29 +1,33 @@ @using PointingParty.Domain -@using PointingParty.Infrastructure -@rendermode InteractiveServer -@inject GameContext GameContext

- Your vote, @PlayerName: + Your vote, @GameContext.PlayerName:

- 1 - 2 - 3 - 5 - 8 - 13 - 21 - 34 - - ☕️ + 1 + 2 + 3 + 5 + 8 + 13 + 21 + 34 + + ☕️
-@if (_game.State.PlayerVotes.IsEmpty) +@if (GameContext.Status != ConnectionStatus.Connected) { } @@ -33,18 +37,18 @@
- +
- @foreach (var (player, vote) in _game.State.PlayerVotes.OrderBy(x => x.Key)) + @foreach (var (player, vote) in Game.State.PlayerVotes.OrderBy(x => x.Key)) { -
Player Vote
@player - @if (vote.Status != VoteStatus.Pending && player != PlayerName && !_game.State.ShowVotes) + + @if (vote.Status != VoteStatus.Pending && player != GameContext.PlayerName && !Game.State.ShowVotes) { Voted! } @@ -65,18 +69,18 @@
- @if (_game.State.ShowVotes) + @if (Game.State.ShowVotes) { - + - if (AverageScore() != default) + if (Game.State.AverageVote() != default) {
Average score: - @Math.Round(AverageScore(), 2) + @Math.Round(Game.State.AverageVote(), 2)
} @@ -101,60 +105,41 @@ @code { [Parameter] - public string GameId { get; set; } = ""; - - [Parameter] - public string PlayerName { get; set; } = ""; + public IGameContext GameContext { get; set; } = default!; - private GameAggregate _game = default!; + private GameAggregate Game => GameContext.Game ?? new GameAggregate(); protected override void OnInitialized() { - _game = GameContext.Start(GameId, PlayerName, HandleGameEvent); - _game.PlayerJoined(); + Game.PlayerJoined(); GameContext.PublishEvents(); } - private void HandleGameEvent() - { - InvokeAsync(StateHasChanged); - } - private void Vote(double score) { - if (_game.State.ShowVotes) return; + if (Game.State.ShowVotes) return; - _game.VoteCast(score); + Game.VoteCast(score); GameContext.PublishEvents(); } private void Vote(VoteStatus status) { - if (_game.State.ShowVotes) return; + if (Game.State.ShowVotes) return; - _game.VoteCast(status); + Game.VoteCast(status); GameContext.PublishEvents(); } private void ClearVotes() { - _game.GameReset(); + Game.GameReset(); GameContext.PublishEvents(); } private void ShowVotes() { - _game.VotesShown(); + Game.VotesShown(); GameContext.PublishEvents(); } - - private double AverageScore() - { - var scoredVotes = _game.State.PlayerVotes - .Where(x => x.Value.Status == VoteStatus.Scored) - .Select(x => x.Value.Score) - .ToList(); - return scoredVotes.Any() ? scoredVotes.Average() : default; - } - } diff --git a/PointingParty.Client/HubConnectionExtensions.cs b/PointingParty.Client/HubConnectionExtensions.cs new file mode 100644 index 0000000..59b0afe --- /dev/null +++ b/PointingParty.Client/HubConnectionExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.SignalR.Client; + +namespace PointingParty.Client; + +// Provide a harness for the SignalR source generator +internal static partial class HubConnectionExtensions +{ + [HubClientProxy] + public static partial IDisposable ClientRegistration(this HubConnection connection, T provider); + + [HubServerProxy] + public static partial T ServerProxy(this HubConnection connection); +} + +[AttributeUsage(AttributeTargets.Method)] +internal class HubServerProxyAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method)] +internal class HubClientProxyAttribute : Attribute +{ +} \ No newline at end of file diff --git a/PointingParty.Client/IGameContext.cs b/PointingParty.Client/IGameContext.cs new file mode 100644 index 0000000..b8adfd9 --- /dev/null +++ b/PointingParty.Client/IGameContext.cs @@ -0,0 +1,13 @@ +using PointingParty.Domain; + +namespace PointingParty.Client; + +public interface IGameContext : IAsyncDisposable +{ + public GameAggregate? Game { get; set; } + string? PlayerName { get; set; } + ConnectionStatus Status { get; } + Task Initialize(Action? stateChangeHandler); + GameAggregate CreateGame(string gameId, string playerName); + void PublishEvents(); +} diff --git a/PointingParty.Client/Pages/Game.razor b/PointingParty.Client/Pages/Game.razor new file mode 100644 index 0000000..7c9f441 --- /dev/null +++ b/PointingParty.Client/Pages/Game.razor @@ -0,0 +1,92 @@ +@page "/Game/{gameId}" +@using System.ComponentModel.DataAnnotations +@inject IGameContext GameContext +@inject NavigationManager NavManager +@implements IAsyncDisposable +@rendermode InteractiveWebAssembly + +@GameId Pointing Party + +

+ @GameId +

+ +@if (GameContext.Game is not null) +{ + +} +else +{ + + +
+ +
+
+ Enter game +
+
+} + +
+ To invite participants, send them this link:
+ + pointingparty.com/Game/@Uri.EscapeDataString(GameId) + +
+ +@code { + + [Parameter] + public string GameId { get; set; } = string.Empty; + + protected class FormData + { + [Required] + public string Name { get; set; } = ""; + } + + [SupplyParameterFromForm] + protected FormData NameForm { get; set; } = new(); + + [SupplyParameterFromQuery] + public string? PlayerName { get; set; } + + private void CreateGame() + { + GameContext.CreateGame(GameId, NameForm.Name); + } + + private void HandleGameEvent() + { + InvokeAsync(StateHasChanged); + } + + protected override async Task OnInitializedAsync() + { + await GameContext.Initialize(HandleGameEvent); + if (string.IsNullOrWhiteSpace(PlayerName)) return; + + NameForm.Name = PlayerName; + CreateGame(); + } + + protected override void OnAfterRender(bool firstRender) + { + // If game was started from the front page, remove PlayerName query string so the browser location + // bar has a share-friendly URL + if (GameContext.Game is not null && !string.IsNullOrWhiteSpace(PlayerName)) + NavManager.NavigateTo($"/Game/{GameId}", replace: true); + } + + public async ValueTask DisposeAsync() + { + await GameContext.DisposeAsync(); + } + +} diff --git a/PointingParty.Client/PointingParty.Client.csproj b/PointingParty.Client/PointingParty.Client.csproj new file mode 100644 index 0000000..b12e7f4 --- /dev/null +++ b/PointingParty.Client/PointingParty.Client.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + true + Default + false + true + true + partial + + + + + + + + + + + + + + diff --git a/PointingParty.Client/Program.cs b/PointingParty.Client/Program.cs new file mode 100644 index 0000000..79bd2bd --- /dev/null +++ b/PointingParty.Client/Program.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using PointingParty.Client; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddTransient(); + +await builder.Build().RunAsync(); \ No newline at end of file diff --git a/Components/ScoreGraph.razor b/PointingParty.Client/ScoreGraph.razor similarity index 94% rename from Components/ScoreGraph.razor rename to PointingParty.Client/ScoreGraph.razor index 492e700..2e48cff 100644 --- a/Components/ScoreGraph.razor +++ b/PointingParty.Client/ScoreGraph.razor @@ -1,7 +1,6 @@ -@using PointingParty.Domain @using System.Collections.Immutable @using ApexCharts - +@using PointingParty.Domain + PointColor="e => BarColor(e.Vote)"/> @code { @@ -20,7 +19,7 @@ [Parameter] public IImmutableDictionary PlayerVotes { get; set; } = ImmutableDictionary.Empty; - private List Data { get; } = []; + private List Data { get; set; } = []; private readonly ApexChartOptions _chartOptions = new() { @@ -75,4 +74,4 @@ public int Frequency { get; set; } } -} +} \ No newline at end of file diff --git a/Components/VoteButton.razor b/PointingParty.Client/VoteButton.razor similarity index 83% rename from Components/VoteButton.razor rename to PointingParty.Client/VoteButton.razor index 69e11db..2c8b627 100644 --- a/Components/VoteButton.razor +++ b/PointingParty.Client/VoteButton.razor @@ -33,22 +33,26 @@ { str.Append($"py-3 lg:py-7 border-4 border-{primaryColor}-200 text-{primaryColor}-500 focus:outline-none focus:ring-2 focus:ring-{primaryColor}-200 focus:ring-offset-2 transition-all "); str.Append( - Color switch { + Color switch + { ButtonColor.Blue => "dark:focus:ring-offset-gray-800", ButtonColor.Indigo => "dark:focus:ring-offset-gray-800", - _ =>"dark:border-gray-700 dark:focus:ring-gray-600 dark:focus:ring-offset-gray-800" }); + _ => "dark:border-gray-700 dark:focus:ring-gray-600 dark:focus:ring-offset-gray-800" + }); } else { str.Append($"py-4 lg:py-8 border border-transparent text-white bg-{primaryColor}-500 hover:bg-{primaryColor}-600 focus:ring-{primaryColor}-500 focus:ring-offset-2 "); str.Append( - Color switch { + Color switch + { ButtonColor.Blue => "dark:focus:ring-offset-gray-800", ButtonColor.Indigo => "dark:focus:ring-offset-gray-800", - _ =>"dark:bg-gray-700 dark:focus:ring-offset-gray-800"}); + _ => "dark:bg-gray-700 dark:focus:ring-offset-gray-800" + }); } return str.ToString(); } -} +} \ No newline at end of file diff --git a/Components/_Imports.razor b/PointingParty.Client/_Imports.razor similarity index 86% rename from Components/_Imports.razor rename to PointingParty.Client/_Imports.razor index 0e7b217..fac8e1f 100644 --- a/Components/_Imports.razor +++ b/PointingParty.Client/_Imports.razor @@ -6,5 +6,4 @@ @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop -@using PointingParty -@using PointingParty.Components +@using PointingParty.Client \ No newline at end of file diff --git a/PointingParty.Domain/Events/GameReset.cs b/PointingParty.Domain/Events/GameReset.cs new file mode 100644 index 0000000..e4b3798 --- /dev/null +++ b/PointingParty.Domain/Events/GameReset.cs @@ -0,0 +1,3 @@ +namespace PointingParty.Domain.Events; + +public record GameReset(string GameId) : IGameEvent; \ No newline at end of file diff --git a/PointingParty.Domain/Events/IGameEvent.cs b/PointingParty.Domain/Events/IGameEvent.cs new file mode 100644 index 0000000..bb7b47b --- /dev/null +++ b/PointingParty.Domain/Events/IGameEvent.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace PointingParty.Domain.Events; + +// Polymorphic JSON configuration is necessary for SignalR +[JsonPolymorphic] +[JsonDerivedType(typeof(GameReset), nameof(GameReset))] +[JsonDerivedType(typeof(PlayerJoinedGame), nameof(PlayerJoinedGame))] +[JsonDerivedType(typeof(PlayerLeftGame), nameof(PlayerLeftGame))] +[JsonDerivedType(typeof(Sync), nameof(Sync))] +[JsonDerivedType(typeof(VoteCast), nameof(VoteCast))] +[JsonDerivedType(typeof(VotesShown), nameof(VotesShown))] +public interface IGameEvent +{ + public string GameId { get; init; } +} \ No newline at end of file diff --git a/Domain/Events/PlayerJoinedGame.cs b/PointingParty.Domain/Events/PlayerJoinedGame.cs similarity index 87% rename from Domain/Events/PlayerJoinedGame.cs rename to PointingParty.Domain/Events/PlayerJoinedGame.cs index 8e2bf6f..b91a529 100644 --- a/Domain/Events/PlayerJoinedGame.cs +++ b/PointingParty.Domain/Events/PlayerJoinedGame.cs @@ -1,3 +1,3 @@ namespace PointingParty.Domain.Events; -public record PlayerJoinedGame(string GameId, string PlayerName) : IGameEvent; +public record PlayerJoinedGame(string GameId, string PlayerName) : IGameEvent; \ No newline at end of file diff --git a/Domain/Events/PlayerLeftGame.cs b/PointingParty.Domain/Events/PlayerLeftGame.cs similarity index 88% rename from Domain/Events/PlayerLeftGame.cs rename to PointingParty.Domain/Events/PlayerLeftGame.cs index 9a52441..89061f3 100644 --- a/Domain/Events/PlayerLeftGame.cs +++ b/PointingParty.Domain/Events/PlayerLeftGame.cs @@ -1,3 +1,3 @@ namespace PointingParty.Domain.Events; -public record PlayerLeftGame(string GameId, string PlayerName) : IGameEvent; +public record PlayerLeftGame(string GameId, string PlayerName) : IGameEvent; \ No newline at end of file diff --git a/Domain/Events/Sync.cs b/PointingParty.Domain/Events/Sync.cs similarity index 88% rename from Domain/Events/Sync.cs rename to PointingParty.Domain/Events/Sync.cs index 4dbfb63..28c9084 100644 --- a/Domain/Events/Sync.cs +++ b/PointingParty.Domain/Events/Sync.cs @@ -1,3 +1,3 @@ namespace PointingParty.Domain.Events; -public record Sync(string GameId, string PlayerName, Vote Vote) : IGameEvent; +public record Sync(string GameId, string PlayerName, Vote Vote) : IGameEvent; \ No newline at end of file diff --git a/Domain/Events/VoteCast.cs b/PointingParty.Domain/Events/VoteCast.cs similarity index 85% rename from Domain/Events/VoteCast.cs rename to PointingParty.Domain/Events/VoteCast.cs index b5f82a8..b511eb9 100644 --- a/Domain/Events/VoteCast.cs +++ b/PointingParty.Domain/Events/VoteCast.cs @@ -1,3 +1,3 @@ namespace PointingParty.Domain.Events; -public record VoteCast(string GameId, string PlayerName, Vote Vote) : IGameEvent; +public record VoteCast(string GameId, string PlayerName, Vote Vote) : IGameEvent; \ No newline at end of file diff --git a/PointingParty.Domain/Events/VotesShown.cs b/PointingParty.Domain/Events/VotesShown.cs new file mode 100644 index 0000000..b19e2ea --- /dev/null +++ b/PointingParty.Domain/Events/VotesShown.cs @@ -0,0 +1,3 @@ +namespace PointingParty.Domain.Events; + +public record VotesShown(string GameId) : IGameEvent; \ No newline at end of file diff --git a/Domain/GameAggregate.cs b/PointingParty.Domain/GameAggregate.cs similarity index 85% rename from Domain/GameAggregate.cs rename to PointingParty.Domain/GameAggregate.cs index 2fd9e9c..a9dc533 100644 --- a/Domain/GameAggregate.cs +++ b/PointingParty.Domain/GameAggregate.cs @@ -7,6 +7,20 @@ public class GameAggregate { private readonly string _playerName; + /// + /// Initialize a default game aggregate with no game or playername for mocks or placeholders + /// + public GameAggregate() + { + State = new GameState(string.Empty, ImmutableDictionary.Empty, false); + _playerName = string.Empty; + } + + /// + /// Initialize a new game aggregate + /// + /// + /// public GameAggregate(string gameId, string playerName) { State = new GameState(gameId, ImmutableDictionary.Empty, false); @@ -78,7 +92,8 @@ private void Apply(PlayerLeftGame e) private void Apply(Sync e) { - if (State.PlayerVotes.ContainsKey(e.PlayerName)) return; + if (State.PlayerVotes.TryGetValue(e.PlayerName, out var existingVote) && existingVote == e.Vote) + return; State = State with { diff --git a/Domain/GameState.cs b/PointingParty.Domain/GameState.cs similarity index 77% rename from Domain/GameState.cs rename to PointingParty.Domain/GameState.cs index 5a6d066..a38d03e 100644 --- a/Domain/GameState.cs +++ b/PointingParty.Domain/GameState.cs @@ -2,4 +2,4 @@ namespace PointingParty.Domain; -public record GameState(string GameId, ImmutableDictionary PlayerVotes, bool ShowVotes); +public record GameState(string GameId, ImmutableDictionary PlayerVotes, bool ShowVotes); \ No newline at end of file diff --git a/PointingParty.Domain/IGameEventClient.cs b/PointingParty.Domain/IGameEventClient.cs new file mode 100644 index 0000000..f04dab5 --- /dev/null +++ b/PointingParty.Domain/IGameEventClient.cs @@ -0,0 +1,8 @@ +using PointingParty.Domain.Events; + +namespace PointingParty.Domain; + +public interface IGameEventClient +{ + public Task ReceiveGameEvent(IGameEvent gameEvent); +} \ No newline at end of file diff --git a/PointingParty.Domain/IGameEventHub.cs b/PointingParty.Domain/IGameEventHub.cs new file mode 100644 index 0000000..90f715f --- /dev/null +++ b/PointingParty.Domain/IGameEventHub.cs @@ -0,0 +1,7 @@ +namespace PointingParty.Domain; + +public interface IGameEventHub +{ + // SignalR client doesn't support polymorphic serialization yet, so we send a type + object + public Task BroadcastGameEvent(string type, object gameEvent); +} \ No newline at end of file diff --git a/PointingParty.Domain/PointingParty.Domain.csproj b/PointingParty.Domain/PointingParty.Domain.csproj new file mode 100644 index 0000000..17b910f --- /dev/null +++ b/PointingParty.Domain/PointingParty.Domain.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/Domain/Vote.cs b/PointingParty.Domain/Vote.cs similarity index 94% rename from Domain/Vote.cs rename to PointingParty.Domain/Vote.cs index f07a0e2..2a641f2 100644 --- a/Domain/Vote.cs +++ b/PointingParty.Domain/Vote.cs @@ -42,15 +42,9 @@ public override string ToString() public int CompareTo(Vote other) { - if (Status == VoteStatus.Scored && other.Status != VoteStatus.Scored) - { - return -1; - } + if (Status == VoteStatus.Scored && other.Status != VoteStatus.Scored) return -1; - if (Status != VoteStatus.Scored && other.Status == VoteStatus.Scored) - { - return 1; - } + if (Status != VoteStatus.Scored && other.Status == VoteStatus.Scored) return 1; return Score.CompareTo(other.Score); } @@ -101,4 +95,4 @@ public static implicit operator Vote(VoteStatus status) { return new Vote(status); } -} +} \ No newline at end of file diff --git a/PointingParty.csproj b/PointingParty.csproj deleted file mode 100644 index f47ed87..0000000 --- a/PointingParty.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - - diff --git a/PointingParty.sln b/PointingParty.sln new file mode 100644 index 0000000..6fad54c --- /dev/null +++ b/PointingParty.sln @@ -0,0 +1,78 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.0.0 +MinimumVisualStudioVersion = 16.0.0.0 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PointingParty", "PointingParty\PointingParty.csproj", "{809F871D-31A0-4855-ABEC-BAA464C28C60}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PointingParty.Client", "PointingParty.Client\PointingParty.Client.csproj", "{0BD42FC1-1B90-47A0-8269-BDA40F5C466F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PointingParty.Domain", "PointingParty.Domain\PointingParty.Domain.csproj", "{9DB8C296-5101-40A4-88CE-4882838BB81B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PointingParty.Client.Tests", "PointingParty.Client.Tests\PointingParty.Client.Tests.csproj", "{66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Debug|x64.ActiveCfg = Debug|Any CPU + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Debug|x64.Build.0 = Debug|Any CPU + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Debug|x86.ActiveCfg = Debug|Any CPU + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Debug|x86.Build.0 = Debug|Any CPU + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Release|Any CPU.Build.0 = Release|Any CPU + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Release|x64.ActiveCfg = Release|Any CPU + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Release|x64.Build.0 = Release|Any CPU + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Release|x86.ActiveCfg = Release|Any CPU + {0BD42FC1-1B90-47A0-8269-BDA40F5C466F}.Release|x86.Build.0 = Release|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Debug|x64.ActiveCfg = Debug|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Debug|x64.Build.0 = Debug|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Debug|x86.ActiveCfg = Debug|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Debug|x86.Build.0 = Debug|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Release|Any CPU.Build.0 = Release|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Release|x64.ActiveCfg = Release|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Release|x64.Build.0 = Release|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Release|x86.ActiveCfg = Release|Any CPU + {809F871D-31A0-4855-ABEC-BAA464C28C60}.Release|x86.Build.0 = Release|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Debug|x64.ActiveCfg = Debug|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Debug|x64.Build.0 = Debug|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Debug|x86.ActiveCfg = Debug|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Debug|x86.Build.0 = Debug|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Release|Any CPU.Build.0 = Release|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Release|x64.ActiveCfg = Release|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Release|x64.Build.0 = Release|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Release|x86.ActiveCfg = Release|Any CPU + {9DB8C296-5101-40A4-88CE-4882838BB81B}.Release|x86.Build.0 = Release|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Debug|x64.ActiveCfg = Debug|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Debug|x64.Build.0 = Debug|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Debug|x86.Build.0 = Debug|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Release|Any CPU.Build.0 = Release|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Release|x64.ActiveCfg = Release|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Release|x64.Build.0 = Release|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Release|x86.ActiveCfg = Release|Any CPU + {66ACD3FD-EA44-4E5E-96D7-A5F87C7E76E8}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C12A8B65-6A41-4E2C-81A8-33F4343E9A30} + EndGlobalSection +EndGlobal diff --git a/PointingParty/Components/App.razor b/PointingParty/Components/App.razor new file mode 100644 index 0000000..dbfceb2 --- /dev/null +++ b/PointingParty/Components/App.razor @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Components/Layout/MainLayout.razor b/PointingParty/Components/Layout/MainLayout.razor similarity index 92% rename from Components/Layout/MainLayout.razor rename to PointingParty/Components/Layout/MainLayout.razor index 129d6ca..e81b6ff 100644 --- a/Components/Layout/MainLayout.razor +++ b/PointingParty/Components/Layout/MainLayout.razor @@ -4,15 +4,15 @@ - +

© Martijn Storck / storck.io
Source on GitHub @@ -23,4 +23,4 @@ An unhandled error has occurred. Reload 🗙 -

+ \ No newline at end of file diff --git a/Components/Pages/Error.razor b/PointingParty/Components/Pages/Error.razor similarity index 90% rename from Components/Pages/Error.razor rename to PointingParty/Components/Pages/Error.razor index 76b03b3..d026f4c 100644 --- a/Components/Pages/Error.razor +++ b/PointingParty/Components/Pages/Error.razor @@ -19,6 +19,9 @@ public string? RequestId { get; set; } public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); - protected override void OnInitialized() => + protected override void OnInitialized() + { RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; -} + } + +} \ No newline at end of file diff --git a/Components/Pages/Home.razor b/PointingParty/Components/Pages/Home.razor similarity index 83% rename from Components/Pages/Home.razor rename to PointingParty/Components/Pages/Home.razor index 53701bd..1cbc0ce 100644 --- a/Components/Pages/Home.razor +++ b/PointingParty/Components/Pages/Home.razor @@ -14,7 +14,7 @@

- This free service is powered by .NET 8 and Blazor. The source code is available on GitHub: + This free service is powered by .NET 9 and Blazor WebAssembly. The source code is available on GitHub: github.com/martijn/PointingParty.

@@ -26,7 +26,7 @@ Game name: - +
@@ -34,7 +34,7 @@ Your name: - +
@@ -60,7 +60,7 @@ } [SupplyParameterFromForm] - NewGameModel Model { get; set; } = new(); + NewGameModel Model { get; set; } = new(); private void StartGame() { diff --git a/PointingParty/Components/Routes.razor b/PointingParty/Components/Routes.razor new file mode 100644 index 0000000..d5ed33e --- /dev/null +++ b/PointingParty/Components/Routes.razor @@ -0,0 +1,7 @@ +@using PointingParty.Components.Layout + + + + + + \ No newline at end of file diff --git a/PointingParty/Components/_Imports.razor b/PointingParty/Components/_Imports.razor new file mode 100644 index 0000000..8e487d8 --- /dev/null +++ b/PointingParty/Components/_Imports.razor @@ -0,0 +1,11 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using PointingParty +@using PointingParty.Client +@using PointingParty.Components \ No newline at end of file diff --git a/PointingParty/GameEventHub.cs b/PointingParty/GameEventHub.cs new file mode 100644 index 0000000..e63d35f --- /dev/null +++ b/PointingParty/GameEventHub.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Microsoft.AspNetCore.SignalR; +using PointingParty.Domain; +using PointingParty.Domain.Events; + +namespace PointingParty; + +public class GameEventHub : Hub, IGameEventHub +{ + private const string PlayerNameItem = "playerName"; + private const string GameIdItem = "gameId"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly ILogger _logger; + + public GameEventHub(ILogger logger) + { + _logger = logger; + } + + public Task BroadcastGameEvent(string type, object gameEvent) + { + var jsonEvent = (JsonElement)gameEvent; + var targetType = typeof(IGameEvent).Assembly.GetType(type); + + if (targetType is null) + { + _logger.LogError("Cannot find type {type}", type); + return Task.CompletedTask; + } + + IGameEvent? typedEvent = null; + + // If SignalR .NET client gets support for polymorphic json serialization we can + // change the signature of this method to IGameEvent and get rid of this mess. + switch (targetType.Name) + { + case nameof(GameReset): + typedEvent = DeserializeEvent(jsonEvent); + break; + case nameof(PlayerJoinedGame): + typedEvent = DeserializeEvent(jsonEvent); + break; + case nameof(PlayerLeftGame): + typedEvent = DeserializeEvent(jsonEvent); + break; + case nameof(Sync): + typedEvent = DeserializeEvent(jsonEvent); + break; + case nameof(VoteCast): + typedEvent = DeserializeEvent(jsonEvent); + break; + case nameof(VotesShown): + typedEvent = DeserializeEvent(jsonEvent); + break; + default: + _logger.LogError("Cannot handle type {type}", targetType.Name); + break; + } + + if (typedEvent is PlayerJoinedGame joinedGame) + { + // Save player details, so we can broadcast a leave event when client disconnects + Context.Items[PlayerNameItem] = joinedGame.PlayerName; + Context.Items[GameIdItem] = joinedGame.GameId; + + // Add client to SignalR group so it receives events for the game it joined + Groups.AddToGroupAsync(Context.ConnectionId, joinedGame.GameId); + } + + if (typedEvent is not null) + { + _logger.LogInformation("{gameId}: processing event {event}", typedEvent.GameId, typedEvent); + return Clients.GroupExcept(typedEvent.GameId, Context.ConnectionId).ReceiveGameEvent(typedEvent); + } + + return Task.CompletedTask; + } + + public override async Task OnConnectedAsync() + { + _logger.LogInformation("Client connected. {id}", Context.ConnectionId); + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + if (exception != null) + _logger.LogError(exception, "Client disconnected with exception."); + else + _logger.LogInformation("Client disconnected gracefully."); + + if (Context.Items[GameIdItem] is string gameId && Context.Items[PlayerNameItem] is string playerName) + { + _logger.LogInformation("{gameId}: Broadcasting PlayerLeftGame for {playerName}", gameId, playerName); + await Clients.Group(gameId).ReceiveGameEvent(new PlayerLeftGame(gameId, playerName)); + } + + await base.OnDisconnectedAsync(exception); + } + + private static T? DeserializeEvent(JsonElement jsonEvent) where T : class, IGameEvent + { + return jsonEvent.Deserialize(JsonOptions); + } +} diff --git a/PointingParty/MockGameContext.cs b/PointingParty/MockGameContext.cs new file mode 100644 index 0000000..68dc797 --- /dev/null +++ b/PointingParty/MockGameContext.cs @@ -0,0 +1,31 @@ +using PointingParty.Client; +using PointingParty.Domain; + +namespace PointingParty; + +// A mock GameContext to allow server-side prerendering of the Game page +public class MockGameContext : IGameContext +{ + public GameAggregate? Game { get; set; } + public string? PlayerName { get; set; } + public ConnectionStatus Status => ConnectionStatus.Connecting; + + public Task Initialize(Action? stateChangeHandler) + { + return Task.CompletedTask; + } + + public GameAggregate CreateGame(string gameId, string playerName) + { + return new GameAggregate(); + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public void PublishEvents() + { + } +} diff --git a/PointingParty/PointingParty.csproj b/PointingParty/PointingParty.csproj new file mode 100644 index 0000000..57dc88e --- /dev/null +++ b/PointingParty/PointingParty.csproj @@ -0,0 +1,16 @@ + + + + net9.0 + enable + enable + false + true + + + + + + + + diff --git a/PointingParty/Program.cs b/PointingParty/Program.cs new file mode 100644 index 0000000..cf1f27a --- /dev/null +++ b/PointingParty/Program.cs @@ -0,0 +1,42 @@ +using PointingParty; +using PointingParty.Client; +using PointingParty.Components; +using _Imports = PointingParty.Client._Imports; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveWebAssemblyComponents(); + +builder.Services.AddSignalR(); + +// GameContext is purely injected for prerendering purposes +builder.Services.AddTransient(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); +} +else +{ + app.UseExceptionHandler("/Error", true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); + +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(_Imports).Assembly); + +app.MapHub("/events"); + +app.Run(); \ No newline at end of file diff --git a/Properties/launchSettings.json b/PointingParty/Properties/launchSettings.json similarity index 55% rename from Properties/launchSettings.json rename to PointingParty/Properties/launchSettings.json index 5f00cb5..45f27b7 100644 --- a/Properties/launchSettings.json +++ b/PointingParty/Properties/launchSettings.json @@ -4,16 +4,17 @@ "windowsAuthentication": false, "anonymousAuthentication": true, "iisExpress": { - "applicationUrl": "http://localhost:33983", - "sslPort": 44336 + "applicationUrl": "http://localhost:30127", + "sslPort": 44330 } }, "profiles": { "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5285", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://0.0.0.0:5174", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -21,8 +22,9 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7010;http://localhost:5285", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7204;http://localhost:5174", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -30,6 +32,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/appsettings.Development.json b/PointingParty/appsettings.Development.json similarity index 52% rename from appsettings.Development.json rename to PointingParty/appsettings.Development.json index ae6220e..0c208ae 100644 --- a/appsettings.Development.json +++ b/PointingParty/appsettings.Development.json @@ -2,8 +2,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "PointingParty": "Debug" + "Microsoft.AspNetCore": "Warning" } } } diff --git a/appsettings.json b/PointingParty/appsettings.json similarity index 100% rename from appsettings.json rename to PointingParty/appsettings.json diff --git a/wwwroot/app.css b/PointingParty/wwwroot/app.css similarity index 100% rename from wwwroot/app.css rename to PointingParty/wwwroot/app.css diff --git a/PointingParty/wwwroot/app.min.css b/PointingParty/wwwroot/app.min.css new file mode 100644 index 0000000..3c2663b --- /dev/null +++ b/PointingParty/wwwroot/app.min.css @@ -0,0 +1 @@ +/*! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.static{position:static}.float-right{float:right}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-top:.5rem}.mb-2,.my-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.block{display:block}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-8{height:2rem}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-6xl{max-width:72rem}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.items-center{align-items:center}.justify-center{justify-content:center}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-4{gap:1rem}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity))}.self-center{align-self:center}.whitespace-nowrap{white-space:nowrap}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-4{border-width:4px}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity))}.border-blue-200\/0{border-color:#bfdbfe00}.border-blue-200\/10{border-color:#bfdbfe1a}.border-blue-200\/100{border-color:#bfdbfe}.border-blue-200\/20{border-color:#bfdbfe33}.border-blue-200\/25{border-color:#bfdbfe40}.border-blue-200\/30{border-color:#bfdbfe4d}.border-blue-200\/40{border-color:#bfdbfe66}.border-blue-200\/5{border-color:#bfdbfe0d}.border-blue-200\/50{border-color:#bfdbfe80}.border-blue-200\/60{border-color:#bfdbfe99}.border-blue-200\/70{border-color:#bfdbfeb3}.border-blue-200\/75{border-color:#bfdbfebf}.border-blue-200\/80{border-color:#bfdbfecc}.border-blue-200\/90{border-color:#bfdbfee6}.border-blue-200\/95{border-color:#bfdbfef2}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-gray-200\/0{border-color:#e5e7eb00}.border-gray-200\/10{border-color:#e5e7eb1a}.border-gray-200\/100{border-color:#e5e7eb}.border-gray-200\/20{border-color:#e5e7eb33}.border-gray-200\/25{border-color:#e5e7eb40}.border-gray-200\/30{border-color:#e5e7eb4d}.border-gray-200\/40{border-color:#e5e7eb66}.border-gray-200\/5{border-color:#e5e7eb0d}.border-gray-200\/50{border-color:#e5e7eb80}.border-gray-200\/60{border-color:#e5e7eb99}.border-gray-200\/70{border-color:#e5e7ebb3}.border-gray-200\/75{border-color:#e5e7ebbf}.border-gray-200\/80{border-color:#e5e7ebcc}.border-gray-200\/90{border-color:#e5e7ebe6}.border-gray-200\/95{border-color:#e5e7ebf2}.border-indigo-200{--tw-border-opacity:1;border-color:rgb(199 210 254/var(--tw-border-opacity))}.border-indigo-200\/0{border-color:#c7d2fe00}.border-indigo-200\/10{border-color:#c7d2fe1a}.border-indigo-200\/100{border-color:#c7d2fe}.border-indigo-200\/20{border-color:#c7d2fe33}.border-indigo-200\/25{border-color:#c7d2fe40}.border-indigo-200\/30{border-color:#c7d2fe4d}.border-indigo-200\/40{border-color:#c7d2fe66}.border-indigo-200\/5{border-color:#c7d2fe0d}.border-indigo-200\/50{border-color:#c7d2fe80}.border-indigo-200\/60{border-color:#c7d2fe99}.border-indigo-200\/70{border-color:#c7d2feb3}.border-indigo-200\/75{border-color:#c7d2febf}.border-indigo-200\/80{border-color:#c7d2fecc}.border-indigo-200\/90{border-color:#c7d2fee6}.border-indigo-200\/95{border-color:#c7d2fef2}.border-transparent{border-color:#0000}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.bg-blue-500\/0{background-color:#3b82f600}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-500\/100{background-color:#3b82f6}.bg-blue-500\/20{background-color:#3b82f633}.bg-blue-500\/25{background-color:#3b82f640}.bg-blue-500\/30{background-color:#3b82f64d}.bg-blue-500\/40{background-color:#3b82f666}.bg-blue-500\/5{background-color:#3b82f60d}.bg-blue-500\/50{background-color:#3b82f680}.bg-blue-500\/60{background-color:#3b82f699}.bg-blue-500\/70{background-color:#3b82f6b3}.bg-blue-500\/75{background-color:#3b82f6bf}.bg-blue-500\/80{background-color:#3b82f6cc}.bg-blue-500\/90{background-color:#3b82f6e6}.bg-blue-500\/95{background-color:#3b82f6f2}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity))}.bg-blue-600\/0{background-color:#2563eb00}.bg-blue-600\/10{background-color:#2563eb1a}.bg-blue-600\/100{background-color:#2563eb}.bg-blue-600\/20{background-color:#2563eb33}.bg-blue-600\/25{background-color:#2563eb40}.bg-blue-600\/30{background-color:#2563eb4d}.bg-blue-600\/40{background-color:#2563eb66}.bg-blue-600\/5{background-color:#2563eb0d}.bg-blue-600\/50{background-color:#2563eb80}.bg-blue-600\/60{background-color:#2563eb99}.bg-blue-600\/70{background-color:#2563ebb3}.bg-blue-600\/75{background-color:#2563ebbf}.bg-blue-600\/80{background-color:#2563ebcc}.bg-blue-600\/90{background-color:#2563ebe6}.bg-blue-600\/95{background-color:#2563ebf2}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity))}.bg-gray-500\/0{background-color:#6b728000}.bg-gray-500\/10{background-color:#6b72801a}.bg-gray-500\/100{background-color:#6b7280}.bg-gray-500\/20{background-color:#6b728033}.bg-gray-500\/25{background-color:#6b728040}.bg-gray-500\/30{background-color:#6b72804d}.bg-gray-500\/40{background-color:#6b728066}.bg-gray-500\/5{background-color:#6b72800d}.bg-gray-500\/50{background-color:#6b728080}.bg-gray-500\/60{background-color:#6b728099}.bg-gray-500\/70{background-color:#6b7280b3}.bg-gray-500\/75{background-color:#6b7280bf}.bg-gray-500\/80{background-color:#6b7280cc}.bg-gray-500\/90{background-color:#6b7280e6}.bg-gray-500\/95{background-color:#6b7280f2}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.bg-gray-600\/0{background-color:#4b556300}.bg-gray-600\/10{background-color:#4b55631a}.bg-gray-600\/100{background-color:#4b5563}.bg-gray-600\/20{background-color:#4b556333}.bg-gray-600\/25{background-color:#4b556340}.bg-gray-600\/30{background-color:#4b55634d}.bg-gray-600\/40{background-color:#4b556366}.bg-gray-600\/5{background-color:#4b55630d}.bg-gray-600\/50{background-color:#4b556380}.bg-gray-600\/60{background-color:#4b556399}.bg-gray-600\/70{background-color:#4b5563b3}.bg-gray-600\/75{background-color:#4b5563bf}.bg-gray-600\/80{background-color:#4b5563cc}.bg-gray-600\/90{background-color:#4b5563e6}.bg-gray-600\/95{background-color:#4b5563f2}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.bg-indigo-500{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.bg-indigo-500\/0{background-color:#6366f100}.bg-indigo-500\/10{background-color:#6366f11a}.bg-indigo-500\/100{background-color:#6366f1}.bg-indigo-500\/20{background-color:#6366f133}.bg-indigo-500\/25{background-color:#6366f140}.bg-indigo-500\/30{background-color:#6366f14d}.bg-indigo-500\/40{background-color:#6366f166}.bg-indigo-500\/5{background-color:#6366f10d}.bg-indigo-500\/50{background-color:#6366f180}.bg-indigo-500\/60{background-color:#6366f199}.bg-indigo-500\/70{background-color:#6366f1b3}.bg-indigo-500\/75{background-color:#6366f1bf}.bg-indigo-500\/80{background-color:#6366f1cc}.bg-indigo-500\/90{background-color:#6366f1e6}.bg-indigo-500\/95{background-color:#6366f1f2}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity))}.bg-indigo-600\/0{background-color:#4f46e500}.bg-indigo-600\/10{background-color:#4f46e51a}.bg-indigo-600\/100{background-color:#4f46e5}.bg-indigo-600\/20{background-color:#4f46e533}.bg-indigo-600\/25{background-color:#4f46e540}.bg-indigo-600\/30{background-color:#4f46e54d}.bg-indigo-600\/40{background-color:#4f46e566}.bg-indigo-600\/5{background-color:#4f46e50d}.bg-indigo-600\/50{background-color:#4f46e580}.bg-indigo-600\/60{background-color:#4f46e599}.bg-indigo-600\/70{background-color:#4f46e5b3}.bg-indigo-600\/75{background-color:#4f46e5bf}.bg-indigo-600\/80{background-color:#4f46e5cc}.bg-indigo-600\/90{background-color:#4f46e5e6}.bg-indigo-600\/95{background-color:#4f46e5f2}.p-4{padding:1rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-8{line-height:2rem}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}.text-blue-500\/0{color:#3b82f600}.text-blue-500\/10{color:#3b82f61a}.text-blue-500\/100{color:#3b82f6}.text-blue-500\/20{color:#3b82f633}.text-blue-500\/25{color:#3b82f640}.text-blue-500\/30{color:#3b82f64d}.text-blue-500\/40{color:#3b82f666}.text-blue-500\/5{color:#3b82f60d}.text-blue-500\/50{color:#3b82f680}.text-blue-500\/60{color:#3b82f699}.text-blue-500\/70{color:#3b82f6b3}.text-blue-500\/75{color:#3b82f6bf}.text-blue-500\/80{color:#3b82f6cc}.text-blue-500\/90{color:#3b82f6e6}.text-blue-500\/95{color:#3b82f6f2}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-500\/0{color:#6b728000}.text-gray-500\/10{color:#6b72801a}.text-gray-500\/100{color:#6b7280}.text-gray-500\/20{color:#6b728033}.text-gray-500\/25{color:#6b728040}.text-gray-500\/30{color:#6b72804d}.text-gray-500\/40{color:#6b728066}.text-gray-500\/5{color:#6b72800d}.text-gray-500\/50{color:#6b728080}.text-gray-500\/60{color:#6b728099}.text-gray-500\/70{color:#6b7280b3}.text-gray-500\/75{color:#6b7280bf}.text-gray-500\/80{color:#6b7280cc}.text-gray-500\/90{color:#6b7280e6}.text-gray-500\/95{color:#6b7280f2}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-indigo-500{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity))}.text-indigo-500\/0{color:#6366f100}.text-indigo-500\/10{color:#6366f11a}.text-indigo-500\/100{color:#6366f1}.text-indigo-500\/20{color:#6366f133}.text-indigo-500\/25{color:#6366f140}.text-indigo-500\/30{color:#6366f14d}.text-indigo-500\/40{color:#6366f166}.text-indigo-500\/5{color:#6366f10d}.text-indigo-500\/50{color:#6366f180}.text-indigo-500\/60{color:#6366f199}.text-indigo-500\/70{color:#6366f1b3}.text-indigo-500\/75{color:#6366f1bf}.text-indigo-500\/80{color:#6366f1cc}.text-indigo-500\/90{color:#6366f1e6}.text-indigo-500\/95{color:#6366f1f2}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.ring-blue-200{--tw-ring-opacity:1;--tw-ring-color:rgb(191 219 254/var(--tw-ring-opacity))}.ring-blue-200\/0{--tw-ring-color:#bfdbfe00}.ring-blue-200\/10{--tw-ring-color:#bfdbfe1a}.ring-blue-200\/100{--tw-ring-color:#bfdbfe}.ring-blue-200\/20{--tw-ring-color:#bfdbfe33}.ring-blue-200\/25{--tw-ring-color:#bfdbfe40}.ring-blue-200\/30{--tw-ring-color:#bfdbfe4d}.ring-blue-200\/40{--tw-ring-color:#bfdbfe66}.ring-blue-200\/5{--tw-ring-color:#bfdbfe0d}.ring-blue-200\/50{--tw-ring-color:#bfdbfe80}.ring-blue-200\/60{--tw-ring-color:#bfdbfe99}.ring-blue-200\/70{--tw-ring-color:#bfdbfeb3}.ring-blue-200\/75{--tw-ring-color:#bfdbfebf}.ring-blue-200\/80{--tw-ring-color:#bfdbfecc}.ring-blue-200\/90{--tw-ring-color:#bfdbfee6}.ring-blue-200\/95{--tw-ring-color:#bfdbfef2}.ring-blue-500{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity))}.ring-blue-500\/0{--tw-ring-color:#3b82f600}.ring-blue-500\/10{--tw-ring-color:#3b82f61a}.ring-blue-500\/100{--tw-ring-color:#3b82f6}.ring-blue-500\/20{--tw-ring-color:#3b82f633}.ring-blue-500\/25{--tw-ring-color:#3b82f640}.ring-blue-500\/30{--tw-ring-color:#3b82f64d}.ring-blue-500\/40{--tw-ring-color:#3b82f666}.ring-blue-500\/5{--tw-ring-color:#3b82f60d}.ring-blue-500\/50{--tw-ring-color:#3b82f680}.ring-blue-500\/60{--tw-ring-color:#3b82f699}.ring-blue-500\/70{--tw-ring-color:#3b82f6b3}.ring-blue-500\/75{--tw-ring-color:#3b82f6bf}.ring-blue-500\/80{--tw-ring-color:#3b82f6cc}.ring-blue-500\/90{--tw-ring-color:#3b82f6e6}.ring-blue-500\/95{--tw-ring-color:#3b82f6f2}.ring-gray-200{--tw-ring-opacity:1;--tw-ring-color:rgb(229 231 235/var(--tw-ring-opacity))}.ring-gray-200\/0{--tw-ring-color:#e5e7eb00}.ring-gray-200\/10{--tw-ring-color:#e5e7eb1a}.ring-gray-200\/100{--tw-ring-color:#e5e7eb}.ring-gray-200\/20{--tw-ring-color:#e5e7eb33}.ring-gray-200\/25{--tw-ring-color:#e5e7eb40}.ring-gray-200\/30{--tw-ring-color:#e5e7eb4d}.ring-gray-200\/40{--tw-ring-color:#e5e7eb66}.ring-gray-200\/5{--tw-ring-color:#e5e7eb0d}.ring-gray-200\/50{--tw-ring-color:#e5e7eb80}.ring-gray-200\/60{--tw-ring-color:#e5e7eb99}.ring-gray-200\/70{--tw-ring-color:#e5e7ebb3}.ring-gray-200\/75{--tw-ring-color:#e5e7ebbf}.ring-gray-200\/80{--tw-ring-color:#e5e7ebcc}.ring-gray-200\/90{--tw-ring-color:#e5e7ebe6}.ring-gray-200\/95{--tw-ring-color:#e5e7ebf2}.ring-gray-500{--tw-ring-opacity:1;--tw-ring-color:rgb(107 114 128/var(--tw-ring-opacity))}.ring-gray-500\/0{--tw-ring-color:#6b728000}.ring-gray-500\/10{--tw-ring-color:#6b72801a}.ring-gray-500\/100{--tw-ring-color:#6b7280}.ring-gray-500\/20{--tw-ring-color:#6b728033}.ring-gray-500\/25{--tw-ring-color:#6b728040}.ring-gray-500\/30{--tw-ring-color:#6b72804d}.ring-gray-500\/40{--tw-ring-color:#6b728066}.ring-gray-500\/5{--tw-ring-color:#6b72800d}.ring-gray-500\/50{--tw-ring-color:#6b728080}.ring-gray-500\/60{--tw-ring-color:#6b728099}.ring-gray-500\/70{--tw-ring-color:#6b7280b3}.ring-gray-500\/75{--tw-ring-color:#6b7280bf}.ring-gray-500\/80{--tw-ring-color:#6b7280cc}.ring-gray-500\/90{--tw-ring-color:#6b7280e6}.ring-gray-500\/95{--tw-ring-color:#6b7280f2}.ring-indigo-200{--tw-ring-opacity:1;--tw-ring-color:rgb(199 210 254/var(--tw-ring-opacity))}.ring-indigo-200\/0{--tw-ring-color:#c7d2fe00}.ring-indigo-200\/10{--tw-ring-color:#c7d2fe1a}.ring-indigo-200\/100{--tw-ring-color:#c7d2fe}.ring-indigo-200\/20{--tw-ring-color:#c7d2fe33}.ring-indigo-200\/25{--tw-ring-color:#c7d2fe40}.ring-indigo-200\/30{--tw-ring-color:#c7d2fe4d}.ring-indigo-200\/40{--tw-ring-color:#c7d2fe66}.ring-indigo-200\/5{--tw-ring-color:#c7d2fe0d}.ring-indigo-200\/50{--tw-ring-color:#c7d2fe80}.ring-indigo-200\/60{--tw-ring-color:#c7d2fe99}.ring-indigo-200\/70{--tw-ring-color:#c7d2feb3}.ring-indigo-200\/75{--tw-ring-color:#c7d2febf}.ring-indigo-200\/80{--tw-ring-color:#c7d2fecc}.ring-indigo-200\/90{--tw-ring-color:#c7d2fee6}.ring-indigo-200\/95{--tw-ring-color:#c7d2fef2}.ring-indigo-500{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity))}.ring-indigo-500\/0{--tw-ring-color:#6366f100}.ring-indigo-500\/10{--tw-ring-color:#6366f11a}.ring-indigo-500\/100{--tw-ring-color:#6366f1}.ring-indigo-500\/20{--tw-ring-color:#6366f133}.ring-indigo-500\/25{--tw-ring-color:#6366f140}.ring-indigo-500\/30{--tw-ring-color:#6366f14d}.ring-indigo-500\/40{--tw-ring-color:#6366f166}.ring-indigo-500\/5{--tw-ring-color:#6366f10d}.ring-indigo-500\/50{--tw-ring-color:#6366f180}.ring-indigo-500\/60{--tw-ring-color:#6366f199}.ring-indigo-500\/70{--tw-ring-color:#6366f1b3}.ring-indigo-500\/75{--tw-ring-color:#6366f1bf}.ring-indigo-500\/80{--tw-ring-color:#6366f1cc}.ring-indigo-500\/90{--tw-ring-color:#6366f1e6}.ring-indigo-500\/95{--tw-ring-color:#6366f1f2}.ring-offset-white{--tw-ring-offset-color:#fff}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}h1:focus{outline:none}.btn-link,a{font-weight:500}.btn-primary{color:#fff;background-color:#1b6ec2;border-color:#1861ac}.btn-link.nav-link:focus,.btn:active:focus,.btn:focus,.form-check-input:focus,.form-control:focus{box-shadow:0 0 0 .1rem #fff,0 0 0 .25rem #258cfb}.content{padding-top:1.1rem}.valid.modified:not([type=checkbox]){outline:1px solid #26b050}.invalid{outline:1px solid red}.validation-message{color:red}.blazor-error-boundary{background:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIgb3ZlcmZsb3c9ImhpZGRlbiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBkPSJNMjM1IDUxaDU2djQ5aC01NnoiLz48L2NsaXBQYXRoPjwvZGVmcz48ZyBjbGlwLXBhdGg9InVybCgjYSkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZmlsbD0iI0ZGRTUwMCIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMjYzLjUwNiA1MWE0LjQgNC40IDAgMCAxIDMuMSAxLjI2NmwuNDQ2LjUzMy40ODcuODMgMjIuNjQ2IDM4LjU1NC4zNi42MTIuMTExLjIwMWE0LjI3IDQuMjcgMCAwIDEgLjM0NCAxLjY4MmMwIDIuMzg3LTEuOTYyIDQuMzIyLTQuMzgzIDQuMzIyaC00Ni4yMzRjLTIuNDIgMC00LjM4My0xLjkzNS00LjM4My00LjMyMiAwLS4yOTguMDMxLS41OS4wODktLjg3bC4yNDktLjc5Mi41Mi0uODg1IDIyLjYxNS0zOC41MDIuNDg4LS44My40NDYtLjUzM0E0LjQgNC40IDAgMCAxIDI2My41MDYgNTFabS4wOCAxNS4wMThjLTIuODQ5IDAtNC4yNzMgMS4xMDYtNC4yNzMgMy4zMTkgMCAuMjczLjAxOS41MjQuMDU4Ljc1MmwyLjQyNCAxMy45MjdoMy41ODVsMi40NDEtMTQuMjY5Yy4wMzktLjAxNi4wNTgtLjE2LjA1OC0uNDMgMC0yLjE5OS0xLjQzMS0zLjI5OS00LjI5My0zLjI5OVptLS4wMSAyMC4wMzdjLTIuNTI3IDAtMy43OSAxLjI0NS0zLjc5IDMuNzM3czEuMjYzIDMuNzM3IDMuNzkgMy43MzdjMi41NCAwIDMuODExLTEuMjQ1IDMuODExLTMuNzM3cy0xLjI3MS0zLjczNy0zLjgxMS0zLjczN1oiLz48L2c+PC9zdmc+) no-repeat 1rem/1.8rem,#b32121;padding:1rem 1rem 1rem 3.7rem;color:#fff}.blazor-error-boundary:after{content:"An error has occurred."}#blazor-error-ui{background:#ffffe0;bottom:0;box-shadow:0 -1px 2px #0003;display:none;left:0;padding:.6rem 1.25rem .7rem;position:fixed;width:100%;z-index:1000}#blazor-error-ui .dismiss{cursor:pointer;position:absolute;right:.75rem;top:.5rem}.hover\:bg-blue-600:hover{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity))}.hover\:bg-blue-600\/0:hover{background-color:#2563eb00}.hover\:bg-blue-600\/10:hover{background-color:#2563eb1a}.hover\:bg-blue-600\/100:hover{background-color:#2563eb}.hover\:bg-blue-600\/20:hover{background-color:#2563eb33}.hover\:bg-blue-600\/25:hover{background-color:#2563eb40}.hover\:bg-blue-600\/30:hover{background-color:#2563eb4d}.hover\:bg-blue-600\/40:hover{background-color:#2563eb66}.hover\:bg-blue-600\/5:hover{background-color:#2563eb0d}.hover\:bg-blue-600\/50:hover{background-color:#2563eb80}.hover\:bg-blue-600\/60:hover{background-color:#2563eb99}.hover\:bg-blue-600\/70:hover{background-color:#2563ebb3}.hover\:bg-blue-600\/75:hover{background-color:#2563ebbf}.hover\:bg-blue-600\/80:hover{background-color:#2563ebcc}.hover\:bg-blue-600\/90:hover{background-color:#2563ebe6}.hover\:bg-blue-600\/95:hover{background-color:#2563ebf2}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity))}.hover\:bg-gray-600\/0:hover{background-color:#4b556300}.hover\:bg-gray-600\/10:hover{background-color:#4b55631a}.hover\:bg-gray-600\/100:hover{background-color:#4b5563}.hover\:bg-gray-600\/20:hover{background-color:#4b556333}.hover\:bg-gray-600\/25:hover{background-color:#4b556340}.hover\:bg-gray-600\/30:hover{background-color:#4b55634d}.hover\:bg-gray-600\/40:hover{background-color:#4b556366}.hover\:bg-gray-600\/5:hover{background-color:#4b55630d}.hover\:bg-gray-600\/50:hover{background-color:#4b556380}.hover\:bg-gray-600\/60:hover{background-color:#4b556399}.hover\:bg-gray-600\/70:hover{background-color:#4b5563b3}.hover\:bg-gray-600\/75:hover{background-color:#4b5563bf}.hover\:bg-gray-600\/80:hover{background-color:#4b5563cc}.hover\:bg-gray-600\/90:hover{background-color:#4b5563e6}.hover\:bg-gray-600\/95:hover{background-color:#4b5563f2}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.hover\:bg-indigo-600:hover{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity))}.hover\:bg-indigo-600\/0:hover{background-color:#4f46e500}.hover\:bg-indigo-600\/10:hover{background-color:#4f46e51a}.hover\:bg-indigo-600\/100:hover{background-color:#4f46e5}.hover\:bg-indigo-600\/20:hover{background-color:#4f46e533}.hover\:bg-indigo-600\/25:hover{background-color:#4f46e540}.hover\:bg-indigo-600\/30:hover{background-color:#4f46e54d}.hover\:bg-indigo-600\/40:hover{background-color:#4f46e566}.hover\:bg-indigo-600\/5:hover{background-color:#4f46e50d}.hover\:bg-indigo-600\/50:hover{background-color:#4f46e580}.hover\:bg-indigo-600\/60:hover{background-color:#4f46e599}.hover\:bg-indigo-600\/70:hover{background-color:#4f46e5b3}.hover\:bg-indigo-600\/75:hover{background-color:#4f46e5bf}.hover\:bg-indigo-600\/80:hover{background-color:#4f46e5cc}.hover\:bg-indigo-600\/90:hover{background-color:#4f46e5e6}.hover\:bg-indigo-600\/95:hover{background-color:#4f46e5f2}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity))}.focus\:bg-blue-500:focus{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.focus\:bg-blue-500\/0:focus{background-color:#3b82f600}.focus\:bg-blue-500\/10:focus{background-color:#3b82f61a}.focus\:bg-blue-500\/100:focus{background-color:#3b82f6}.focus\:bg-blue-500\/20:focus{background-color:#3b82f633}.focus\:bg-blue-500\/25:focus{background-color:#3b82f640}.focus\:bg-blue-500\/30:focus{background-color:#3b82f64d}.focus\:bg-blue-500\/40:focus{background-color:#3b82f666}.focus\:bg-blue-500\/5:focus{background-color:#3b82f60d}.focus\:bg-blue-500\/50:focus{background-color:#3b82f680}.focus\:bg-blue-500\/60:focus{background-color:#3b82f699}.focus\:bg-blue-500\/70:focus{background-color:#3b82f6b3}.focus\:bg-blue-500\/75:focus{background-color:#3b82f6bf}.focus\:bg-blue-500\/80:focus{background-color:#3b82f6cc}.focus\:bg-blue-500\/90:focus{background-color:#3b82f6e6}.focus\:bg-blue-500\/95:focus{background-color:#3b82f6f2}.focus\:bg-gray-500:focus{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity))}.focus\:bg-gray-500\/0:focus{background-color:#6b728000}.focus\:bg-gray-500\/10:focus{background-color:#6b72801a}.focus\:bg-gray-500\/100:focus{background-color:#6b7280}.focus\:bg-gray-500\/20:focus{background-color:#6b728033}.focus\:bg-gray-500\/25:focus{background-color:#6b728040}.focus\:bg-gray-500\/30:focus{background-color:#6b72804d}.focus\:bg-gray-500\/40:focus{background-color:#6b728066}.focus\:bg-gray-500\/5:focus{background-color:#6b72800d}.focus\:bg-gray-500\/50:focus{background-color:#6b728080}.focus\:bg-gray-500\/60:focus{background-color:#6b728099}.focus\:bg-gray-500\/70:focus{background-color:#6b7280b3}.focus\:bg-gray-500\/75:focus{background-color:#6b7280bf}.focus\:bg-gray-500\/80:focus{background-color:#6b7280cc}.focus\:bg-gray-500\/90:focus{background-color:#6b7280e6}.focus\:bg-gray-500\/95:focus{background-color:#6b7280f2}.focus\:bg-indigo-500:focus{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.focus\:bg-indigo-500\/0:focus{background-color:#6366f100}.focus\:bg-indigo-500\/10:focus{background-color:#6366f11a}.focus\:bg-indigo-500\/100:focus{background-color:#6366f1}.focus\:bg-indigo-500\/20:focus{background-color:#6366f133}.focus\:bg-indigo-500\/25:focus{background-color:#6366f140}.focus\:bg-indigo-500\/30:focus{background-color:#6366f14d}.focus\:bg-indigo-500\/40:focus{background-color:#6366f166}.focus\:bg-indigo-500\/5:focus{background-color:#6366f10d}.focus\:bg-indigo-500\/50:focus{background-color:#6366f180}.focus\:bg-indigo-500\/60:focus{background-color:#6366f199}.focus\:bg-indigo-500\/70:focus{background-color:#6366f1b3}.focus\:bg-indigo-500\/75:focus{background-color:#6366f1bf}.focus\:bg-indigo-500\/80:focus{background-color:#6366f1cc}.focus\:bg-indigo-500\/90:focus{background-color:#6366f1e6}.focus\:bg-indigo-500\/95:focus{background-color:#6366f1f2}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(191 219 254/var(--tw-ring-opacity))}.focus\:ring-blue-200\/0:focus{--tw-ring-color:#bfdbfe00}.focus\:ring-blue-200\/10:focus{--tw-ring-color:#bfdbfe1a}.focus\:ring-blue-200\/100:focus{--tw-ring-color:#bfdbfe}.focus\:ring-blue-200\/20:focus{--tw-ring-color:#bfdbfe33}.focus\:ring-blue-200\/25:focus{--tw-ring-color:#bfdbfe40}.focus\:ring-blue-200\/30:focus{--tw-ring-color:#bfdbfe4d}.focus\:ring-blue-200\/40:focus{--tw-ring-color:#bfdbfe66}.focus\:ring-blue-200\/5:focus{--tw-ring-color:#bfdbfe0d}.focus\:ring-blue-200\/50:focus{--tw-ring-color:#bfdbfe80}.focus\:ring-blue-200\/60:focus{--tw-ring-color:#bfdbfe99}.focus\:ring-blue-200\/70:focus{--tw-ring-color:#bfdbfeb3}.focus\:ring-blue-200\/75:focus{--tw-ring-color:#bfdbfebf}.focus\:ring-blue-200\/80:focus{--tw-ring-color:#bfdbfecc}.focus\:ring-blue-200\/90:focus{--tw-ring-color:#bfdbfee6}.focus\:ring-blue-200\/95:focus{--tw-ring-color:#bfdbfef2}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity))}.focus\:ring-blue-500\/0:focus{--tw-ring-color:#3b82f600}.focus\:ring-blue-500\/10:focus{--tw-ring-color:#3b82f61a}.focus\:ring-blue-500\/100:focus{--tw-ring-color:#3b82f6}.focus\:ring-blue-500\/20:focus{--tw-ring-color:#3b82f633}.focus\:ring-blue-500\/25:focus{--tw-ring-color:#3b82f640}.focus\:ring-blue-500\/30:focus{--tw-ring-color:#3b82f64d}.focus\:ring-blue-500\/40:focus{--tw-ring-color:#3b82f666}.focus\:ring-blue-500\/5:focus{--tw-ring-color:#3b82f60d}.focus\:ring-blue-500\/50:focus{--tw-ring-color:#3b82f680}.focus\:ring-blue-500\/60:focus{--tw-ring-color:#3b82f699}.focus\:ring-blue-500\/70:focus{--tw-ring-color:#3b82f6b3}.focus\:ring-blue-500\/75:focus{--tw-ring-color:#3b82f6bf}.focus\:ring-blue-500\/80:focus{--tw-ring-color:#3b82f6cc}.focus\:ring-blue-500\/90:focus{--tw-ring-color:#3b82f6e6}.focus\:ring-blue-500\/95:focus{--tw-ring-color:#3b82f6f2}.focus\:ring-gray-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(229 231 235/var(--tw-ring-opacity))}.focus\:ring-gray-200\/0:focus{--tw-ring-color:#e5e7eb00}.focus\:ring-gray-200\/10:focus{--tw-ring-color:#e5e7eb1a}.focus\:ring-gray-200\/100:focus{--tw-ring-color:#e5e7eb}.focus\:ring-gray-200\/20:focus{--tw-ring-color:#e5e7eb33}.focus\:ring-gray-200\/25:focus{--tw-ring-color:#e5e7eb40}.focus\:ring-gray-200\/30:focus{--tw-ring-color:#e5e7eb4d}.focus\:ring-gray-200\/40:focus{--tw-ring-color:#e5e7eb66}.focus\:ring-gray-200\/5:focus{--tw-ring-color:#e5e7eb0d}.focus\:ring-gray-200\/50:focus{--tw-ring-color:#e5e7eb80}.focus\:ring-gray-200\/60:focus{--tw-ring-color:#e5e7eb99}.focus\:ring-gray-200\/70:focus{--tw-ring-color:#e5e7ebb3}.focus\:ring-gray-200\/75:focus{--tw-ring-color:#e5e7ebbf}.focus\:ring-gray-200\/80:focus{--tw-ring-color:#e5e7ebcc}.focus\:ring-gray-200\/90:focus{--tw-ring-color:#e5e7ebe6}.focus\:ring-gray-200\/95:focus{--tw-ring-color:#e5e7ebf2}.focus\:ring-gray-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(107 114 128/var(--tw-ring-opacity))}.focus\:ring-gray-500\/0:focus{--tw-ring-color:#6b728000}.focus\:ring-gray-500\/10:focus{--tw-ring-color:#6b72801a}.focus\:ring-gray-500\/100:focus{--tw-ring-color:#6b7280}.focus\:ring-gray-500\/20:focus{--tw-ring-color:#6b728033}.focus\:ring-gray-500\/25:focus{--tw-ring-color:#6b728040}.focus\:ring-gray-500\/30:focus{--tw-ring-color:#6b72804d}.focus\:ring-gray-500\/40:focus{--tw-ring-color:#6b728066}.focus\:ring-gray-500\/5:focus{--tw-ring-color:#6b72800d}.focus\:ring-gray-500\/50:focus{--tw-ring-color:#6b728080}.focus\:ring-gray-500\/60:focus{--tw-ring-color:#6b728099}.focus\:ring-gray-500\/70:focus{--tw-ring-color:#6b7280b3}.focus\:ring-gray-500\/75:focus{--tw-ring-color:#6b7280bf}.focus\:ring-gray-500\/80:focus{--tw-ring-color:#6b7280cc}.focus\:ring-gray-500\/90:focus{--tw-ring-color:#6b7280e6}.focus\:ring-gray-500\/95:focus{--tw-ring-color:#6b7280f2}.focus\:ring-gray-800:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(31 41 55/var(--tw-ring-opacity))}.focus\:ring-indigo-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(199 210 254/var(--tw-ring-opacity))}.focus\:ring-indigo-200\/0:focus{--tw-ring-color:#c7d2fe00}.focus\:ring-indigo-200\/10:focus{--tw-ring-color:#c7d2fe1a}.focus\:ring-indigo-200\/100:focus{--tw-ring-color:#c7d2fe}.focus\:ring-indigo-200\/20:focus{--tw-ring-color:#c7d2fe33}.focus\:ring-indigo-200\/25:focus{--tw-ring-color:#c7d2fe40}.focus\:ring-indigo-200\/30:focus{--tw-ring-color:#c7d2fe4d}.focus\:ring-indigo-200\/40:focus{--tw-ring-color:#c7d2fe66}.focus\:ring-indigo-200\/5:focus{--tw-ring-color:#c7d2fe0d}.focus\:ring-indigo-200\/50:focus{--tw-ring-color:#c7d2fe80}.focus\:ring-indigo-200\/60:focus{--tw-ring-color:#c7d2fe99}.focus\:ring-indigo-200\/70:focus{--tw-ring-color:#c7d2feb3}.focus\:ring-indigo-200\/75:focus{--tw-ring-color:#c7d2febf}.focus\:ring-indigo-200\/80:focus{--tw-ring-color:#c7d2fecc}.focus\:ring-indigo-200\/90:focus{--tw-ring-color:#c7d2fee6}.focus\:ring-indigo-200\/95:focus{--tw-ring-color:#c7d2fef2}.focus\:ring-indigo-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity))}.focus\:ring-indigo-500\/0:focus{--tw-ring-color:#6366f100}.focus\:ring-indigo-500\/10:focus{--tw-ring-color:#6366f11a}.focus\:ring-indigo-500\/100:focus{--tw-ring-color:#6366f1}.focus\:ring-indigo-500\/20:focus{--tw-ring-color:#6366f133}.focus\:ring-indigo-500\/25:focus{--tw-ring-color:#6366f140}.focus\:ring-indigo-500\/30:focus{--tw-ring-color:#6366f14d}.focus\:ring-indigo-500\/40:focus{--tw-ring-color:#6366f166}.focus\:ring-indigo-500\/5:focus{--tw-ring-color:#6366f10d}.focus\:ring-indigo-500\/50:focus{--tw-ring-color:#6366f180}.focus\:ring-indigo-500\/60:focus{--tw-ring-color:#6366f199}.focus\:ring-indigo-500\/70:focus{--tw-ring-color:#6366f1b3}.focus\:ring-indigo-500\/75:focus{--tw-ring-color:#6366f1bf}.focus\:ring-indigo-500\/80:focus{--tw-ring-color:#6366f1cc}.focus\:ring-indigo-500\/90:focus{--tw-ring-color:#6366f1e6}.focus\:ring-indigo-500\/95:focus{--tw-ring-color:#6366f1f2}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}@media (prefers-color-scheme:dark){.dark\:block{display:block}.dark\:hidden{display:none}.dark\:divide-gray-700>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(55 65 81/var(--tw-divide-opacity))}.dark\:border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity))}.dark\:bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.dark\:bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity))}.dark\:bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.dark\:text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity))}.dark\:text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.dark\:text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.dark\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.dark\:hover\:bg-gray-900:hover{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity))}.dark\:focus\:ring-gray-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(75 85 99/var(--tw-ring-opacity))}.dark\:focus\:ring-offset-gray-800:focus{--tw-ring-offset-color:#1f2937}}@media (min-width:640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:768px){.md\:mb-4{margin-bottom:1rem}.md\:mt-4{margin-top:1rem}.md\:inline{display:inline}.md\:grid{display:grid}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.md\:gap-2{gap:.5rem}.md\:text-left{text-align:left}}@media (min-width:1024px){.lg\:py-7{padding-top:1.75rem;padding-bottom:1.75rem}.lg\:py-8{padding-top:2rem;padding-bottom:2rem}} \ No newline at end of file diff --git a/wwwroot/favicon.png b/PointingParty/wwwroot/favicon.png similarity index 100% rename from wwwroot/favicon.png rename to PointingParty/wwwroot/favicon.png diff --git a/PointingParty/wwwroot/pointpingparty-cactus-dark.svg b/PointingParty/wwwroot/pointpingparty-cactus-dark.svg new file mode 100644 index 0000000..edb209a --- /dev/null +++ b/PointingParty/wwwroot/pointpingparty-cactus-dark.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/PointingParty/wwwroot/pointpingparty-cactus-light.svg b/PointingParty/wwwroot/pointpingparty-cactus-light.svg new file mode 100644 index 0000000..17f5985 --- /dev/null +++ b/PointingParty/wwwroot/pointpingparty-cactus-light.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Program.cs b/Program.cs deleted file mode 100644 index c19ef53..0000000 --- a/Program.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Net; -using MassTransit; -using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Rewrite; -using PointingParty.Components; -using PointingParty.Infrastructure; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; - -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(options => options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(15)); - -builder.Services.AddMassTransit(x => -{ - x.SetKebabCaseEndpointNameFormatter(); - x.SetInMemorySagaRepositoryProvider(); - - x.AddConsumer(); - x.AddConsumer(); - - x.UsingInMemory((context, cfg) => { cfg.ConfigureEndpoints(context); }); -}); - -builder.Services.Configure(options => -{ - options.ForwardedHeaders = - ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("172.16.0.0"), 16)); -}); - -builder.Services.AddSingleton(); -builder.Services.AddScoped(); - -var app = builder.Build(); - -app.UseForwardedHeaders(); -app.UseRewriter(new RewriteOptions().AddRedirectToNonWwwPermanent()); - -// Configure the HTTP request pipeline. -if (!app.Environment.IsDevelopment()) -{ - app.UseExceptionHandler("/Error", true); - app.UseHsts(); - app.UseHttpsRedirection(); -} - -app.UseStaticFiles(); -app.UseAntiforgery(); - -app.MapRazorComponents() - .AddInteractiveServerRenderMode(); - -app.Run(); diff --git a/README.md b/README.md index 29dedf9..5737ae3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Pointing Party logo](wwwroot/pointpingparty-cactus-light.svg) +![Pointing Party logo](PointingParty/wwwroot/pointpingparty-cactus-light.svg) # Pointing Party @@ -6,10 +6,10 @@ Pointing Party is a web application to aid in agile story point estimation proce to entry. Simply start a game, share the URL, and start voting. Registration is neither necessary nor possible and the application is built to be usable from all platforms and devices. -The project is powered by Blazor Server and does not rely on a backend database. Instead, -each client maintains its own game state based on events published on a central message bus. +The project does not rely on a backend database. Instead, each client maintains its own game state +based on events published through the central SignalR hub. -This project uses .NET 8, Blazor, Mass Transit and Tailwind CSS. +This project uses .NET 8, Blazor, and Tailwind CSS. ## Try it out @@ -18,7 +18,6 @@ free to use it in your team. ## Contributing -The application has no dependencies outside of .NET 8 and publicly available NuGet. Simply clone -and run. Pull requests are welcome! +Simply clone and run. Pull requests are welcome! -© 2023 Martijn Storck +© Martijn Storck diff --git a/e2e/lib/helpers.ts b/e2e/lib/helpers.ts index bc91e47..062c77f 100644 --- a/e2e/lib/helpers.ts +++ b/e2e/lib/helpers.ts @@ -1,3 +1,17 @@ -export function gameName() : string { - return 'TestGame' + Math.random().toString().substring(2, 8); +import { expect, Page, test } from "@playwright/test"; + +export function gameName(): string { + return "TestGame" + Math.random().toString().substring(2, 8); +} + +export async function waitForWasmInitialization(page: Page) { + await page.waitForFunction(() => { + const element = document.querySelector("html"); + return ( + element && + window + .getComputedStyle(element) + .getPropertyValue("--blazor-load-percentage") === "100%" + ); + }); } diff --git a/e2e/tests/game.spec.ts b/e2e/tests/game.spec.ts index 882c5d7..c6bee09 100644 --- a/e2e/tests/game.spec.ts +++ b/e2e/tests/game.spec.ts @@ -1,91 +1,117 @@ -import {expect, Page, test} from '@playwright/test'; -import {gameName} from "../lib/helpers"; +import { expect, Page, test } from "@playwright/test"; +import { gameName, waitForWasmInitialization } from "../lib/helpers"; -test('can start a game from home', async ({page}) => { - const game = gameName(); +test("can start a game from home", async ({ page }) => { + const game = gameName(); - await page.goto('/'); + await page.goto("/"); - await page.getByPlaceholder('My Pointing Party').fill(game); - await page.getByPlaceholder('Player Name').fill('Player One'); - await page.getByRole('button', {name: 'Start game'}).click(); + await page.getByPlaceholder("My Pointing Party").fill(game); + await page.getByPlaceholder("Player Name").fill("Player One"); + await page.getByRole("button", { name: "Start game" }).click(); - await expect(page).toHaveURL(`/Game/${game}`); - await expect(page.getByRole('heading', {name: 'Pointing Party'})).toContainText(game); - await expect(page.getByRole('heading', {name: 'Your vote'})).toContainText('Player One'); + await expect(page).toHaveURL(`/Game/${game}`); + await expect( + page.getByRole("heading", { name: "Pointing Party" }), + ).toContainText(game); + await expect(page.getByRole("heading", { name: "Your vote" })).toContainText( + "Player One", + ); }); -test('can start a game from a game URL', async ({page}) => { - const game = gameName(); +test("can start a game from a game URL", async ({ page }) => { + const game = gameName(); - await page.goto(`/Game/${game}`); + await page.goto(`/Game/${game}`); - const playerNameInput = page.getByPlaceholder('Player Name'); - await playerNameInput.fill('Player Two'); - await playerNameInput.press('Enter'); + // Not great pactice, but Playwright is so fast with entering the player name, the wasm hydration overwrites it + await waitForWasmInitialization(page); - await expect(page.getByRole('heading', {name: 'Pointing Party'})).toContainText(game); - await expect(page.getByRole('heading', {name: 'Your vote'})).toContainText('Player Two'); + await page.getByPlaceholder("Player Name").fill("Player Two"); + await page.getByRole("button", { name: "Enter game" }).click(); + + await expect( + page.getByRole("heading", { name: "Pointing Party" }), + ).toContainText(game); + await expect(page.getByRole("heading", { name: "Your vote" })).toContainText( + "Player Two", + ); }); -test('play with two players', async ({context}) => { - const game = gameName(); +test("play with two players", async ({ context }) => { + const game = gameName(); - const pageOne = await context.newPage(); - const pageTwo = await context.newPage(); + const pageOne = await context.newPage(); + const pageTwo = await context.newPage(); - await pageOne.goto(`/Game/${game}?PlayerName=Player%20One`); - await pageTwo.goto(`/Game/${game}?PlayerName=Player%20Two`); + await pageOne.goto(`/Game/${game}?PlayerName=Player%20One`); + await pageTwo.goto(`/Game/${game}?PlayerName=Player%20Two`); - await pageOne.getByRole('button', {name: '1', exact: true}).click(); + await pageOne.getByRole("button", { name: "1", exact: true }).click(); - const pageOnePlayerOneScore = pageOne.getByRole('cell', {name: 'Player One'}).locator('+ td'); - const pageOnePlayerTwoScore = pageOne.getByRole('cell', {name: 'Player Two'}).locator('+ td'); - const pageTwoPlayerOneScore = pageTwo.getByRole('cell', {name: 'Player One'}).locator('+ td'); - const pageTwoPlayerTwoScore = pageTwo.getByRole('cell', {name: 'Player Two'}).locator('+ td'); + const pageOnePlayerOneScore = pageOne + .getByRole("cell", { name: "Player One" }) + .locator("+ td"); + const pageOnePlayerTwoScore = pageOne + .getByRole("cell", { name: "Player Two" }) + .locator("+ td"); + const pageTwoPlayerOneScore = pageTwo + .getByRole("cell", { name: "Player One" }) + .locator("+ td"); + const pageTwoPlayerTwoScore = pageTwo + .getByRole("cell", { name: "Player Two" }) + .locator("+ td"); - await expect(pageOnePlayerOneScore).toContainText('1'); - await expect(pageTwoPlayerOneScore).toContainText('Voted'); + await expect(pageOnePlayerOneScore).toContainText("1"); + await expect(pageTwoPlayerOneScore).toContainText("Voted"); - await pageTwo.getByRole('button', {name: '2', exact: true}).click(); + await pageTwo.getByRole("button", { name: "2", exact: true }).click(); - await expect(pageOnePlayerTwoScore).toContainText('Voted'); - await expect(pageTwoPlayerTwoScore).toContainText('2'); + await expect(pageOnePlayerTwoScore).toContainText("Voted"); + await expect(pageTwoPlayerTwoScore).toContainText("2"); - await pageOne.getByRole('button', {name: 'Show votes'}).click(); + await pageOne.getByRole("button", { name: "Show votes" }).click(); - await expect(pageOnePlayerOneScore).toContainText('1'); - await expect(pageOnePlayerTwoScore).toContainText('2'); + await expect(pageOnePlayerOneScore).toContainText("1"); + await expect(pageOnePlayerTwoScore).toContainText("2"); - await expect(pageTwoPlayerOneScore).toContainText('1'); - await expect(pageTwoPlayerTwoScore).toContainText('2'); + await expect(pageTwoPlayerOneScore).toContainText("1"); + await expect(pageTwoPlayerTwoScore).toContainText("2"); - await pageTwo.getByRole('button', {name: 'Clear votes'}).click(); + await pageTwo.getByRole("button", { name: "Clear votes" }).click(); - await expect(pageOnePlayerOneScore).toBeEmpty(); - await expect(pageOnePlayerTwoScore).toBeEmpty(); + await expect(pageOnePlayerOneScore).toBeEmpty(); + await expect(pageOnePlayerTwoScore).toBeEmpty(); - await expect(pageTwoPlayerOneScore).toBeEmpty(); - await expect(pageTwoPlayerTwoScore).toBeEmpty(); + await expect(pageTwoPlayerOneScore).toBeEmpty(); + await expect(pageTwoPlayerTwoScore).toBeEmpty(); }); -test('shows statistics', async ({page, context}) => { - const game = gameName(); - - const joinAndVote = async (page: Page, playerName: string, vote: number | null) => { - await page.goto(`/Game/${game}?PlayerName=${playerName}`); - if (vote == null) return; - - await page.getByRole('button', {name: vote.toString(), exact: true}).click(); - }; - - await joinAndVote(page, "Player One", 3); - await joinAndVote(await context.newPage(), "Player Two", 5); - await joinAndVote(await context.newPage(), "Player Three", 5); - await joinAndVote(await context.newPage(), "Player Abstains", null); - - await expect(page.getByRole('cell', {name: 'Player Abstains'})).toBeVisible(); - await page.getByRole('button', {name: 'Show votes'}).click(); - - await expect(page.getByTestId('average')).toContainText('4,33'); +test("shows statistics", async ({ page, context }) => { + const game = gameName(); + + const joinAndVote = async ( + page: Page, + playerName: string, + vote: number | null, + ) => { + await page.goto(`/Game/${game}?PlayerName=${playerName}`); + if (vote == null) return; + + await page + .getByRole("button", { name: vote.toString(), exact: true }) + .click(); + }; + + await joinAndVote(page, "Player One", 3); + await joinAndVote(await context.newPage(), "Player Two", 5); + await joinAndVote(await context.newPage(), "Player Three", 5); + await joinAndVote(await context.newPage(), "Player Abstains", null); + + await expect( + page.getByRole("cell", { name: "Player Abstains" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Show votes" }).click(); + + await expect(page.getByTestId("average")).toContainText("4.33"); }); diff --git a/global.json b/global.json index dad2db5..f4fd385 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.0", + "version": "9.0.0", "rollForward": "latestMajor", "allowPrerelease": true } diff --git a/tailwind.config.js b/tailwind.config.js index 81ca8ea..7916a60 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,8 +1,9 @@ /** @type {import('tailwindcss').Config} */ module.exports = { content: [ - './Components/**/*.razor', -], + './PointingParty/Components/**/*.razor', + './PointingParty.Client/**/*.razor' + ], theme: { extend: {}, }, diff --git a/wwwroot/app.js b/wwwroot/app.js deleted file mode 100644 index 0f07ecb..0000000 --- a/wwwroot/app.js +++ /dev/null @@ -1,3 +0,0 @@ -window.replaceURL = (url) => { - history.replaceState(null, "", url); -} diff --git a/wwwroot/pointpingparty-cactus-dark.svg b/wwwroot/pointpingparty-cactus-dark.svg deleted file mode 100644 index 4086d85..0000000 --- a/wwwroot/pointpingparty-cactus-dark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/wwwroot/pointpingparty-cactus-light.svg b/wwwroot/pointpingparty-cactus-light.svg deleted file mode 100644 index afebdb5..0000000 --- a/wwwroot/pointpingparty-cactus-light.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file