Replace custom socket implementation with SignalR.
Replace MSAL and custom cookie auth with Microsoft.Identity.EntityFramework Also some UI redesign to accommodate different login experience.
This commit is contained in:
@@ -1,27 +0,0 @@
|
||||
@page "/authentication/{action}"
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||
@inject NavigationManager navigationManager
|
||||
|
||||
<RemoteAuthenticatorView Action="@Action" LogInFailed="LoginFailed" LogOutSucceeded="LogoutSuccess()">
|
||||
</RemoteAuthenticatorView>
|
||||
@code {
|
||||
[Parameter] public string? Action { get; set; }
|
||||
// https://github.com/dotnet/aspnetcore/blob/main/src/Components/WebAssembly/WebAssembly.Authentication/src/Models/RemoteAuthenticationActions.cs
|
||||
// https://github.com/dotnet/aspnetcore/blob/7c810658463f35c39c54d5fb8a8dbbfd463bf747/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticatorViewCore.cs
|
||||
|
||||
RenderFragment LoginFailed(string message)
|
||||
{
|
||||
Console.WriteLine($"Failed to login because: {message}");
|
||||
if (message.Contains("AADSTS65004", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return builder => navigationManager.NavigateTo("/");
|
||||
}
|
||||
return builder => navigationManager.NavigateTo("/error");
|
||||
}
|
||||
|
||||
RenderFragment LogoutSuccess()
|
||||
{
|
||||
return builder => navigationManager.NavigateTo("/");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@page "/error"
|
||||
|
||||
<main>
|
||||
<main class="PrimaryTheme">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Oops!</h1>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
|
||||
using Shogi.UI.Pages.Home.Api;
|
||||
using Shogi.UI.Shared;
|
||||
|
||||
namespace Shogi.UI.Pages.Home.Account;
|
||||
|
||||
public class AccountManager
|
||||
{
|
||||
private readonly AccountState accountState;
|
||||
private readonly IShogiApi shogiApi;
|
||||
private readonly ILocalStorage localStorage;
|
||||
private readonly AuthenticationStateProvider authState;
|
||||
private readonly NavigationManager navigation;
|
||||
private readonly ShogiSocket shogiSocket;
|
||||
|
||||
public AccountManager(
|
||||
AccountState accountState,
|
||||
IShogiApi unauthenticatedClient,
|
||||
AuthenticationStateProvider authState,
|
||||
ILocalStorage localStorage,
|
||||
NavigationManager navigation,
|
||||
ShogiSocket shogiSocket)
|
||||
{
|
||||
this.accountState = accountState;
|
||||
this.shogiApi = unauthenticatedClient;
|
||||
this.authState = authState;
|
||||
this.localStorage = localStorage;
|
||||
this.navigation = navigation;
|
||||
this.shogiSocket = shogiSocket;
|
||||
}
|
||||
|
||||
private Task SetUser(User user) => accountState.SetUser(user);
|
||||
|
||||
|
||||
public async Task LoginWithGuestAccount()
|
||||
{
|
||||
var response = await shogiApi.GetToken(WhichAccountPlatform.Guest);
|
||||
if (response != null)
|
||||
{
|
||||
await SetUser(new User
|
||||
{
|
||||
DisplayName = response.DisplayName,
|
||||
Id = response.UserId,
|
||||
WhichAccountPlatform = WhichAccountPlatform.Guest
|
||||
});
|
||||
await localStorage.SetAccountPlatform(WhichAccountPlatform.Guest);
|
||||
// TODO: OpenAsync() sometimes doesn't return, probably because of the fire'n'forget task inside it. Figure that out.
|
||||
await shogiSocket.OpenAsync(response.OneTimeToken.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Failed to get token from server during guest login.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoginWithMicrosoftAccount()
|
||||
{
|
||||
var state = await authState.GetAuthenticationStateAsync();
|
||||
|
||||
if (state?.User?.Identity?.Name == null || state.User?.Identity?.IsAuthenticated == false)
|
||||
{
|
||||
// Set the login platform so that we know to log in with microsoft after being redirected away from the UI.
|
||||
await localStorage.SetAccountPlatform(WhichAccountPlatform.Microsoft);
|
||||
navigation.NavigateToLogin("authentication/login");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to log in with the account used from the previous browser session.
|
||||
/// </summary>
|
||||
/// <returns>True if login succeeded.</returns>
|
||||
public async Task<bool> TryLoginSilentAsync()
|
||||
{
|
||||
var platform = await localStorage.GetAccountPlatform();
|
||||
if (platform == WhichAccountPlatform.Guest)
|
||||
{
|
||||
var response = await shogiApi.GetToken(WhichAccountPlatform.Guest);
|
||||
if (response != null)
|
||||
{
|
||||
await accountState.SetUser(new User(
|
||||
Id: response.UserId,
|
||||
DisplayName: response.DisplayName,
|
||||
WhichAccountPlatform: WhichAccountPlatform.Guest));
|
||||
await shogiSocket.OpenAsync(response.OneTimeToken.ToString());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (platform == WhichAccountPlatform.Microsoft)
|
||||
{
|
||||
var state = await authState.GetAuthenticationStateAsync();
|
||||
if (state.User?.Identity?.Name != null)
|
||||
{
|
||||
var response = await shogiApi.GetToken(WhichAccountPlatform.Microsoft);
|
||||
if (response == null)
|
||||
{
|
||||
// Login failed, so reset local storage to avoid putting the user in a broken state.
|
||||
await localStorage.DeleteAccountPlatform();
|
||||
return false;
|
||||
}
|
||||
var id = state.User.Claims.Single(claim => claim.Type == "oid").Value;
|
||||
var displayName = state.User.Identity.Name;
|
||||
await accountState.SetUser(new User(
|
||||
Id: id,
|
||||
DisplayName: displayName,
|
||||
WhichAccountPlatform: WhichAccountPlatform.Microsoft));
|
||||
await shogiSocket.OpenAsync(response.OneTimeToken.ToString());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
var platform = await localStorage.GetAccountPlatform();
|
||||
await localStorage.DeleteAccountPlatform();
|
||||
await accountState.SetUser(null);
|
||||
|
||||
if (platform == WhichAccountPlatform.Guest)
|
||||
{
|
||||
await shogiApi.GuestLogout();
|
||||
}
|
||||
else if (platform == WhichAccountPlatform.Microsoft)
|
||||
{
|
||||
navigation.NavigateToLogout("authentication/logout");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Tried to logout without a valid account platform.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using static Shogi.UI.Shared.Events;
|
||||
|
||||
namespace Shogi.UI.Pages.Home.Account;
|
||||
|
||||
public class AccountState
|
||||
{
|
||||
public event AsyncEventHandler<LoginEventArgs>? LoginChangedEvent;
|
||||
|
||||
public User? User { get; private set; }
|
||||
|
||||
public Task SetUser(User? user)
|
||||
{
|
||||
User = user;
|
||||
return EmitLoginChangedEvent();
|
||||
}
|
||||
|
||||
private async Task EmitLoginChangedEvent()
|
||||
{
|
||||
if (LoginChangedEvent is not null)
|
||||
{
|
||||
await LoginChangedEvent.Invoke(new LoginEventArgs
|
||||
{
|
||||
User = User
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Shogi.UI.Shared;
|
||||
|
||||
namespace Shogi.UI.Pages.Home.Account;
|
||||
|
||||
public static class LocalStorageExtensions
|
||||
{
|
||||
private const string AccountPlatform = "AccountPlatform";
|
||||
|
||||
public static Task<WhichAccountPlatform?> GetAccountPlatform(this ILocalStorage self)
|
||||
{
|
||||
return self.Get<WhichAccountPlatform>(AccountPlatform).AsTask();
|
||||
}
|
||||
|
||||
public static Task SetAccountPlatform(this ILocalStorage self, WhichAccountPlatform platform)
|
||||
{
|
||||
return self.Set(AccountPlatform, platform.ToString()).AsTask();
|
||||
}
|
||||
|
||||
public static Task DeleteAccountPlatform(this ILocalStorage self)
|
||||
{
|
||||
return self.Delete(AccountPlatform).AsTask();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Shogi.UI.Pages.Home.Account;
|
||||
|
||||
public class LoginEventArgs : EventArgs
|
||||
{
|
||||
public User? User { get; set; }
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Shogi.UI.Pages.Home.Account
|
||||
{
|
||||
public readonly record struct User(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
WhichAccountPlatform WhichAccountPlatform)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Shogi.UI.Pages.Home.Account
|
||||
{
|
||||
public enum WhichAccountPlatform
|
||||
{
|
||||
Guest,
|
||||
Microsoft
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Http;
|
||||
|
||||
namespace Shogi.UI.Pages.Home.Api
|
||||
{
|
||||
public class CookieCredentialsMessageHandler : DelegatingHandler
|
||||
{
|
||||
|
||||
public CookieCredentialsMessageHandler()
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
|
||||
try
|
||||
{
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.WriteLine("Catch!");
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using Shogi.Contracts.Api;
|
||||
using Shogi.Contracts.Types;
|
||||
using Shogi.UI.Pages.Home.Account;
|
||||
using System.Net;
|
||||
|
||||
namespace Shogi.UI.Pages.Home.Api;
|
||||
|
||||
public interface IShogiApi
|
||||
{
|
||||
Task<Session?> GetSession(string name);
|
||||
Task<ReadSessionsPlayerCountResponse?> GetSessionsPlayerCount();
|
||||
Task<CreateTokenResponse?> GetToken(WhichAccountPlatform whichAccountPlatform);
|
||||
Task GuestLogout();
|
||||
Task Move(string sessionName, MovePieceCommand move);
|
||||
Task<HttpResponseMessage> PatchJoinGame(string name);
|
||||
Task<HttpStatusCode> PostSession(string name, bool isPrivate);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
|
||||
|
||||
namespace Shogi.UI.Pages.Home.Api
|
||||
{
|
||||
public class MsalMessageHandler : AuthorizationMessageHandler
|
||||
{
|
||||
public MsalMessageHandler(IAccessTokenProvider provider, NavigationManager navigation) : base(provider, navigation)
|
||||
{
|
||||
ConfigureHandler(
|
||||
authorizedUrls: new[] { "https://api.lucaserver.space/Shogi.Api", "https://localhost:5001" },
|
||||
scopes: new string[] {
|
||||
"api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope",
|
||||
//"offline_access",
|
||||
});
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
using Shogi.Contracts.Api;
|
||||
using Shogi.Contracts.Types;
|
||||
using Shogi.UI.Pages.Home.Account;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Shogi.UI.Pages.Home.Api
|
||||
{
|
||||
public class ShogiApi : IShogiApi
|
||||
{
|
||||
public const string GuestClientName = "Guest";
|
||||
public const string MsalClientName = "Msal";
|
||||
|
||||
private readonly JsonSerializerOptions serializerOptions;
|
||||
private readonly AccountState accountState;
|
||||
private readonly HttpClient guestHttpClient;
|
||||
private readonly HttpClient msalHttpClient;
|
||||
private readonly string baseUrl;
|
||||
|
||||
public ShogiApi(IHttpClientFactory clientFactory, AccountState accountState, IConfiguration configuration)
|
||||
{
|
||||
this.serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
this.accountState = accountState;
|
||||
this.guestHttpClient = clientFactory.CreateClient(GuestClientName);
|
||||
this.msalHttpClient = clientFactory.CreateClient(MsalClientName);
|
||||
this.baseUrl = configuration["ShogiApiUrl"] ?? throw new InvalidOperationException("Configuration missing.");
|
||||
this.baseUrl = this.baseUrl.TrimEnd('/');
|
||||
}
|
||||
|
||||
private HttpClient HttpClient => accountState.User?.WhichAccountPlatform switch
|
||||
{
|
||||
WhichAccountPlatform.Guest => this.guestHttpClient,
|
||||
WhichAccountPlatform.Microsoft => this.msalHttpClient,
|
||||
_ => throw new InvalidOperationException("AccountState.User must not be null during API call.")
|
||||
};
|
||||
|
||||
public async Task GuestLogout()
|
||||
{
|
||||
var response = await this.guestHttpClient.PutAsync(RelativeUri("User/GuestLogout"), null);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<Session?> GetSession(string name)
|
||||
{
|
||||
var response = await HttpClient.GetAsync(RelativeUri($"Sessions/{name}"));
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return (await response.Content.ReadFromJsonAsync<ReadSessionResponse>(serializerOptions))?.Session;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<ReadSessionsPlayerCountResponse?> GetSessionsPlayerCount()
|
||||
{
|
||||
var response = await HttpClient.GetAsync(RelativeUri("Sessions/PlayerCount"));
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadFromJsonAsync<ReadSessionsPlayerCountResponse>(serializerOptions);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the user into the API and returns a token which can be used to request a socket connection.
|
||||
/// </summary>
|
||||
public async Task<CreateTokenResponse?> GetToken(WhichAccountPlatform whichAccountPlatform)
|
||||
{
|
||||
var httpClient = whichAccountPlatform == WhichAccountPlatform.Microsoft
|
||||
? this.msalHttpClient
|
||||
: this.guestHttpClient;
|
||||
|
||||
var response = await httpClient.GetAsync(RelativeUri("User/Token"));
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
return JsonSerializer.Deserialize<CreateTokenResponse>(content, serializerOptions);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task Move(string sessionName, MovePieceCommand command)
|
||||
{
|
||||
await this.HttpClient.PatchAsync(RelativeUri($"Sessions/{sessionName}/Move"), JsonContent.Create(command));
|
||||
}
|
||||
|
||||
public async Task<HttpStatusCode> PostSession(string name, bool isPrivate)
|
||||
{
|
||||
var response = await HttpClient.PostAsJsonAsync(RelativeUri("Sessions"), new CreateSessionCommand
|
||||
{
|
||||
Name = name,
|
||||
});
|
||||
return response.StatusCode;
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> PatchJoinGame(string name)
|
||||
{
|
||||
return HttpClient.PatchAsync(RelativeUri($"Sessions/{name}/Join"), null);
|
||||
}
|
||||
|
||||
private Uri RelativeUri(string path) => new($"{this.baseUrl}/{path}", UriKind.Absolute);
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
@using Shogi.Contracts.Api
|
||||
@using Shogi.Contracts.Socket;
|
||||
@using Shogi.Contracts.Types;
|
||||
@using System.Text.RegularExpressions;
|
||||
@inject IShogiApi ShogiApi
|
||||
@inject AccountState Account;
|
||||
@inject PromotePrompt PromotePrompt;
|
||||
@inject ShogiSocket ShogiSocket;
|
||||
|
||||
@if (session == null)
|
||||
{
|
||||
<EmptyGameBoard />
|
||||
}
|
||||
else if (isSpectating)
|
||||
{
|
||||
<SpectatorGameBoard Session="session" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<SeatedGameBoard Perspective="perspective" Session="session" />
|
||||
}
|
||||
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? SessionName { get; set; }
|
||||
|
||||
Session? session;
|
||||
private WhichPlayer perspective;
|
||||
private bool isSpectating;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
ShogiSocket.OnPlayerMoved += OnPlayerMoved_FetchSession;
|
||||
ShogiSocket.OnSessionJoined += OnSessionJoined_FetchSession;
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await FetchSession();
|
||||
}
|
||||
|
||||
Task OnSessionJoined_FetchSession(SessionJoinedByPlayerSocketMessage args)
|
||||
{
|
||||
if (args.SessionName == SessionName)
|
||||
{
|
||||
return FetchSession();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task FetchSession()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(SessionName))
|
||||
{
|
||||
this.session = await ShogiApi.GetSession(SessionName);
|
||||
if (this.session != null)
|
||||
{
|
||||
var accountId = Account.User?.Id;
|
||||
|
||||
this.perspective = accountId == session.Player1.Id ? WhichPlayer.Player1 : WhichPlayer.Player2;
|
||||
Console.WriteLine(new { this.perspective, accountId });
|
||||
this.isSpectating = !(accountId == this.session.Player1.Id || accountId == this.session.Player2?.Id);
|
||||
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
Task OnPlayerMoved_FetchSession(PlayerHasMovedMessage args)
|
||||
{
|
||||
if (args.SessionName == SessionName)
|
||||
{
|
||||
return FetchSession();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
@using Shogi.Contracts.Types;
|
||||
@using System.Text.Json;
|
||||
@inject PromotePrompt PromotePrompt;
|
||||
@inject AccountState AccountState;
|
||||
|
||||
<article class="game-board">
|
||||
@if (IsSpectating)
|
||||
{
|
||||
<aside class="icons">
|
||||
<div class="spectating" title="You are spectating.">
|
||||
<svg width="32" height="32" fill="currentColor">
|
||||
<use xlink:href="css/bootstrap/bootstrap-icons.svg#camera-reels" />
|
||||
</svg>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
<!-- Game board -->
|
||||
<section class="board" data-perspective="@Perspective">
|
||||
@for (var rank = 1; rank < 10; rank++)
|
||||
{
|
||||
foreach (var file in Files)
|
||||
{
|
||||
var position = $"{file}{rank}";
|
||||
var piece = Session?.BoardState.Board[position];
|
||||
var isSelected = piece != null && SelectedPosition == position;
|
||||
<div class="tile" @onclick="OnClickTileInternal(piece, position)"
|
||||
data-position="@(position)"
|
||||
data-selected="@(isSelected)"
|
||||
style="grid-area: @position">
|
||||
@if (piece != null){
|
||||
<GamePiece Piece="piece" Perspective="Perspective" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="ruler vertical" style="grid-area: rank">
|
||||
<span>9</span>
|
||||
<span>8</span>
|
||||
<span>7</span>
|
||||
<span>6</span>
|
||||
<span>5</span>
|
||||
<span>4</span>
|
||||
<span>3</span>
|
||||
<span>2</span>
|
||||
<span>1</span>
|
||||
</div>
|
||||
<div class="ruler" style="grid-area: file">
|
||||
<span>A</span>
|
||||
<span>B</span>
|
||||
<span>C</span>
|
||||
<span>D</span>
|
||||
<span>E</span>
|
||||
<span>F</span>
|
||||
<span>G</span>
|
||||
<span>H</span>
|
||||
<span>I</span>
|
||||
</div>
|
||||
<!-- Promote prompt -->
|
||||
<div class="promote-prompt" data-visible="@PromotePrompt.IsVisible">
|
||||
<p>Do you wish to promote?</p>
|
||||
<div>
|
||||
<button type="button">Yes</button>
|
||||
<button type="button">No</button>
|
||||
<button type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Side board -->
|
||||
@if (Session != null)
|
||||
{
|
||||
<aside class="side-board">
|
||||
<div class="player-area">
|
||||
<div class="hand">
|
||||
@if (opponentHand.Any())
|
||||
{
|
||||
@foreach (var piece in opponentHand)
|
||||
{
|
||||
<div class="tile">
|
||||
<GamePiece Piece="piece" Perspective="Perspective" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<p class="text-center">Opponent Hand</p>
|
||||
</div>
|
||||
|
||||
<div class="spacer place-self-center text-center">
|
||||
<p>@opponentName</p>
|
||||
<p title="It is @(IsMyTurn ? "your" : "their") turn.">
|
||||
<svg width="32" height="32" fill="currentColor">
|
||||
@if (IsMyTurn)
|
||||
{
|
||||
<use xlink:href="css/bootstrap/bootstrap-icons.svg#chevron-down" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<use xlink:href="css/bootstrap/bootstrap-icons.svg#chevron-up" />
|
||||
}
|
||||
</svg>
|
||||
</p>
|
||||
<p>@userName</p>
|
||||
</div>
|
||||
|
||||
<div class="player-area">
|
||||
@if (Session.Player2 == null && Session.Player1.Id != AccountState.User?.Id)
|
||||
{
|
||||
<div class="place-self-center">
|
||||
<p>Seat is Empty</p>
|
||||
<button @onclick="OnClickJoinGameInternal">Join Game</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-center">Hand</p>
|
||||
<div class="hand">
|
||||
@if (userHand.Any())
|
||||
{
|
||||
@foreach (var piece in userHand)
|
||||
{
|
||||
<div @onclick="OnClickHandInternal(piece)"
|
||||
class="tile"
|
||||
data-selected="@(piece.WhichPiece == SelectedPieceFromHand)">
|
||||
<GamePiece Piece="piece" Perspective="Perspective" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
}
|
||||
</article>
|
||||
|
||||
@code {
|
||||
static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" };
|
||||
|
||||
/// <summary>
|
||||
/// When true, an icon is displayed indicating that the user is spectating.
|
||||
/// </summary>
|
||||
[Parameter] public bool IsSpectating { get; set; } = false;
|
||||
[Parameter] public WhichPlayer Perspective { get; set; }
|
||||
[Parameter] public Session? Session { get; set; }
|
||||
[Parameter] public string? SelectedPosition { get; set; }
|
||||
[Parameter] public WhichPiece? SelectedPieceFromHand { get; set; }
|
||||
// TODO: Exchange these OnClick actions for events like "SelectionChangedEvent" and "MoveFromBoardEvent" and "MoveFromHandEvent".
|
||||
[Parameter] public Func<Piece?, string, Task>? OnClickTile { get; set; }
|
||||
[Parameter] public Func<Piece, Task>? OnClickHand { get; set; }
|
||||
[Parameter] public Func<Task>? OnClickJoinGame { get; set; }
|
||||
[Parameter] public bool IsMyTurn { get; set; }
|
||||
|
||||
private IReadOnlyCollection<Piece> opponentHand;
|
||||
private IReadOnlyCollection<Piece> userHand;
|
||||
private string? userName;
|
||||
private string? opponentName;
|
||||
|
||||
public GameBoardPresentation()
|
||||
{
|
||||
opponentHand = Array.Empty<Piece>();
|
||||
userHand = Array.Empty<Piece>();
|
||||
userName = string.Empty;
|
||||
opponentName = string.Empty;
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
base.OnParametersSet();
|
||||
if (Session == null)
|
||||
{
|
||||
opponentHand = Array.Empty<Piece>();
|
||||
userHand = Array.Empty<Piece>();
|
||||
userName = string.Empty;
|
||||
opponentName = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(new { this.Session.Player1, this.Session.Player2, Perspective, this.Session.SessionName }));
|
||||
opponentHand = Perspective == WhichPlayer.Player1
|
||||
? this.Session.BoardState.Player2Hand
|
||||
: this.Session.BoardState.Player1Hand;
|
||||
userHand = Perspective == WhichPlayer.Player1
|
||||
? this.Session.BoardState.Player1Hand
|
||||
: this.Session.BoardState.Player2Hand;
|
||||
userName = Perspective == WhichPlayer.Player1
|
||||
? this.Session.Player1.Name
|
||||
: this.Session.Player2?.Name ?? "Empty Seat";
|
||||
opponentName = Perspective == WhichPlayer.Player1
|
||||
? this.Session.Player2?.Name ?? "Empty Seat"
|
||||
: this.Session.Player1.Name;
|
||||
}
|
||||
}
|
||||
|
||||
private Action OnClickTileInternal(Piece? piece, string position) => () => OnClickTile?.Invoke(piece, position);
|
||||
private Action OnClickHandInternal(Piece piece) => () => OnClickHand?.Invoke(piece);
|
||||
private void OnClickJoinGameInternal() => OnClickJoinGame?.Invoke();
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
.game-board {
|
||||
display: grid;
|
||||
/*grid-template-areas: "board side-board icons";
|
||||
grid-template-columns: 1fr minmax(9rem, 15rem) 3rem;*/
|
||||
grid-template-areas: "board";
|
||||
grid-template-columns: 1fr;
|
||||
place-content: center;
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
background-color: #444;
|
||||
position: relative; /* For absolute positioned children. */
|
||||
}
|
||||
|
||||
.board {
|
||||
grid-area: board;
|
||||
}
|
||||
|
||||
.side-board {
|
||||
grid-area: side-board;
|
||||
}
|
||||
|
||||
.icons {
|
||||
grid-area: icons;
|
||||
}
|
||||
|
||||
.board {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"rank A9 B9 C9 D9 E9 F9 G9 H9 I9"
|
||||
"rank A8 B8 C8 D8 E8 F8 G8 H8 I8"
|
||||
"rank A7 B7 C7 D7 E7 F7 G7 H7 I7"
|
||||
"rank A6 B6 C6 D6 E6 F6 G6 H6 I6"
|
||||
"rank A5 B5 C5 D5 E5 F5 G5 H5 I5"
|
||||
"rank A4 B4 C4 D4 E4 F4 G4 H4 I4"
|
||||
"rank A3 B3 C3 D3 E3 F3 G3 H3 I3"
|
||||
"rank A2 B2 C2 D2 E2 F2 G2 H2 I2"
|
||||
"rank A1 B1 C1 D1 E1 F1 G1 H1 I1"
|
||||
". file file file file file file file file file";
|
||||
grid-template-columns: auto repeat(9, 1fr);
|
||||
grid-template-rows: repeat(9, 1fr) auto;
|
||||
gap: 3px;
|
||||
aspect-ratio: 0.9167;
|
||||
max-height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
.board[data-perspective="Player2"] {
|
||||
grid-template-areas:
|
||||
"file file file file file file file file file ."
|
||||
"I1 H1 G1 F1 E1 D1 C1 B1 A1 rank"
|
||||
"I2 H2 G2 F2 E2 D2 C2 B2 A2 rank"
|
||||
"I3 H3 G3 F3 E3 D3 C3 B3 A3 rank"
|
||||
"I4 H4 G4 F4 E4 D4 C4 B4 A4 rank"
|
||||
"I5 H5 G5 F5 E5 D5 C5 B5 A5 rank"
|
||||
"I6 H6 G6 F6 E6 D6 C6 B6 A6 rank"
|
||||
"I7 H7 G7 F7 E7 D7 C7 B7 A7 rank"
|
||||
"I8 H8 G8 F8 E8 D8 C8 B8 A8 rank"
|
||||
"I9 H9 G9 F9 E9 D9 C9 B9 A9 rank";
|
||||
grid-template-columns: repeat(9, minmax(0, 1fr)) auto;
|
||||
grid-template-rows: auto repeat(9, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
transition: filter linear 0.25s;
|
||||
aspect-ratio: 0.9167;
|
||||
}
|
||||
|
||||
.board .tile {
|
||||
background-color: beige;
|
||||
}
|
||||
|
||||
.tile[data-selected] {
|
||||
filter: invert(0.8);
|
||||
}
|
||||
|
||||
.ruler {
|
||||
color: beige;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.ruler.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.board[data-perspective="Player2"] .ruler {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.board[data-perspective="Player2"] .ruler.vertical {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.side-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
place-content: space-between;
|
||||
padding: 1rem;
|
||||
background-color: var(--contrast-color);
|
||||
}
|
||||
|
||||
.side-board .player-area {
|
||||
display: grid;
|
||||
place-items: stretch;
|
||||
}
|
||||
|
||||
.side-board .hand {
|
||||
display: grid;
|
||||
border: 1px solid #ccc;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: 4rem;
|
||||
place-items: center start;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.side-board .hand .tile {
|
||||
max-height: 100%; /* I have no idea why I need to set this here to prevent a height blowout. */
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.promote-prompt {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 2px solid #444;
|
||||
background-color: #eaeaea;
|
||||
padding: 1rem;
|
||||
box-shadow: 1px 1px 1px #444;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.promote-prompt[data-visible="true"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spectating {
|
||||
color: var(--contrast-color)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
@using Shogi.Contracts.Api;
|
||||
@using Shogi.Contracts.Types;
|
||||
@using System.Text.RegularExpressions;
|
||||
@using System.Net;
|
||||
@inject PromotePrompt PromotePrompt;
|
||||
@inject IShogiApi ShogiApi;
|
||||
|
||||
<GameBoardPresentation Session="Session"
|
||||
Perspective="Perspective"
|
||||
OnClickHand="OnClickHand"
|
||||
OnClickTile="OnClickTile"
|
||||
SelectedPosition="@selectedBoardPosition"
|
||||
SelectedPieceFromHand="@selectedPieceFromHand"
|
||||
IsMyTurn="IsMyTurn" />
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public WhichPlayer Perspective { get; set; }
|
||||
[Parameter, EditorRequired]
|
||||
public Session Session { get; set; }
|
||||
private bool IsMyTurn => Session?.BoardState.WhoseTurn == Perspective;
|
||||
private string? selectedBoardPosition;
|
||||
private WhichPiece? selectedPieceFromHand;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
base.OnParametersSet();
|
||||
selectedBoardPosition = null;
|
||||
selectedPieceFromHand = null;
|
||||
if (Session == null)
|
||||
{
|
||||
throw new ArgumentException($"{nameof(Session)} cannot be null.", nameof(Session));
|
||||
}
|
||||
}
|
||||
|
||||
bool ShouldPromptForPromotion(string position)
|
||||
{
|
||||
if (Perspective == WhichPlayer.Player1 && Regex.IsMatch(position, ".[7-9]"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (Perspective == WhichPlayer.Player2 && Regex.IsMatch(position, ".[1-3]"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async Task OnClickTile(Piece? pieceAtPosition, string position)
|
||||
{
|
||||
if (!IsMyTurn) return;
|
||||
|
||||
|
||||
if (selectedBoardPosition == position)
|
||||
{
|
||||
// Deselect the selected position.
|
||||
selectedBoardPosition = null;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedBoardPosition == null && pieceAtPosition?.Owner == Perspective)
|
||||
{
|
||||
// Select an owned piece.
|
||||
Console.WriteLine("Selecting piece owned by {0} while I am perspective {1}", pieceAtPosition?.Owner, Perspective);
|
||||
selectedBoardPosition = position;
|
||||
// Prevent selecting pieces from the hand and board at the same time.
|
||||
selectedPieceFromHand = null;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPieceFromHand is not null)
|
||||
{
|
||||
if (pieceAtPosition is null)
|
||||
{
|
||||
// Placing a piece from the hand to an empty space.
|
||||
await ShogiApi.Move(
|
||||
Session.SessionName,
|
||||
new MovePieceCommand(selectedPieceFromHand.Value, position));
|
||||
}
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedBoardPosition != null)
|
||||
{
|
||||
if (pieceAtPosition == null || pieceAtPosition?.Owner != Perspective)
|
||||
{
|
||||
// Moving to an empty space or capturing an opponent's piece.
|
||||
if (ShouldPromptForPromotion(position) || ShouldPromptForPromotion(selectedBoardPosition))
|
||||
{
|
||||
PromotePrompt.Show(
|
||||
Session.SessionName,
|
||||
new MovePieceCommand(selectedBoardPosition, position, false));
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShogiApi.Move(Session.SessionName, new MovePieceCommand(selectedBoardPosition, position, false));
|
||||
}
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnClickHand(Piece piece)
|
||||
{
|
||||
if (!IsMyTurn) return;
|
||||
|
||||
// Prevent selecting from both the hand and the board.
|
||||
selectedBoardPosition = null;
|
||||
|
||||
selectedPieceFromHand = piece.WhichPiece == selectedPieceFromHand
|
||||
// Deselecting the already-selected piece
|
||||
? selectedPieceFromHand = null
|
||||
: selectedPieceFromHand = piece.WhichPiece;
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
@using Contracts.Types;
|
||||
@using System.Net;
|
||||
@inject IShogiApi ShogiApi;
|
||||
|
||||
<GameBoardPresentation IsSpectating="true"
|
||||
Perspective="WhichPlayer.Player2"
|
||||
Session="Session"
|
||||
OnClickJoinGame="OnClickJoinGame" />
|
||||
|
||||
@code {
|
||||
[Parameter] public Session Session { get; set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
base.OnParametersSet();
|
||||
if (Session == null)
|
||||
{
|
||||
throw new ArgumentException($"{nameof(Session)} cannot be null.", nameof(Session));
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnClickJoinGame()
|
||||
{
|
||||
var response = await ShogiApi.PatchJoinGame(Session.SessionName);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
@implements IDisposable;
|
||||
|
||||
@using Shogi.Contracts.Socket;
|
||||
@using Shogi.Contracts.Types;
|
||||
@using System.ComponentModel.DataAnnotations;
|
||||
@using System.Net;
|
||||
@using System.Text.Json;
|
||||
|
||||
@inject IShogiApi ShogiApi;
|
||||
@inject ShogiSocket ShogiSocket;
|
||||
@inject AccountState Account;
|
||||
|
||||
<section class="game-browser">
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#search-pane">Search</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#create-pane">Create</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="search-pane">
|
||||
<h3>Games you're seated at</h3>
|
||||
<div class="list-group">
|
||||
@if (!joinedSessions.Any())
|
||||
{
|
||||
<p>You have not joined any games.</p>
|
||||
}
|
||||
@foreach (var session in joinedSessions)
|
||||
{
|
||||
<button class="list-group-item list-group-item-action @ActiveCss(session)" @onclick="() => OnClickSession(session)">
|
||||
<span>@session.Name</span>
|
||||
<span>(@session.PlayerCount/2)</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<h3>Other games</h3>
|
||||
<div class="list-group">
|
||||
@if (!otherSessions.Any())
|
||||
{
|
||||
<p>You have not joined any games.</p>
|
||||
}
|
||||
@foreach (var session in otherSessions)
|
||||
{
|
||||
<button class="list-group-item list-group-item-action @ActiveCss(session)" @onclick="() => OnClickSession(session)">
|
||||
<span>@session.Name</span>
|
||||
<span>(@session.PlayerCount/2)</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="create-pane">
|
||||
<EditForm Model="createForm" OnValidSubmit="async () => await CreateSession()">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<h3>Start a new session</h3>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="text" class="form-control" id="session-name" placeholder="Session name" @bind-Value="createForm.Name" />
|
||||
<label for="session-name">Session name</label>
|
||||
</div>
|
||||
<div class="flex-between mb-3">
|
||||
<div class="form-check">
|
||||
<InputCheckbox class="form-check-input" role="switch" id="session-privacy" @bind-Value="createForm.IsPrivate" />
|
||||
<label class="form-check-label" for="session-privacy">Private?</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</div>
|
||||
|
||||
@if (createSessionStatusCode == HttpStatusCode.Created)
|
||||
{
|
||||
<div class="alert alert-success" role="alert">
|
||||
Session started. View it in the search tab.
|
||||
</div>
|
||||
}
|
||||
else if (createSessionStatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
The name you chose is taken; choose another.
|
||||
</div>
|
||||
}
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Action<SessionMetadata>? ActiveSessionChanged { get; set; }
|
||||
|
||||
private CreateForm createForm = new();
|
||||
private SessionMetadata[] joinedSessions = Array.Empty<SessionMetadata>();
|
||||
private SessionMetadata[] otherSessions = Array.Empty<SessionMetadata>();
|
||||
private SessionMetadata? activeSession;
|
||||
private HttpStatusCode? createSessionStatusCode;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
ShogiSocket.OnSessionCreated += FetchSessions;
|
||||
ShogiSocket.OnSessionJoined += OnSessionJoined_FetchSessions;
|
||||
Account.LoginChangedEvent += LoginChangedEvent_FetchSessions;
|
||||
}
|
||||
|
||||
string ActiveCss(SessionMetadata s) => s == activeSession ? "active" : string.Empty;
|
||||
|
||||
void OnClickSession(SessionMetadata s)
|
||||
{
|
||||
activeSession = s;
|
||||
ActiveSessionChanged?.Invoke(s);
|
||||
}
|
||||
|
||||
Task OnSessionJoined_FetchSessions(SessionJoinedByPlayerSocketMessage args) => FetchSessions();
|
||||
|
||||
Task LoginChangedEvent_FetchSessions(LoginEventArgs args)
|
||||
{
|
||||
if (args.User == null)
|
||||
{
|
||||
joinedSessions = Array.Empty<SessionMetadata>();
|
||||
otherSessions = Array.Empty<SessionMetadata>();
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
return FetchSessions();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task FetchSessions()
|
||||
{
|
||||
var sessions = await ShogiApi.GetSessionsPlayerCount();
|
||||
if (sessions != null)
|
||||
{
|
||||
this.joinedSessions = sessions.PlayerHasJoinedSessions.ToArray();
|
||||
this.otherSessions = sessions.AllOtherSessions.ToArray();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
async Task CreateSession()
|
||||
{
|
||||
createSessionStatusCode = await ShogiApi.PostSession(createForm.Name, createForm.IsPrivate);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ShogiSocket.OnSessionCreated -= FetchSessions;
|
||||
ShogiSocket.OnSessionJoined -= OnSessionJoined_FetchSessions;
|
||||
Account.LoginChangedEvent -= LoginChangedEvent_FetchSessions;
|
||||
}
|
||||
|
||||
private class CreateForm
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public bool IsPrivate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.game-browser {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--contrast-color);
|
||||
}
|
||||
|
||||
#search-pane button.list-group-item {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
#search-pane button.list-group-item.active {
|
||||
background-color: #444;
|
||||
border-color: #666;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
@page "/"
|
||||
@using Shogi.Contracts.Types
|
||||
@using System.Net.WebSockets
|
||||
@using System.Text
|
||||
@inject AccountManager AccountManager
|
||||
@inject AccountState Account
|
||||
|
||||
|
||||
<main class="shogi">
|
||||
@if (welcomeModalIsVisible)
|
||||
{
|
||||
<LoginModal />
|
||||
}
|
||||
<aside class="sidebar">
|
||||
<PageHeader />
|
||||
<GameBrowser ActiveSessionChanged="OnChangeSession" />
|
||||
</aside>
|
||||
@if (Account.User == null || activeSessionName == null)
|
||||
{
|
||||
<EmptyGameBoard />
|
||||
}
|
||||
else
|
||||
{
|
||||
<GameBoard SessionName="@activeSessionName" />
|
||||
}
|
||||
</main>
|
||||
|
||||
@code {
|
||||
private bool welcomeModalIsVisible;
|
||||
private string activeSessionName;
|
||||
private ClientWebSocket socket;
|
||||
|
||||
public Home()
|
||||
{
|
||||
welcomeModalIsVisible = false;
|
||||
activeSessionName = string.Empty;
|
||||
socket = new ClientWebSocket();
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Account.LoginChangedEvent += OnLoginChanged;
|
||||
var success = await AccountManager.TryLoginSilentAsync();
|
||||
if (!success)
|
||||
{
|
||||
welcomeModalIsVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnLoginChanged(LoginEventArgs args)
|
||||
{
|
||||
welcomeModalIsVisible = args.User == null;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
private void OnChangeSession(SessionMetadata s)
|
||||
{
|
||||
activeSessionName = s.Name;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
.shogi {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"sidebar board";
|
||||
grid-template-columns: clamp(20rem, 20vw, 25rem) 1fr;
|
||||
position: relative; /* For absolute positioned children. */
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.shogi > .sidebar {
|
||||
grid-area: sidebar;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
.shogi > .sidebar.collapsed {
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.shogi > ::deep .game-board {
|
||||
grid-area: board;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
@inject AccountManager Account
|
||||
@inject AccountState AccountState
|
||||
|
||||
|
||||
<div class="my-modal-background">
|
||||
<div class="my-modal">
|
||||
@if (guestAccountDescriptionIsVisible)
|
||||
{
|
||||
<h1>What's the difference?</h1>
|
||||
<p>
|
||||
Guest accounts are session based, meaning that the account lives exclusively within the device and browser you play on as a guest.
|
||||
This is the only difference between guest and email accounts.
|
||||
</p>
|
||||
<div class="alert alert-warning">
|
||||
Deleting your device's browser storage for this site also deletes your guest account. This data is how you are remembered between sessions.
|
||||
</div>
|
||||
<button class="btn btn-link smaller" @onclick="HideGuestAccountDescription">Take me back</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1>Welcome to Shogi!</h1>
|
||||
<div>
|
||||
<p>How would you like to proceed?</p>
|
||||
<p>
|
||||
<button @onclick="async () => await Account.LoginWithMicrosoftAccount()">Log in</button>
|
||||
<button @onclick="async () => await Account.LoginWithGuestAccount()">Proceed as Guest</button>
|
||||
@if (AccountState.User != null)
|
||||
{
|
||||
/* This is an escape hatch in case user login fails in certain ways. */
|
||||
<button @onclick="Account.LogoutAsync">Logout</button>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
<button class="btn btn-link smaller" @onclick="ShowGuestAccountDescription">What's the difference?</button>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
bool guestAccountDescriptionIsVisible = false;
|
||||
|
||||
void ShowGuestAccountDescription()
|
||||
{
|
||||
guestAccountDescriptionIsVisible = true;
|
||||
}
|
||||
void HideGuestAccountDescription()
|
||||
{
|
||||
guestAccountDescriptionIsVisible = false;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
.my-modal-background {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: fixed;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.my-modal {
|
||||
text-align: center;
|
||||
background-color: var(--contrast-color);
|
||||
padding: 1rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.account-description {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr max-content max-content;
|
||||
column-gap: 1.5rem;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
@inject AccountState Account
|
||||
@inject AccountManager AccountManager
|
||||
|
||||
<div class="pageHeader">
|
||||
<h1>Shogi</h1>
|
||||
@if (user == null)
|
||||
{
|
||||
<button type="button" class="logout" @onclick="AccountManager.LogoutAsync">Logout</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="user">
|
||||
<div>@user.Value.DisplayName</div>
|
||||
<button type="button" class="logout" @onclick="AccountManager.LogoutAsync">Logout</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private User? user;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Account.LoginChangedEvent += OnLoginChange;
|
||||
}
|
||||
|
||||
private Task OnLoginChange(LoginEventArgs args)
|
||||
{
|
||||
if (args == null)
|
||||
throw new ArgumentException(nameof(args));
|
||||
user = args.User;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
.pageHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr max-content;
|
||||
place-items: center stretch;
|
||||
place-content: stretch;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--contrast-color);
|
||||
}
|
||||
|
||||
.pageHeader h1 {
|
||||
place-self: baseline start;
|
||||
}
|
||||
|
||||
.pageHeader button.logout {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.pageHeader .user {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using Shogi.Contracts.Api;
|
||||
using Shogi.UI.Pages.Home.Api;
|
||||
|
||||
namespace Shogi.UI.Pages.Home;
|
||||
|
||||
public class PromotePrompt
|
||||
{
|
||||
private readonly IShogiApi shogiApi;
|
||||
private string? sessionName;
|
||||
private MovePieceCommand? command;
|
||||
|
||||
public PromotePrompt(IShogiApi shogiApi)
|
||||
{
|
||||
this.shogiApi = shogiApi;
|
||||
IsVisible = false;
|
||||
OnClickCancel = Hide;
|
||||
}
|
||||
|
||||
public bool IsVisible { get; private set; }
|
||||
public Action OnClickCancel;
|
||||
public Func<Task>? OnClickNo;
|
||||
public Func<Task>? OnClickYes;
|
||||
|
||||
public void Show(string sessionName, MovePieceCommand command)
|
||||
{
|
||||
this.sessionName = sessionName;
|
||||
this.command = command;
|
||||
IsVisible = true;
|
||||
OnClickNo = Move;
|
||||
OnClickYes = MoveAndPromote;
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
IsVisible = false;
|
||||
OnClickNo = null;
|
||||
OnClickYes = null;
|
||||
}
|
||||
|
||||
private Task Move()
|
||||
{
|
||||
if (command != null && sessionName != null)
|
||||
{
|
||||
command.IsPromotion = false;
|
||||
return shogiApi.Move(sessionName, command);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
private Task MoveAndPromote()
|
||||
{
|
||||
if (command != null && sessionName != null)
|
||||
{
|
||||
command.IsPromotion = true;
|
||||
return shogiApi.Move(sessionName, command);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
26
Shogi.UI/Pages/HomePage.razor
Normal file
26
Shogi.UI/Pages/HomePage.razor
Normal file
@@ -0,0 +1,26 @@
|
||||
@page "/"
|
||||
|
||||
@using Shogi.Contracts.Types
|
||||
@using System.Net.WebSockets
|
||||
@using System.Text
|
||||
|
||||
<main class="shogi PrimaryTheme">
|
||||
<p>How to play goes here</p>
|
||||
<p>Maybe a cool animation of a game being played.</p>
|
||||
</main>
|
||||
|
||||
@code {
|
||||
|
||||
private string activeSessionName = string.Empty;
|
||||
|
||||
private Task OnLoginChanged()
|
||||
{
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
private void OnChangeSession(SessionMetadata s)
|
||||
{
|
||||
activeSessionName = s.SessionId.ToString();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
4
Shogi.UI/Pages/HomePage.razor.css
Normal file
4
Shogi.UI/Pages/HomePage.razor.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.shogi {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
72
Shogi.UI/Pages/Identity/LoginPage.razor
Normal file
72
Shogi.UI/Pages/Identity/LoginPage.razor
Normal file
@@ -0,0 +1,72 @@
|
||||
@page "/login"
|
||||
@inject IAccountManagement Acct
|
||||
@inject NavigationManager navigator
|
||||
|
||||
<main class="PrimaryTheme">
|
||||
<h1>Login</h1>
|
||||
|
||||
<section class="LoginForm">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div>You're logged in as @context.User.Identity?.Name.</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
@if (errorList.Length > 0)
|
||||
{
|
||||
<ul class="Errors" style="grid-area: errors">
|
||||
@foreach (var error in errorList)
|
||||
{
|
||||
<li>@error</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<label for="email" style="grid-area: emailLabel">Email</label>
|
||||
<input required id="email" name="emailInput" type="email" style="grid-area: emailControl" @bind-value="email" />
|
||||
|
||||
<label for="password" style="grid-area: passLabel">Password</label>
|
||||
<input required id="password" name="passwordInput" type="password" style="grid-area: passControl" @bind-value="password" />
|
||||
|
||||
<button style="grid-area: button" @onclick="DoLoginAsync">Login</button>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
@code {
|
||||
private string email = string.Empty;
|
||||
private string password = string.Empty;
|
||||
private string[] errorList = [];
|
||||
|
||||
public async Task DoLoginAsync()
|
||||
{
|
||||
errorList = [];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
errorList = ["Email is required."];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
errorList = ["Password is required."];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await Acct.LoginAsync(email, password);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
email = password = string.Empty;
|
||||
navigator.NavigateTo("/");
|
||||
}
|
||||
else
|
||||
{
|
||||
errorList = result.ErrorList;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Shogi.UI/Pages/Identity/LoginPage.razor.css
Normal file
28
Shogi.UI/Pages/Identity/LoginPage.razor.css
Normal file
@@ -0,0 +1,28 @@
|
||||
main {
|
||||
/*display: grid;
|
||||
grid-template-areas:
|
||||
"header header header"
|
||||
". form ."
|
||||
". . .";
|
||||
grid-template-rows: auto 1fr 1fr;
|
||||
|
||||
place-items: center;
|
||||
*/
|
||||
}
|
||||
|
||||
.LoginForm {
|
||||
grid-area: form;
|
||||
|
||||
display: inline-grid;
|
||||
grid-template-areas:
|
||||
"errors errors"
|
||||
"emailLabel emailControl"
|
||||
"passLabel passControl"
|
||||
"button button";
|
||||
gap: 0.5rem 3rem;
|
||||
}
|
||||
|
||||
.LoginForm .Errors {
|
||||
color: darkred;
|
||||
}
|
||||
|
||||
30
Shogi.UI/Pages/Identity/LogoutPage.razor
Normal file
30
Shogi.UI/Pages/Identity/LogoutPage.razor
Normal file
@@ -0,0 +1,30 @@
|
||||
@page "/logout"
|
||||
@inject IAccountManagement Acct
|
||||
|
||||
<main class="PrimaryTheme">
|
||||
<h1>Logout</h1>
|
||||
|
||||
<AuthorizeView @ref="authView">
|
||||
<Authorized>
|
||||
<div class="alert alert-info">Logging you out...</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<p>Thanks for playing!</p>
|
||||
<div class="alert alert-success">You're logged out. <a href="/login">Log in.</a></div>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</main>
|
||||
|
||||
@code {
|
||||
private AuthorizeView? authView;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (await Acct.CheckAuthenticatedAsync())
|
||||
{
|
||||
await Acct.LogoutAsync();
|
||||
}
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
}
|
||||
93
Shogi.UI/Pages/Identity/RegisterPage.razor
Normal file
93
Shogi.UI/Pages/Identity/RegisterPage.razor
Normal file
@@ -0,0 +1,93 @@
|
||||
@page "/register"
|
||||
@inject IAccountManagement Acct
|
||||
|
||||
<main class="PrimaryTheme">
|
||||
<h1>Register</h1>
|
||||
|
||||
<section class="LoginForm">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="alert alert-success">You're already logged in as @context.User.Identity?.Name.</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
@if (showNextSteps)
|
||||
{
|
||||
<p>Thank you for joining! You will receive an email asking to confirm you own this email address.</p>
|
||||
}
|
||||
@if (errorList.Length > 0)
|
||||
{
|
||||
<ul class="Errors" style="grid-area: errors">
|
||||
@foreach (var error in errorList)
|
||||
{
|
||||
<li>@error</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<label for="email" style="grid-area: emailLabel">Email</label>
|
||||
<input autofocus autocomplete="on" required id="email" name="emailInput" type="email" style="grid-area: emailControl" @bind-value="email" />
|
||||
|
||||
<label for="password" style="grid-area: passLabel">Password</label>
|
||||
<input required id="password" name="passwordInput" type="password" style="grid-area: passControl" @bind-value="password" /><br />
|
||||
|
||||
<label for="confirmPassword" style="grid-area: confirmLabel">Retype password</label>
|
||||
<input required id="confirmPassword" name="confirmPasswordInput" type="password" style="grid-area: confirmControl" @bind-value="confirmPassword" />
|
||||
|
||||
<button style="grid-area: button" @onclick="DoRegisterAsync">Register</button>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@code {
|
||||
private bool showNextSteps;
|
||||
private string email = string.Empty;
|
||||
private string password = string.Empty;
|
||||
private string confirmPassword = string.Empty;
|
||||
private string[] errorList = [];
|
||||
|
||||
public async Task DoRegisterAsync()
|
||||
{
|
||||
errorList = [];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
errorList = ["Email is required."];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
errorList = ["Password is required."];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(confirmPassword))
|
||||
{
|
||||
errorList = ["Please confirm your password."];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (password != confirmPassword)
|
||||
{
|
||||
errorList = ["Passwords don't match."];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await Acct.RegisterAsync(email, password);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
email = password = confirmPassword = string.Empty;
|
||||
showNextSteps = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
errorList = result.ErrorList;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Shogi.UI/Pages/Identity/RegisterPage.razor.css
Normal file
15
Shogi.UI/Pages/Identity/RegisterPage.razor.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.LoginForm {
|
||||
grid-area: form;
|
||||
display: inline-grid;
|
||||
grid-template-areas:
|
||||
"errors errors"
|
||||
"emailLabel emailControl"
|
||||
"passLabel passControl"
|
||||
"confirmLabel confirmControl"
|
||||
"button button";
|
||||
gap: 0.5rem 3rem;
|
||||
}
|
||||
|
||||
.LoginForm .Errors {
|
||||
color: darkred;
|
||||
}
|
||||
76
Shogi.UI/Pages/Play/GameBoard/GameBoard.razor
Normal file
76
Shogi.UI/Pages/Play/GameBoard/GameBoard.razor
Normal file
@@ -0,0 +1,76 @@
|
||||
@using Shogi.Contracts.Api
|
||||
@using Shogi.Contracts.Types
|
||||
@using System.Text.RegularExpressions
|
||||
@using System.Security.Claims
|
||||
|
||||
@implements IDisposable
|
||||
@inject ShogiApi ShogiApi
|
||||
@inject PromotePrompt PromotePrompt
|
||||
@inject GameHubNode hubNode
|
||||
@inject NavigationManager navigator
|
||||
|
||||
@if (session == null)
|
||||
{
|
||||
<EmptyGameBoard />
|
||||
}
|
||||
else if (isSpectating)
|
||||
{
|
||||
<SpectatorGameBoard Session="session" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<SeatedGameBoard Perspective="perspective" Session="session" />
|
||||
}
|
||||
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState> authenticationState { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
Session? session;
|
||||
private WhichPlayer perspective;
|
||||
private bool isSpectating;
|
||||
private List<IDisposable> disposables = new List<IDisposable>(2);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
navigator.RegisterLocationChangingHandler((a) => new ValueTask(hubNode.Unsubscribe(SessionId)));
|
||||
disposables.Add(hubNode.OnSessionJoined(async () => await FetchSession()));
|
||||
disposables.Add(hubNode.OnPieceMoved(async () => await FetchSession()));
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await hubNode.Subscribe(SessionId);
|
||||
await FetchSession();
|
||||
}
|
||||
|
||||
async Task FetchSession()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(SessionId))
|
||||
{
|
||||
this.session = await ShogiApi.GetSession(SessionId);
|
||||
if (this.session != null)
|
||||
{
|
||||
var state = await authenticationState;
|
||||
var accountId = state.User.Claims.First(c => c.Type == ClaimTypes.Name).Value;
|
||||
this.perspective = accountId == session.Player1 ? WhichPlayer.Player1 : WhichPlayer.Player2;
|
||||
this.isSpectating = !(accountId == this.session.Player1 || accountId == this.session.Player2);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in disposables)
|
||||
{
|
||||
d.Dispose();
|
||||
}
|
||||
disposables.Clear();
|
||||
}
|
||||
}
|
||||
183
Shogi.UI/Pages/Play/GameBoard/GameBoardPresentation.razor
Normal file
183
Shogi.UI/Pages/Play/GameBoard/GameBoardPresentation.razor
Normal file
@@ -0,0 +1,183 @@
|
||||
@using Shogi.Contracts.Types;
|
||||
@using System.Text.Json;
|
||||
@inject PromotePrompt PromotePrompt;
|
||||
|
||||
<article class="game-board">
|
||||
@if (IsSpectating)
|
||||
{
|
||||
<aside class="icons">
|
||||
<div title="You are spectating.">
|
||||
<span>Camera icon</span>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
<!-- Game board -->
|
||||
<section class="board" data-perspective="@Perspective">
|
||||
@for (var rank = 1; rank < 10; rank++)
|
||||
{
|
||||
foreach (var file in Files)
|
||||
{
|
||||
var position = $"{file}{rank}";
|
||||
var piece = Session?.BoardState.Board[position];
|
||||
var isSelected = piece != null && SelectedPosition == position;
|
||||
<div class="tile" @onclick="OnClickTileInternal(piece, position)"
|
||||
data-position="@(position)"
|
||||
data-selected="@(isSelected)"
|
||||
style="grid-area: @position">
|
||||
@if (piece != null)
|
||||
{
|
||||
<GamePiece Piece="piece" Perspective="Perspective" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="ruler vertical" style="grid-area: rank">
|
||||
<span>9</span>
|
||||
<span>8</span>
|
||||
<span>7</span>
|
||||
<span>6</span>
|
||||
<span>5</span>
|
||||
<span>4</span>
|
||||
<span>3</span>
|
||||
<span>2</span>
|
||||
<span>1</span>
|
||||
</div>
|
||||
<div class="ruler" style="grid-area: file">
|
||||
<span>A</span>
|
||||
<span>B</span>
|
||||
<span>C</span>
|
||||
<span>D</span>
|
||||
<span>E</span>
|
||||
<span>F</span>
|
||||
<span>G</span>
|
||||
<span>H</span>
|
||||
<span>I</span>
|
||||
</div>
|
||||
|
||||
<!-- Promote prompt -->
|
||||
<div class="promote-prompt" data-visible="@PromotePrompt.IsVisible">
|
||||
<p>Do you wish to promote?</p>
|
||||
<div>
|
||||
<button type="button">Yes</button>
|
||||
<button type="button">No</button>
|
||||
<button type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Side board -->
|
||||
@if (Session != null)
|
||||
{
|
||||
<aside class="side-board PrimaryTheme ThemeVariant--Contrast">
|
||||
<div class="player-area">
|
||||
<div class="hand">
|
||||
@if (opponentHand.Any())
|
||||
{
|
||||
@foreach (var piece in opponentHand)
|
||||
{
|
||||
<div class="tile">
|
||||
<GamePiece Piece="piece" Perspective="Perspective" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<p class="text-center">Opponent Hand</p>
|
||||
</div>
|
||||
|
||||
<div class="place-self-center">
|
||||
<PlayerName Name="@opponentName" IsTurn="!IsMyTurn" />
|
||||
<hr />
|
||||
<PlayerName Name="@userName" IsTurn="IsMyTurn" />
|
||||
</div>
|
||||
|
||||
<div class="player-area">
|
||||
@if (this.OnClickJoinGame != null && string.IsNullOrEmpty(Session.Player2) && !string.IsNullOrEmpty(Session.Player1))
|
||||
{
|
||||
<div class="place-self-center">
|
||||
<button @onclick="OnClickJoinGameInternal">Join Game</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-center">Hand</p>
|
||||
<div class="hand">
|
||||
@if (userHand.Any())
|
||||
{
|
||||
@foreach (var piece in userHand)
|
||||
{
|
||||
<div @onclick="OnClickHandInternal(piece)"
|
||||
class="tile"
|
||||
data-selected="@(piece.WhichPiece == SelectedPieceFromHand)">
|
||||
<GamePiece Piece="piece" Perspective="Perspective" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
}
|
||||
</article>
|
||||
|
||||
@code {
|
||||
static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" };
|
||||
|
||||
/// <summary>
|
||||
/// When true, an icon is displayed indicating that the user is spectating.
|
||||
/// </summary>
|
||||
[Parameter] public bool IsSpectating { get; set; } = false;
|
||||
[Parameter] public WhichPlayer Perspective { get; set; }
|
||||
[Parameter] public Session? Session { get; set; }
|
||||
[Parameter] public string? SelectedPosition { get; set; }
|
||||
[Parameter] public WhichPiece? SelectedPieceFromHand { get; set; }
|
||||
// TODO: Exchange these OnClick actions for events like "SelectionChangedEvent" and "MoveFromBoardEvent" and "MoveFromHandEvent".
|
||||
[Parameter] public Func<Piece?, string, Task>? OnClickTile { get; set; }
|
||||
[Parameter] public Func<Piece, Task>? OnClickHand { get; set; }
|
||||
[Parameter] public Func<Task>? OnClickJoinGame { get; set; }
|
||||
[Parameter] public bool IsMyTurn { get; set; }
|
||||
|
||||
private IReadOnlyCollection<Piece> opponentHand;
|
||||
private IReadOnlyCollection<Piece> userHand;
|
||||
private string? userName;
|
||||
private string? opponentName;
|
||||
|
||||
public GameBoardPresentation()
|
||||
{
|
||||
opponentHand = Array.Empty<Piece>();
|
||||
userHand = Array.Empty<Piece>();
|
||||
userName = string.Empty;
|
||||
opponentName = string.Empty;
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
base.OnParametersSet();
|
||||
if (Session == null)
|
||||
{
|
||||
opponentHand = Array.Empty<Piece>();
|
||||
userHand = Array.Empty<Piece>();
|
||||
userName = string.Empty;
|
||||
opponentName = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
opponentHand = Perspective == WhichPlayer.Player1
|
||||
? this.Session.BoardState.Player2Hand
|
||||
: this.Session.BoardState.Player1Hand;
|
||||
userHand = Perspective == WhichPlayer.Player1
|
||||
? this.Session.BoardState.Player1Hand
|
||||
: this.Session.BoardState.Player2Hand;
|
||||
userName = Perspective == WhichPlayer.Player1
|
||||
? this.Session.Player1
|
||||
: this.Session.Player2 ?? "Empty Seat";
|
||||
opponentName = Perspective == WhichPlayer.Player1
|
||||
? this.Session.Player2 ?? "Empty Seat"
|
||||
: this.Session.Player1;
|
||||
}
|
||||
}
|
||||
|
||||
private Action OnClickTileInternal(Piece? piece, string position) => () => OnClickTile?.Invoke(piece, position);
|
||||
private Action OnClickHandInternal(Piece piece) => () => OnClickHand?.Invoke(piece);
|
||||
private void OnClickJoinGameInternal() => OnClickJoinGame?.Invoke();
|
||||
}
|
||||
128
Shogi.UI/Pages/Play/GameBoard/GameboardPresentation.razor.css
Normal file
128
Shogi.UI/Pages/Play/GameBoard/GameboardPresentation.razor.css
Normal file
@@ -0,0 +1,128 @@
|
||||
.game-board {
|
||||
--ratio: 0.9;
|
||||
display: grid;
|
||||
grid-template-areas: "board side-board icons";
|
||||
grid-template-columns: auto minmax(10rem, 30rem) 1fr;
|
||||
background-color: #444;
|
||||
position: relative; /* For absolute positioned promote prompt. */
|
||||
}
|
||||
|
||||
.board {
|
||||
grid-area: board;
|
||||
}
|
||||
|
||||
.side-board {
|
||||
grid-area: side-board;
|
||||
}
|
||||
|
||||
.icons {
|
||||
grid-area: icons;
|
||||
}
|
||||
|
||||
.board {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"rank A9 B9 C9 D9 E9 F9 G9 H9 I9"
|
||||
"rank A8 B8 C8 D8 E8 F8 G8 H8 I8"
|
||||
"rank A7 B7 C7 D7 E7 F7 G7 H7 I7"
|
||||
"rank A6 B6 C6 D6 E6 F6 G6 H6 I6"
|
||||
"rank A5 B5 C5 D5 E5 F5 G5 H5 I5"
|
||||
"rank A4 B4 C4 D4 E4 F4 G4 H4 I4"
|
||||
"rank A3 B3 C3 D3 E3 F3 G3 H3 I3"
|
||||
"rank A2 B2 C2 D2 E2 F2 G2 H2 I2"
|
||||
"rank A1 B1 C1 D1 E1 F1 G1 H1 I1"
|
||||
". file file file file file file file file file";
|
||||
grid-template-columns: auto repeat(9, 1fr);
|
||||
grid-template-rows: repeat(9, 1fr) auto;
|
||||
gap: 3px;
|
||||
padding: 1rem;
|
||||
background-color: #444444;
|
||||
height: calc(100vmin - 2rem);
|
||||
aspect-ratio: var(--ratio);
|
||||
}
|
||||
|
||||
.board[data-perspective="Player2"] {
|
||||
grid-template-areas:
|
||||
"file file file file file file file file file ."
|
||||
"I1 H1 G1 F1 E1 D1 C1 B1 A1 rank"
|
||||
"I2 H2 G2 F2 E2 D2 C2 B2 A2 rank"
|
||||
"I3 H3 G3 F3 E3 D3 C3 B3 A3 rank"
|
||||
"I4 H4 G4 F4 E4 D4 C4 B4 A4 rank"
|
||||
"I5 H5 G5 F5 E5 D5 C5 B5 A5 rank"
|
||||
"I6 H6 G6 F6 E6 D6 C6 B6 A6 rank"
|
||||
"I7 H7 G7 F7 E7 D7 C7 B7 A7 rank"
|
||||
"I8 H8 G8 F8 E8 D8 C8 B8 A8 rank"
|
||||
"I9 H9 G9 F9 E9 D9 C9 B9 A9 rank";
|
||||
grid-template-columns: repeat(9, minmax(0, 1fr)) auto;
|
||||
grid-template-rows: auto repeat(9, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
|
||||
.board .tile {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
aspect-ratio: var(--ratio);
|
||||
background-color: beige;
|
||||
transition: filter linear 0.25s;
|
||||
}
|
||||
|
||||
.board .tile[data-selected] {
|
||||
filter: invert(0.8);
|
||||
}
|
||||
|
||||
.ruler {
|
||||
color: beige;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.ruler.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.board[data-perspective="Player2"] .ruler {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.board[data-perspective="Player2"] .ruler.vertical {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.side-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
place-content: space-between;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.side-board .player-area {
|
||||
display: grid;
|
||||
place-items: stretch;
|
||||
}
|
||||
|
||||
.side-board .hand {
|
||||
display: grid;
|
||||
border: 1px solid #ccc;
|
||||
grid-template-columns: repeat(auto-fill, 3rem);
|
||||
grid-template-rows: 3rem;
|
||||
place-items: center start;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.promote-prompt {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 2px solid #444;
|
||||
padding: 1rem;
|
||||
box-shadow: 1px 1px 1px #444;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.promote-prompt[data-visible="true"] {
|
||||
display: block;
|
||||
}
|
||||
21
Shogi.UI/Pages/Play/GameBoard/PlayerName.razor
Normal file
21
Shogi.UI/Pages/Play/GameBoard/PlayerName.razor
Normal file
@@ -0,0 +1,21 @@
|
||||
<p style="margin: 0">
|
||||
@if (IsTurn)
|
||||
{
|
||||
<span>* </span>
|
||||
}
|
||||
|
||||
@if (string.IsNullOrEmpty(Name))
|
||||
{
|
||||
<span>Empty Seat</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@Name</span>
|
||||
}
|
||||
|
||||
</p>
|
||||
|
||||
@code {
|
||||
[Parameter][EditorRequired] public bool IsTurn { get; set; }
|
||||
[Parameter][EditorRequired] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
128
Shogi.UI/Pages/Play/GameBoard/SeatedGameBoard.razor
Normal file
128
Shogi.UI/Pages/Play/GameBoard/SeatedGameBoard.razor
Normal file
@@ -0,0 +1,128 @@
|
||||
@using Shogi.Contracts.Api;
|
||||
@using Shogi.Contracts.Types;
|
||||
@using System.Text.RegularExpressions;
|
||||
@using System.Net;
|
||||
@inject PromotePrompt PromotePrompt;
|
||||
@inject ShogiApi ShogiApi;
|
||||
|
||||
<GameBoardPresentation Session="Session"
|
||||
Perspective="Perspective"
|
||||
OnClickHand="OnClickHand"
|
||||
OnClickTile="OnClickTile"
|
||||
SelectedPosition="@selectedBoardPosition"
|
||||
SelectedPieceFromHand="@selectedPieceFromHand"
|
||||
IsMyTurn="IsMyTurn" />
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public WhichPlayer Perspective { get; set; }
|
||||
[Parameter, EditorRequired]
|
||||
public Session Session { get; set; } = default!;
|
||||
private bool IsMyTurn => Session?.BoardState.WhoseTurn == Perspective;
|
||||
private string? selectedBoardPosition;
|
||||
private WhichPiece? selectedPieceFromHand;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
base.OnParametersSet();
|
||||
selectedBoardPosition = null;
|
||||
selectedPieceFromHand = null;
|
||||
if (Session == null)
|
||||
{
|
||||
throw new ArgumentException($"{nameof(Session)} cannot be null.", nameof(Session));
|
||||
}
|
||||
}
|
||||
|
||||
bool ShouldPromptForPromotion(string position)
|
||||
{
|
||||
if (Perspective == WhichPlayer.Player1 && Regex.IsMatch(position, ".[7-9]"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (Perspective == WhichPlayer.Player2 && Regex.IsMatch(position, ".[1-3]"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async Task OnClickTile(Piece? pieceAtPosition, string position)
|
||||
{
|
||||
if (!IsMyTurn) return;
|
||||
|
||||
|
||||
if (selectedBoardPosition == position)
|
||||
{
|
||||
// Deselect the selected position.
|
||||
selectedBoardPosition = null;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedBoardPosition == null && pieceAtPosition?.Owner == Perspective)
|
||||
{
|
||||
// Select an owned piece.
|
||||
selectedBoardPosition = position;
|
||||
// Prevent selecting pieces from the hand and board at the same time.
|
||||
selectedPieceFromHand = null;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPieceFromHand is not null)
|
||||
{
|
||||
if (pieceAtPosition is null)
|
||||
{
|
||||
// Placing a piece from the hand to an empty space.
|
||||
var success = await ShogiApi.Move(
|
||||
Session.SessionId.ToString(),
|
||||
new MovePieceCommand(selectedPieceFromHand.Value, position));
|
||||
if (!success)
|
||||
{
|
||||
selectedPieceFromHand = null;
|
||||
}
|
||||
}
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedBoardPosition != null)
|
||||
{
|
||||
if (pieceAtPosition == null || pieceAtPosition?.Owner != Perspective)
|
||||
{
|
||||
// Moving to an empty space or capturing an opponent's piece.
|
||||
if (ShouldPromptForPromotion(position) || ShouldPromptForPromotion(selectedBoardPosition))
|
||||
{
|
||||
PromotePrompt.Show(
|
||||
Session.SessionId.ToString(),
|
||||
new MovePieceCommand(selectedBoardPosition, position, false));
|
||||
}
|
||||
else
|
||||
{
|
||||
var success = await ShogiApi.Move(Session.SessionId.ToString(), new MovePieceCommand(selectedBoardPosition, position, false));
|
||||
if (!success)
|
||||
{
|
||||
selectedBoardPosition = null;
|
||||
}
|
||||
}
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnClickHand(Piece piece)
|
||||
{
|
||||
if (!IsMyTurn) return;
|
||||
|
||||
// Prevent selecting from both the hand and the board.
|
||||
selectedBoardPosition = null;
|
||||
|
||||
selectedPieceFromHand = piece.WhichPiece == selectedPieceFromHand
|
||||
// Deselecting the already-selected piece
|
||||
? selectedPieceFromHand = null
|
||||
: selectedPieceFromHand = piece.WhichPiece;
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
29
Shogi.UI/Pages/Play/GameBoard/SpectatorGameBoard.razor
Normal file
29
Shogi.UI/Pages/Play/GameBoard/SpectatorGameBoard.razor
Normal file
@@ -0,0 +1,29 @@
|
||||
@using Contracts.Types
|
||||
@using System.Net
|
||||
@inject ShogiApi ShogiApi
|
||||
|
||||
<GameBoardPresentation IsSpectating="true"
|
||||
Perspective="WhichPlayer.Player2"
|
||||
Session="Session"
|
||||
OnClickJoinGame="OnClickJoinGame" />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public Session Session { get; set; } = default!;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
base.OnParametersSet();
|
||||
if (Session == null)
|
||||
{
|
||||
throw new ArgumentException($"{nameof(Session)} cannot be null.", nameof(Session));
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnClickJoinGame()
|
||||
{
|
||||
var response = await ShogiApi.PatchJoinGame(Session.SessionId.ToString());
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
59
Shogi.UI/Pages/Play/GameBrowser.razor
Normal file
59
Shogi.UI/Pages/Play/GameBrowser.razor
Normal file
@@ -0,0 +1,59 @@
|
||||
@using Shogi.Contracts.Types;
|
||||
@using System.ComponentModel.DataAnnotations;
|
||||
@using System.Net;
|
||||
@using System.Text.Json;
|
||||
|
||||
@inject ShogiApi ShogiApi
|
||||
|
||||
<section class="GameBrowser PrimaryTheme ThemeVariant--Contrast">
|
||||
<div class="table">
|
||||
<row class="header">
|
||||
<span>Creator</span>
|
||||
<span>Seats</span>
|
||||
</row>
|
||||
<hr />
|
||||
<AuthorizeView>
|
||||
@foreach (var session in allSessions)
|
||||
{
|
||||
<row>
|
||||
<div>
|
||||
<a href="/play/@session.SessionId">@session.Player1</a>
|
||||
</div>
|
||||
@if (string.IsNullOrEmpty(session.Player2))
|
||||
{
|
||||
<span>1 / 2</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Full</span>
|
||||
}
|
||||
</row>
|
||||
}
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
|
||||
@if (allSessions.Length == 0)
|
||||
{
|
||||
<p>There are no games being played.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private SessionMetadata[] allSessions = Array.Empty<SessionMetadata>();
|
||||
private SessionMetadata? activeSession;
|
||||
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
return FetchSessions();
|
||||
}
|
||||
|
||||
async Task FetchSessions()
|
||||
{
|
||||
var sessions = await ShogiApi.GetAllSessionsMetadata();
|
||||
if (sessions != null)
|
||||
{
|
||||
this.allSessions = sessions.ToArray();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
5
Shogi.UI/Pages/Play/GameBrowser.razor.css
Normal file
5
Shogi.UI/Pages/Play/GameBrowser.razor.css
Normal file
@@ -0,0 +1,5 @@
|
||||
row {
|
||||
display: grid;
|
||||
grid-template-columns: 18rem 5rem;
|
||||
padding-left: 5px; /* Matches box shadow on hover */
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@using Shogi.Contracts.Types
|
||||
|
||||
<div class="game-piece" data-upsidedown="@(Piece?.Owner != Perspective)" data-owner="@Piece?.Owner.ToString()">
|
||||
<div class="game-piece" title="@HtmlTitle" data-upsidedown="@(Piece?.Owner != Perspective)" data-owner="@Piece?.Owner.ToString()">
|
||||
@switch (Piece?.WhichPiece)
|
||||
{
|
||||
case WhichPiece.Bishop:
|
||||
@@ -41,4 +41,17 @@
|
||||
public WhichPlayer Perspective { get; set; }
|
||||
|
||||
private bool IsPromoted => Piece != null && Piece.IsPromoted;
|
||||
|
||||
private string HtmlTitle => Piece?.WhichPiece switch
|
||||
{
|
||||
WhichPiece.Bishop => "Bishop",
|
||||
WhichPiece.GoldGeneral => "Gold General",
|
||||
WhichPiece.King => "King",
|
||||
WhichPiece.Knight => "Knight",
|
||||
WhichPiece.Lance => "Lance",
|
||||
WhichPiece.Pawn => "Pawn",
|
||||
WhichPiece.Rook => "Rook",
|
||||
WhichPiece.SilverGeneral => "Silver General",
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
28
Shogi.UI/Pages/Play/PlayPage.razor
Normal file
28
Shogi.UI/Pages/Play/PlayPage.razor
Normal file
@@ -0,0 +1,28 @@
|
||||
@attribute [Authorize]
|
||||
@page "/play/{sessionId}"
|
||||
|
||||
@inject GameHubNode node
|
||||
|
||||
@if (string.IsNullOrWhiteSpace(SessionId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
<main class="PrimaryTheme">
|
||||
<AuthorizeView>
|
||||
<GameBoard SessionId="@SessionId" />
|
||||
</AuthorizeView>
|
||||
</main>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public string? SessionId { get; set; }
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (!node.IsConnected)
|
||||
{
|
||||
await node.BeginListen();
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Shogi.UI/Pages/Play/PromotePrompt.cs
Normal file
58
Shogi.UI/Pages/Play/PromotePrompt.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Shogi.Contracts.Api;
|
||||
using Shogi.UI.Shared;
|
||||
|
||||
namespace Shogi.UI.Pages.Play;
|
||||
|
||||
public class PromotePrompt
|
||||
{
|
||||
private readonly ShogiApi shogiApi;
|
||||
private string? sessionName;
|
||||
private MovePieceCommand? command;
|
||||
|
||||
public PromotePrompt(ShogiApi shogiApi)
|
||||
{
|
||||
this.shogiApi = shogiApi;
|
||||
this.IsVisible = false;
|
||||
this.OnClickCancel = this.Hide;
|
||||
}
|
||||
|
||||
public bool IsVisible { get; private set; }
|
||||
public Action OnClickCancel;
|
||||
public Func<Task>? OnClickNo;
|
||||
public Func<Task>? OnClickYes;
|
||||
|
||||
public void Show(string sessionName, MovePieceCommand command)
|
||||
{
|
||||
this.sessionName = sessionName;
|
||||
this.command = command;
|
||||
this.IsVisible = true;
|
||||
this.OnClickNo = this.Move;
|
||||
this.OnClickYes = this.MoveAndPromote;
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
this.IsVisible = false;
|
||||
this.OnClickNo = null;
|
||||
this.OnClickYes = null;
|
||||
}
|
||||
|
||||
private Task Move()
|
||||
{
|
||||
if (this.command != null && this.sessionName != null)
|
||||
{
|
||||
this.command.IsPromotion = false;
|
||||
return this.shogiApi.Move(this.sessionName, this.command);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
private Task MoveAndPromote()
|
||||
{
|
||||
if (this.command != null && this.sessionName != null)
|
||||
{
|
||||
this.command.IsPromotion = true;
|
||||
return this.shogiApi.Move(this.sessionName, this.command);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
11
Shogi.UI/Pages/SearchPage.razor
Normal file
11
Shogi.UI/Pages/SearchPage.razor
Normal file
@@ -0,0 +1,11 @@
|
||||
@page "/search"
|
||||
|
||||
<main class="SearchPage PrimaryTheme">
|
||||
<h3>Find Sessions</h3>
|
||||
|
||||
<GameBrowser />
|
||||
</main>
|
||||
|
||||
@code {
|
||||
|
||||
}
|
||||
4
Shogi.UI/Pages/SearchPage.razor.css
Normal file
4
Shogi.UI/Pages/SearchPage.razor.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.SearchPage {
|
||||
background-color: var(--contrast-color);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
@page "/test"
|
||||
|
||||
<h3>TestPage</h3>
|
||||
|
||||
@code {
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user