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:
2024-08-25 03:46:44 +00:00
parent d688afaeae
commit 51d234d871
172 changed files with 3857 additions and 4045 deletions

View File

@@ -2,7 +2,7 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>

View File

@@ -0,0 +1,246 @@
namespace Shogi.UI.Identity;
using Microsoft.AspNetCore.Components.Authorization;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
/// <summary>
/// Handles state for cookie-based auth.
/// </summary>
public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IAccountManagement
{
/// <summary>
/// Map the JavaScript-formatted properties to C#-formatted classes.
/// </summary>
private readonly JsonSerializerOptions jsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// Special auth client.
/// </summary>
private readonly HttpClient _httpClient;
/// <summary>
/// Authentication state.
/// </summary>
private bool _authenticated = false;
/// <summary>
/// Default principal for anonymous (not authenticated) users.
/// </summary>
private readonly ClaimsPrincipal Unauthenticated =
new(new ClaimsIdentity());
/// <summary>
/// Create a new instance of the auth provider.
/// </summary>
/// <param name="httpClientFactory">Factory to retrieve auth client.</param>
public CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory)
=> _httpClient = httpClientFactory.CreateClient("Auth");
/// <summary>
/// Register a new user.
/// </summary>
/// <param name="email">The user's email address.</param>
/// <param name="password">The user's password.</param>
/// <returns>The result serialized to a <see cref="FormResult"/>.
/// </returns>
public async Task<FormResult> RegisterAsync(string email, string password)
{
string[] defaultDetail = ["An unknown error prevented registration from succeeding."];
try
{
// make the request
var result = await _httpClient.PostAsJsonAsync("register", new
{
email,
password
});
// successful?
if (result.IsSuccessStatusCode)
{
return new FormResult { Succeeded = true };
}
// body should contain details about why it failed
var details = await result.Content.ReadAsStringAsync();
var problemDetails = JsonDocument.Parse(details);
var errors = new List<string>();
var errorList = problemDetails.RootElement.GetProperty("errors");
foreach (var errorEntry in errorList.EnumerateObject())
{
if (errorEntry.Value.ValueKind == JsonValueKind.String)
{
errors.Add(errorEntry.Value.GetString()!);
}
else if (errorEntry.Value.ValueKind == JsonValueKind.Array)
{
errors.AddRange(
errorEntry.Value.EnumerateArray().Select(
e => e.GetString() ?? string.Empty)
.Where(e => !string.IsNullOrEmpty(e)));
}
}
// return the error list
return new FormResult
{
Succeeded = false,
ErrorList = problemDetails == null ? defaultDetail : [.. errors]
};
}
catch { }
// unknown error
return new FormResult
{
Succeeded = false,
ErrorList = defaultDetail
};
}
/// <summary>
/// User login.
/// </summary>
/// <param name="email">The user's email address.</param>
/// <param name="password">The user's password.</param>
/// <returns>The result of the login request serialized to a <see cref="FormResult"/>.</returns>
public async Task<FormResult> LoginAsync(string email, string password)
{
try
{
// login with cookies
var result = await _httpClient.PostAsJsonAsync("login?useCookies=true", new
{
email,
password
});
// success?
if (result.IsSuccessStatusCode)
{
// need to refresh auth state
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
// success!
return new FormResult { Succeeded = true };
}
}
catch { }
// unknown error
return new FormResult
{
Succeeded = false,
ErrorList = ["Invalid email and/or password."]
};
}
/// <summary>
/// Get authentication state.
/// </summary>
/// <remarks>
/// Called by Blazor anytime and authentication-based decision needs to be made, then cached
/// until the changed state notification is raised.
/// </remarks>
/// <returns>The authentication state asynchronous request.</returns>
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
_authenticated = false;
// default to not authenticated
var user = Unauthenticated;
try
{
// the user info endpoint is secured, so if the user isn't logged in this will fail
var userResponse = await _httpClient.GetAsync("manage/info");
// throw if user info wasn't retrieved
userResponse.EnsureSuccessStatusCode();
// user is authenticated,so let's build their authenticated identity
var userJson = await userResponse.Content.ReadAsStringAsync();
var userInfo = JsonSerializer.Deserialize<UserInfo>(userJson, jsonSerializerOptions);
if (userInfo != null)
{
// in our system name and email are the same
var claims = new List<Claim>
{
new(ClaimTypes.Name, userInfo.Email),
new(ClaimTypes.Email, userInfo.Email)
};
// add any additional claims
claims.AddRange(
userInfo.Claims
.Where(c => c.Key != ClaimTypes.Name && c.Key != ClaimTypes.Email)
.Select(c => new Claim(c.Key, c.Value)));
// tap the roles endpoint for the user's roles
var rolesResponse = await _httpClient.GetAsync("roles");
// throw if request fails
rolesResponse.EnsureSuccessStatusCode();
// read the response into a string
var rolesJson = await rolesResponse.Content.ReadAsStringAsync();
// deserialize the roles string into an array
var roles = JsonSerializer.Deserialize<RoleClaim[]>(rolesJson, jsonSerializerOptions);
// if there are roles, add them to the claims collection
if (roles?.Length > 0)
{
foreach (var role in roles)
{
if (!string.IsNullOrEmpty(role.Type) && !string.IsNullOrEmpty(role.Value))
{
claims.Add(new Claim(role.Type, role.Value, role.ValueType, role.Issuer, role.OriginalIssuer));
}
}
}
// set the principal
var id = new ClaimsIdentity(claims, nameof(CookieAuthenticationStateProvider));
user = new ClaimsPrincipal(id);
_authenticated = true;
}
}
catch { }
// return the state
return new AuthenticationState(user);
}
public async Task LogoutAsync()
{
const string Empty = "{}";
var emptyContent = new StringContent(Empty, Encoding.UTF8, "application/json");
await _httpClient.PostAsync("logout", emptyContent);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public async Task<bool> CheckAuthenticatedAsync()
{
await GetAuthenticationStateAsync();
return _authenticated;
}
public class RoleClaim
{
public string? Issuer { get; set; }
public string? OriginalIssuer { get; set; }
public string? Type { get; set; }
public string? Value { get; set; }
public string? ValueType { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Components.WebAssembly.Http;
namespace Shogi.UI.Identity;
public class CookieCredentialsMessageHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
request.Headers.Add("X-Requested-With", ["XMLHttpRequest"]);
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,14 @@
namespace Shogi.UI.Identity;
public class FormResult
{
/// <summary>
/// Gets or sets a value indicating whether the action was successful.
/// </summary>
public bool Succeeded { get; set; }
/// <summary>
/// On failure, the problem details are parsed and returned in this array.
/// </summary>
public string[] ErrorList { get; set; } = [];
}

View File

@@ -0,0 +1,31 @@
namespace Shogi.UI.Identity;
/// <summary>
/// Account management services.
/// </summary>
public interface IAccountManagement
{
/// <summary>
/// Login service.
/// </summary>
/// <param name="email">User's email.</param>
/// <param name="password">User's password.</param>
/// <returns>The result of the request serialized to <see cref="FormResult"/>.</returns>
public Task<FormResult> LoginAsync(string email, string password);
/// <summary>
/// Log out the logged in user.
/// </summary>
/// <returns>The asynchronous task.</returns>
public Task LogoutAsync();
/// <summary>
/// Registration service.
/// </summary>
/// <param name="email">User's email.</param>
/// <param name="password">User's password.</param>
/// <returns>The result of the request serialized to <see cref="FormResult"/>.</returns>
public Task<FormResult> RegisterAsync(string email, string password);
public Task<bool> CheckAuthenticatedAsync();
}

View File

@@ -0,0 +1,22 @@
namespace Shogi.UI.Identity;
/// <summary>
/// User info from identity endpoint to establish claims.
/// </summary>
public class UserInfo
{
/// <summary>
/// The email address.
/// </summary>
public string Email { get; set; } = string.Empty;
/// <summary>
/// A value indicating whether the email has been confirmed yet.
/// </summary>
public bool IsEmailConfirmed { get; set; }
/// <summary>
/// The list of claims for the user.
/// </summary>
public Dictionary<string, string> Claims { get; set; } = [];
}

View File

@@ -0,0 +1,7 @@
@inherits LayoutComponentBase
<div class="MainLayout PrimaryTheme">
<NavMenu />
@Body
</div>

View File

@@ -0,0 +1,5 @@
.MainLayout {
display: grid;
grid-template-columns: auto 1fr;
place-items: stretch;
}

View File

@@ -0,0 +1,52 @@
@inject NavigationManager navigator
@inject ShogiApi Api
<div class="NavMenu PrimaryTheme ThemeVariant--Contrast">
<h1>Shogi</h1>
<p>
<a href="/">Home</a>
</p>
<AuthorizeView>
<p>
<a href="/search">Search</a>
</p>
<p>
<button class="href" @onclick="CreateSession">Create</button>
</p>
</AuthorizeView>
<div class="spacer" />
<AuthorizeView>
<Authorized>
<p>@context.User.Identity?.Name</p>
<p>
<a href="logout">Logout</a>
</p>
</Authorized>
<NotAuthorized>
<p>
<a href="login">Login</a>
</p>
<p>
<a href="register">Register</a>
</p>
</NotAuthorized>
</AuthorizeView>
</div>
@code {
async Task CreateSession()
{
var sessionId = await Api.PostSession();
if (!string.IsNullOrEmpty(sessionId))
{
navigator.NavigateTo($"/play/{sessionId}");
}
}
}

View File

@@ -0,0 +1,15 @@
.NavMenu {
display: flex;
flex-direction: column;
border-right: 2px solid #444;
}
.NavMenu > * {
padding: 0 0.5rem;
}
.NavMenu h1 {
}
.NavMenu .spacer {
flex: 1;
}

View File

@@ -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("/");
}
}

View File

@@ -1,6 +1,6 @@
@page "/error"
<main>
<main class="PrimaryTheme">
<div class="card">
<div class="card-body">
<h1 class="card-title">Oops!</h1>

View File

@@ -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.");
}
}
}

View File

@@ -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
});
}
}
}

View File

@@ -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();
}
}

View File

@@ -1,6 +0,0 @@
namespace Shogi.UI.Pages.Home.Account;
public class LoginEventArgs : EventArgs
{
public User? User { get; set; }
}

View File

@@ -1,9 +0,0 @@
namespace Shogi.UI.Pages.Home.Account
{
public readonly record struct User(
string Id,
string DisplayName,
WhichAccountPlatform WhichAccountPlatform)
{
}
}

View File

@@ -1,8 +0,0 @@
namespace Shogi.UI.Pages.Home.Account
{
public enum WhichAccountPlatform
{
Guest,
Microsoft
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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)
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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&apos;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; }
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -1,52 +0,0 @@
@inject AccountManager Account
@inject AccountState AccountState
<div class="my-modal-background">
<div class="my-modal">
@if (guestAccountDescriptionIsVisible)
{
<h1>What&apos;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&apos;s the difference?</button>
</p>
}
</div>
</div>
@code {
bool guestAccountDescriptionIsVisible = false;
void ShowGuestAccountDescription()
{
guestAccountDescriptionIsVisible = true;
}
void HideGuestAccountDescription()
{
guestAccountDescriptionIsVisible = false;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View 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();
}
}

View File

@@ -0,0 +1,4 @@
.shogi {
background-color: var(--primary-color);
color: white;
}

View 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;
}
}
}

View 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;
}

View 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();
}
}

View 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;
}
}
}

View 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;
}

View 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();
}
}

View 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();
}

View 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;
}

View File

@@ -0,0 +1,21 @@
<p style="margin: 0">
@if (IsTurn)
{
<span>*&nbsp;</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;
}

View 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();
}
}

View 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();
}
}

View 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();
}
}
}

View File

@@ -0,0 +1,5 @@
row {
display: grid;
grid-template-columns: 18rem 5rem;
padding-left: 5px; /* Matches box shadow on hover */
}

View File

@@ -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
};
}

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View 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();
}
}
}

View 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;
}
}

View File

@@ -0,0 +1,11 @@
@page "/search"
<main class="SearchPage PrimaryTheme">
<h3>Find Sessions</h3>
<GameBrowser />
</main>
@code {
}

View File

@@ -0,0 +1,4 @@
.SearchPage {
background-color: var(--contrast-color);
padding: 0 0.5rem;
}

View File

@@ -1,7 +0,0 @@
@page "/test"
<h3>TestPage</h3>
@code {
}

View File

@@ -1,66 +1,66 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.ResponseCompression;
using Shogi.UI;
using Shogi.UI.Pages.Home;
using Shogi.UI.Pages.Home.Account;
using Shogi.UI.Pages.Home.Api;
using Shogi.UI.Identity;
using Shogi.UI.Pages.Play;
using Shogi.UI.Shared;
using System.Text.Json;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Logging.AddConfiguration(
builder.Configuration.GetSection("Logging"));
ConfigureDependencies(builder.Services, builder.Configuration);
builder.Services.AddResponseCompression(options =>
{
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]);
});
await builder.Build().RunAsync();
static void ConfigureDependencies(IServiceCollection services, IConfiguration configuration)
{
/**
* Why two HTTP clients?
* See qhttps://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/additional-scenarios?source=recommendations&view=aspnetcore-6.0#unauthenticated-or-unauthorized-web-api-requests-in-an-app-with-a-secure-default-client
*/
var baseUrl = configuration["ShogiApiUrl"];
if (string.IsNullOrWhiteSpace(baseUrl))
{
throw new InvalidOperationException("ShogiApiUrl configuration is missing.");
}
/**
* Why two HTTP clients?
* See qhttps://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/additional-scenarios?source=recommendations&view=aspnetcore-6.0#unauthenticated-or-unauthorized-web-api-requests-in-an-app-with-a-secure-default-client
*/
var baseUrl = configuration["ShogiApiUrl"];
if (string.IsNullOrWhiteSpace(baseUrl))
{
throw new InvalidOperationException("ShogiApiUrl configuration is missing.");
}
var shogiApiUrl = new Uri(baseUrl, UriKind.Absolute);
services
.AddHttpClient(ShogiApi.MsalClientName, client => client.BaseAddress = shogiApiUrl)
.AddHttpMessageHandler<MsalMessageHandler>();
services
.AddHttpClient(ShogiApi.GuestClientName, client => client.BaseAddress = shogiApiUrl)
.AddHttpMessageHandler<CookieCredentialsMessageHandler>();
var shogiApiUrl = new Uri(baseUrl, UriKind.Absolute);
// Authorization
services.AddMsalAuthentication(options =>
{
configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
options.ProviderOptions.LoginMode = "redirect";
services
.AddTransient<CookieCredentialsMessageHandler>()
.AddTransient<ILocalStorage, LocalStorage>()
.AddSingleton<PromotePrompt>();
});
services.AddOidcAuthentication(options =>
{
// Configure your authentication provider options here.
// For more information, see https://aka.ms/blazor-standalone-auth
configuration.Bind("AzureAd", options.ProviderOptions);
options.ProviderOptions.ResponseType = "code";
});
// Identity
services
.AddAuthorizationCore(options => options.AddPolicy("Admin", policy => policy.RequireUserName("Hauth@live.com")))
.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>()
.AddScoped(sp => (IAccountManagement)sp.GetRequiredService<AuthenticationStateProvider>())
.AddHttpClient("Auth", client => client.BaseAddress = shogiApiUrl) // "Auth" is the name expected by the auth library.
.AddHttpMessageHandler<CookieCredentialsMessageHandler>();
// https://docs.microsoft.com/en-us/aspnet/core/blazor/fundamentals/dependency-injection?view=aspnetcore-6.0#service-lifetime
services.AddScoped<AccountManager>();
services.AddScoped<AccountState>();
services.AddScoped<ShogiSocket>();
services.AddScoped<ILocalStorage, LocalStorage>();
services.AddScoped<MsalMessageHandler>();
services.AddScoped<CookieCredentialsMessageHandler>();
services.AddScoped<IShogiApi, ShogiApi>();
services.AddScoped<PromotePrompt>();
// Network clients
services
.AddHttpClient<ShogiApi>(client => client.BaseAddress = shogiApiUrl)
.AddHttpMessageHandler<CookieCredentialsMessageHandler>();
services
.AddSingleton<GameHubNode>();
var serializerOptions = new JsonSerializerOptions
{
WriteIndented = true
};
services.AddScoped((sp) => serializerOptions);
var serializerOptions = new JsonSerializerOptions
{
WriteIndented = true
};
services.AddSingleton((sp) => serializerOptions);
}

View File

@@ -1,8 +0,0 @@
namespace Shogi.UI.Shared
{
public static class Events
{
public delegate Task AsyncEventHandler();
public delegate Task AsyncEventHandler<TArgs>(TArgs args);
}
}

View File

@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.SignalR.Client;
using Shogi.UI.Identity;
using System.Diagnostics;
namespace Shogi.UI.Shared;
public class GameHubNode : IAsyncDisposable
{
private readonly HubConnection hubConnection;
public GameHubNode()
{
this.hubConnection = new HubConnectionBuilder()
.WithUrl(new Uri("https://localhost:5001/gamehub", UriKind.Absolute), options =>
{
options.HttpMessageHandlerFactory = handler => new CookieCredentialsMessageHandler { InnerHandler = handler };
options.SkipNegotiation = true;
options.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets;
})
.Build();
}
public bool IsConnected => this.hubConnection.State == HubConnectionState.Connected;
public async Task BeginListen()
{
if (!this.IsConnected)
{
await this.hubConnection.StartAsync();
}
}
public async Task Subscribe(string sessionId)
{
await this.hubConnection.SendAsync("Subscribe", sessionId);
}
public async Task Unsubscribe(string sessionId)
{
await this.hubConnection.SendAsync("Unsubscribe", sessionId);
}
public IDisposable OnSessionJoined(Func<Task> func)
{
return this.hubConnection.On("SessionJoined", func);
}
public IDisposable OnPieceMoved(Func<Task> func)
{
return this.hubConnection.On("PieceMoved", func);
}
public ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
return this.hubConnection.DisposeAsync();
}
}

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-down" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708" />
</svg>
@code {
}

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-up" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708z" />
</svg>
@code {
}

View File

@@ -2,50 +2,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Shogi.UI.Shared
namespace Shogi.UI.Shared;
public class LocalStorage : ILocalStorage
{
public class LocalStorage : ILocalStorage
private readonly JsonSerializerOptions jsonOptions;
private readonly IJSRuntime jSRuntime;
public LocalStorage(IJSRuntime jSRuntime)
{
private readonly JsonSerializerOptions jsonOptions;
private readonly IJSRuntime jSRuntime;
this.jsonOptions = new JsonSerializerOptions();
this.jsonOptions.Converters.Add(new JsonStringEnumConverter());
this.jSRuntime = jSRuntime;
}
public LocalStorage(IJSRuntime jSRuntime)
public ValueTask Set<T>(string key, T value)
{
var serialized = JsonSerializer.Serialize(value);
return this.jSRuntime.InvokeVoidAsync("localStorage.setItem", key, serialized);
}
public async ValueTask<T?> Get<T>(string key) where T : struct
{
var value = await this.jSRuntime.InvokeAsync<string>("localStorage.getItem", key);
try
{
jsonOptions = new JsonSerializerOptions();
jsonOptions.Converters.Add(new JsonStringEnumConverter());
this.jSRuntime = jSRuntime;
return JsonSerializer.Deserialize<T>(value, this.jsonOptions);
}
public ValueTask Set<T>(string key, T value)
catch (ArgumentNullException)
{
var serialized = JsonSerializer.Serialize(value);
return jSRuntime.InvokeVoidAsync("localStorage.setItem", key, serialized);
}
public async ValueTask<T?> Get<T>(string key) where T : struct
{
var value = await jSRuntime.InvokeAsync<string>("localStorage.getItem", key);
try
{
return JsonSerializer.Deserialize<T>(value, jsonOptions);
}
catch (ArgumentNullException)
{
return default;
}
}
public ValueTask Delete(string key)
{
return jSRuntime.InvokeVoidAsync("localStorage.removeItem", key);
return default;
}
}
public interface ILocalStorage
public ValueTask Delete(string key)
{
ValueTask Delete(string key);
ValueTask<T?> Get<T>(string key) where T : struct;
ValueTask Set<T>(string key, T value);
return this.jSRuntime.InvokeVoidAsync("localStorage.removeItem", key);
}
}
public interface ILocalStorage
{
ValueTask Delete(string key);
ValueTask<T?> Get<T>(string key) where T : struct;
ValueTask Set<T>(string key, T value);
}

View File

@@ -1,4 +0,0 @@
@inherits LayoutComponentBase
@Body

View File

@@ -1,3 +0,0 @@
html, body, #app {
height: 100%;
}

View File

@@ -0,0 +1,74 @@
using Shogi.Contracts.Api;
using Shogi.Contracts.Types;
using System.Net;
using System.Net.Http.Json;
using System.Reflection.Metadata.Ecma335;
using System.Text.Json;
namespace Shogi.UI.Shared;
public class ShogiApi(HttpClient httpClient)
{
private readonly JsonSerializerOptions serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
public async Task Register(string email, string password)
{
var response = await httpClient.PostAsJsonAsync(Relative("register"), new { email, password });
response.EnsureSuccessStatusCode();
}
public async Task LoginEventArgs(string email, string password)
{
var response = await httpClient.PostAsJsonAsync("login?useCookies=true", new { email, password });
response.EnsureSuccessStatusCode();
}
public async Task Logout()
{
var response = await httpClient.PutAsync(Relative("User/GuestLogout"), null);
response.EnsureSuccessStatusCode();
}
public async Task<Session?> GetSession(string name)
{
var response = await httpClient.GetAsync(Relative($"Sessions/{name}"));
if (response.IsSuccessStatusCode)
{
return (await response.Content.ReadFromJsonAsync<Session>(this.serializerOptions));
}
return null;
}
public async Task<SessionMetadata[]> GetAllSessionsMetadata()
{
var response = await httpClient.GetAsync(Relative("Sessions"));
if (response.IsSuccessStatusCode)
{
return (await response.Content.ReadFromJsonAsync<SessionMetadata[]>(this.serializerOptions))!;
}
return [];
}
/// <summary>
/// Returns false if the move was not accepted by the server.
/// </summary>
public async Task<bool> Move(string sessionName, MovePieceCommand command)
{
var response = await httpClient.PatchAsync(Relative($"Sessions/{sessionName}/Move"), JsonContent.Create(command));
return response.IsSuccessStatusCode;
}
public async Task<string?> PostSession()
{
var response = await httpClient.PostAsync(Relative("Sessions"), null);
var sessionId = response.IsSuccessStatusCode ? await response.Content.ReadAsStringAsync() : null;
return sessionId;
}
public Task<HttpResponseMessage> PatchJoinGame(string name)
{
return httpClient.PatchAsync(Relative($"Sessions/{name}/Join"), null);
}
private static Uri Relative(string path) => new(path, UriKind.Relative);
}

View File

@@ -1,131 +0,0 @@
using Microsoft.AspNetCore.WebUtilities;
using Shogi.Contracts.Socket;
using Shogi.Contracts.Types;
using System.Buffers;
using System.Net.WebSockets;
using System.Text.Json;
using static Shogi.UI.Shared.Events;
namespace Shogi.UI.Shared;
public class ShogiSocket : IDisposable
{
public event AsyncEventHandler? OnSessionCreated;
public event AsyncEventHandler<SessionJoinedByPlayerSocketMessage>? OnSessionJoined;
public event AsyncEventHandler<PlayerHasMovedMessage>? OnPlayerMoved;
private ClientWebSocket socket;
private readonly JsonSerializerOptions serializerOptions;
private readonly string baseUrl;
private readonly CancellationTokenSource cancelToken;
private readonly IMemoryOwner<byte> memoryOwner;
private bool disposedValue;
public ShogiSocket(IConfiguration configuration, JsonSerializerOptions serializerOptions)
{
this.socket = new ClientWebSocket();
this.serializerOptions = serializerOptions;
this.baseUrl = configuration["SocketUrl"] ?? throw new InvalidOperationException("SocketUrl configuration is missing.");
this.cancelToken = new CancellationTokenSource();
this.memoryOwner = MemoryPool<byte>.Shared.Rent(1024 * 2);
}
public async Task OpenAsync(string token)
{
if (this.socket.State == WebSocketState.Open)
{
await this.socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing before opening a new connection.", CancellationToken.None);
}
if (this.socket.State == WebSocketState.Closed)
{
this.socket.Dispose();
this.socket = new ClientWebSocket(); // Because you can't reopen a closed socket.
}
else
{
Console.WriteLine("Opening socket and existing socket state is " + this.socket.State.ToString());
}
var uri = new Uri(QueryHelpers.AddQueryString(this.baseUrl, "token", token), UriKind.Absolute);
await socket.ConnectAsync(uri, cancelToken.Token);
// Fire and forget! I'm way too lazy to write my own javascript interop to a web worker. Nooo thanks.
_ = Listen()
.ContinueWith(async antecedent =>
{
this.cancelToken.Cancel();
await this.socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Page was probably closed or refresh.", CancellationToken.None);
if (antecedent.Exception != null)
{
throw antecedent.Exception;
}
}, TaskContinuationOptions.OnlyOnFaulted)
.ConfigureAwait(false);
}
private async Task Listen()
{
while (socket.State == WebSocketState.Open && !cancelToken.IsCancellationRequested)
{
var result = await socket.ReceiveAsync(this.memoryOwner.Memory, cancelToken.Token);
var memory = this.memoryOwner.Memory[..result.Count].ToArray();
var action = JsonDocument
.Parse(memory)
.RootElement
.GetProperty(nameof(ISocketMessage.Action))
.Deserialize<SocketAction>();
Console.WriteLine($"Socket action: {action}");
switch (action)
{
case SocketAction.SessionCreated:
if (this.OnSessionCreated is not null)
{
await this.OnSessionCreated();
}
break;
case SocketAction.SessionJoined:
if (this.OnSessionJoined is not null)
{
var args = JsonSerializer.Deserialize<SessionJoinedByPlayerSocketMessage>(memory, serializerOptions);
await this.OnSessionJoined(args!);
}
break;
case SocketAction.PieceMoved:
if (this.OnPlayerMoved is not null)
{
var args = JsonSerializer.Deserialize<PlayerHasMovedMessage>(memory, serializerOptions);
await this.OnPlayerMoved(args!);
}
break;
default:
throw new NotImplementedException($"Socket message for action:{action} is not implemented.");
}
}
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Socket closed because cancellation token was cancelled.", CancellationToken.None);
if (!cancelToken.IsCancellationRequested)
{
throw new InvalidOperationException("Stopped socket listening without cancelling.");
}
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
//socket.Dispose(); // This is handled by the DI container.
cancelToken.Cancel();
memoryOwner.Dispose();
}
disposedValue = true;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

View File

@@ -13,13 +13,25 @@
<None Remove="zzzNotSure\**" />
</ItemGroup>
<ItemGroup>
<None Remove="Pages\SearchPage.razor.css" />
</ItemGroup>
<ItemGroup>
<Content Include="Pages\SearchPage.razor.css" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.ResponseCompression" Version="2.1.21" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client.Core" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="8.0.1" />
<PackageReference Include="Microsoft.Authentication.WebAssembly.Msal" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Configuration" Version="8.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -8,8 +8,10 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using Shogi.UI.Pages.Home.Account
@using Shogi.UI.Pages.Home.Api
@using Shogi.UI.Pages.Home.GameBoard
@using Shogi.UI.Pages.Home.Pieces
@using Shogi.UI.Identity
@using Shogi.UI.Layout
@using Shogi.UI.Pages.Play
@using Shogi.UI.Pages.Play.GameBoard
@using Shogi.UI.Pages.Play.Pieces
@using Shogi.UI.Shared
@using Shogi.UI.Shared.Icons

View File

@@ -1,7 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning"
"Default": "Warning",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Error",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information",
"System.Net.Http.HttpClient": "Error",
"Microsoft.AspNetCore.SignalR": "Debug",
"Microsoft.AspNetCore.Http.Connections": "Debug"
}
},
"AzureAd": {
@@ -12,9 +18,9 @@
"api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope"
]
},
"ShogiApiUrl": "https://api.lucaserver.space/Shogi.Api/",
"SocketUrl": "wss://api.lucaserver.space/Shogi.Api/",
"ShogiApiUrl2": "https://api.lucaserver.space/Shogi.Api/",
"SocketUrl2": "wss://api.lucaserver.space/Shogi.Api/",
"ShogiApiUrl2": "https://localhost:5001",
"SocketUrl2": "wss://localhost:5001"
"ShogiApiUrl": "https://localhost:5001",
"SocketUrl": "wss://localhost:5001"
}

View File

@@ -1,65 +1,16 @@
@import url('open-iconic/font/css/open-iconic-bootstrap.min.css');
html, body, #app {
html, body, #app {
height: 100vh;
}
body {
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: var(--primary-color);
}
span {
display: inline-block;
vertical-align: middle;
}
a {
text-decoration: none;
}
a.plain {
color: inherit;
}
a:hover {
text-decoration: underline;
}
button {
background-color: var(--primary-color);
color: var(--contrast-color);
border: none;
padding: 0.3rem 0.7rem;
border: 1px solid var(--primary-color);
border-radius: 2px;
cursor: pointer;
font: inherit;
font-size: 85%;
}
.smaller {
font-size: smaller;
}
#app {
display: grid;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
@@ -88,40 +39,3 @@ button {
.blazor-error-boundary::after {
content: "An error has occurred."
}
p {
margin-bottom: 0.5rem;
}
/* Layout */
.flex-between {
display: flex;
justify-content: space-between;
}
/* Variables */
html {
--primary-color: #444;
--secondary-color: #5e5e5e;
--contrast-color: #eaeaea;
}
/* Bootstrap overrides */
h1, h2, h3, h4, h5, h6 {
all: revert;
margin-top: 0;
margin-bottom: 0.5rem;
}
button.btn-link {
color: #0066cc;
}
button.btn.btn-link:not(:hover) {
text-decoration: none;
}
.place-self-center {
place-self: center;
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.0 MiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,68 @@
.PrimaryTheme {
--backgroundColor: #444444;
--foregroundColor: #eaeaea;
--hrefColor: #99c3ff;
--uniformBottomMargin: 0.5rem;
background-color: var(--backgroundColor);
color: var(--foregroundColor);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.PrimaryTheme a {
color: var(--hrefColor);
}
.PrimaryTheme a:not(:hover) {
text-decoration: none;
}
.PrimaryTheme button {
background-color: var(--foregroundColor);
color: var(--backgroundColor);
border: none;
padding: 0.3rem 0.7rem;
border: 1px solid var(--backgroundColor);
border-radius: 2px;
cursor: pointer;
font: inherit;
font-size: 85%;
}
.PrimaryTheme button.href {
border: 0;
background: unset;
color: var(--hrefColor);
padding: 0;
font-size: 100%;
}
.PrimaryTheme button.href:hover {
text-decoration: underline;
}
.PrimaryTheme p, .PrimaryTheme h1, .PrimaryTheme h2, .PrimaryTheme h3, .PrimaryTheme h4, .PrimaryTheme h5, .PrimaryTheme h6 {
margin-top: 0;
margin-bottom: var(--uniformBottomMargin);
}
.PrimaryTheme ul {
padding: 0.3rem;
margin: 0;
margin-bottom: var(--uniformBottomMargin);
background-color: var(--foregroundColor);
color: var(--backgroundColor);
list-style-position: inside;
}
/***************************
* Contrast Variant *
***************************/
.PrimaryTheme .ThemeVariant--Contrast {
--backgroundColor: #eaeaea;
--foregroundColor: #444444;
--hrefColor: #0065be;
}

View File

@@ -2,35 +2,32 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Shogi.UI</title>
<script type="text/javascript">
var base = document.createElement('base');
base.href = window.location.href.includes("localhost")
? "/"
: "/shogi/";
document.head.appendChild(base);
</script>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Shogi.UI</title>
<script type="text/javascript">
var base = document.createElement('base');
base.href = window.location.href.includes("localhost")
? "/"
: "/shogi/";
document.head.appendChild(base);
</script>
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="css/icons/bootstrap-icons.css" rel="stylesheet" />
<script type="text/javascript" src="css/bootstrap/bootstrap.min.js"></script>
<link href="css/app.css" rel="stylesheet" />
<link href="Shogi.UI.styles.css" rel="stylesheet" />
<link href="css/app.css" rel="stylesheet" />
<link href="css/themes.css" rel="stylesheet" />
<link href="Shogi.UI.styles.css" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<div id="app"></div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></script>
<script src="_content/Microsoft.Authentication.WebAssembly.Msal/AuthenticationService.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>