@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
+
+
+ Pointing Party: @GameId
+
+
+@if (GameContext.Game is not null)
+{
+
+}
+else
+{
+
+
+
+
+
+ Your name:
+
+
+
+
+
+ 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 @@
@Body
-
+
© 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