squash a bunch of commits

This commit is contained in:
2022-10-30 12:03:16 -05:00
parent 09b72c1858
commit 93027e8c57
222 changed files with 6157 additions and 3201 deletions

View File

@@ -0,0 +1,138 @@
using Microsoft.AspNetCore.Components;
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 IConfiguration configuration;
private readonly ILocalStorage localStorage;
//private readonly AuthenticationStateProvider authState;
private readonly NavigationManager navigation;
private readonly ShogiSocket shogiSocket;
public AccountManager(
AccountState accountState,
IShogiApi unauthenticatedClient,
IConfiguration configuration,
//AuthenticationStateProvider authState,
ILocalStorage localStorage,
NavigationManager navigation,
ShogiSocket shogiSocket)
{
this.accountState = accountState;
this.shogiApi = unauthenticatedClient;
this.configuration = configuration;
//this.authState = authState;
this.localStorage = localStorage;
this.navigation = navigation;
this.shogiSocket = shogiSocket;
}
private User? User { get => accountState.User; set => accountState.User = value; }
public async Task LoginWithGuestAccount()
{
var response = await shogiApi.GetGuestToken();
if (response != null)
{
User = new User
{
DisplayName = response.DisplayName,
Id = response.UserId,
OneTimeSocketToken = response.OneTimeToken,
WhichAccountPlatform = WhichAccountPlatform.Guest
};
await shogiSocket.OpenAsync(User.OneTimeSocketToken.ToString());
await localStorage.SetAccountPlatform(WhichAccountPlatform.Guest);
}
}
public async Task LoginWithMicrosoftAccount()
{
throw new NotImplementedException();
//var state = await authState.GetAuthenticationStateAsync();
//if (state.User?.Identity?.Name == null || state.User?.Identity?.IsAuthenticated != true)
//{
// navigation.NavigateTo("authentication/login");
// return;
//}
//var id = state.User.Identity.Name;
//var socketToken = await shogiApi.GetToken();
//if (socketToken.HasValue)
//{
// User = new User
// {
// DisplayName = id,
// Id = id,
// OneTimeSocketToken = socketToken.Value
// };
// await ConnectToSocketAsync();
// await localStorage.SetAccountPlatform(WhichAccountPlatform.Microsoft);
// }
}
/// <summary>
/// Try to log in with the account used from the previous browser session.
/// </summary>
/// <returns></returns>
public async Task<bool> TryLoginSilentAsync()
{
var platform = await localStorage.GetAccountPlatform();
if (platform == WhichAccountPlatform.Guest)
{
var response = await shogiApi.GetGuestToken();
if (response != null)
{
User = new User
{
DisplayName = response.DisplayName,
Id = response.UserId,
OneTimeSocketToken = response.OneTimeToken,
WhichAccountPlatform = WhichAccountPlatform.Guest
};
}
}
else if (platform == WhichAccountPlatform.Microsoft)
{
Console.WriteLine("Login Microsoft");
throw new NotImplementedException();
//var state = await authState.GetAuthenticationStateAsync();
//if (state.User?.Identity?.Name != null)
//{
// var id = state.User.Identity;
// User = new User
// {
// DisplayName = id.Name,
// Id = id.Name
// };
// var token = await shogiApi.GetToken();
// if (token.HasValue)
// {
// User.OneTimeSocketToken = token.Value;
// }
//}
// TODO: If this fails then platform saved to localStorage should get cleared
}
if (User != null)
{
await shogiSocket.OpenAsync(User.OneTimeSocketToken.ToString());
return true;
}
return false;
}
public async Task LogoutAsync()
{
await Task.WhenAll(shogiApi.GuestLogout(), localStorage.DeleteAccountPlatform());
User = null;
}
}

View File

@@ -0,0 +1,28 @@
namespace Shogi.UI.Pages.Home.Account;
public class AccountState
{
public event EventHandler<LoginEventArgs>? LoginChangedEvent;
private User? user;
public User? User
{
get => user;
set
{
if (user != value)
{
user = value;
EmitLoginChangedEvent();
}
}
}
private void EmitLoginChangedEvent()
{
LoginChangedEvent?.Invoke(this, new LoginEventArgs
{
User = User
});
}
}

View File

@@ -0,0 +1,24 @@
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

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

View File

@@ -0,0 +1,12 @@
namespace Shogi.UI.Pages.Home.Account
{
public class User
{
public string Id { get; set; }
public string DisplayName { get; set; }
public WhichAccountPlatform WhichAccountPlatform { get; set; }
public Guid OneTimeSocketToken { get; set; }
}
}

View File

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

View File

@@ -0,0 +1,18 @@
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);
return base.SendAsync(request, cancellationToken);
}
}
}

View File

@@ -0,0 +1,17 @@
using Shogi.Contracts.Api;
using Shogi.Contracts.Types;
using System.Net;
namespace Shogi.UI.Pages.Home.Api
{
public interface IShogiApi
{
Task<CreateGuestTokenResponse?> GetGuestToken();
Task<Session?> GetSession(string name);
Task<ReadAllSessionsResponse?> GetSessions();
Task<Guid?> GetToken();
Task GuestLogout();
Task PostMove(string sessionName, Move move);
Task<HttpStatusCode> PostSession(string name, bool isPrivate);
}
}

View File

@@ -0,0 +1,21 @@
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", "https://localhost:5001" },
scopes: new[] { "api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope" },
returnUrl: "https://localhost:3000");
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return base.SendAsync(request, cancellationToken);
}
}
}

View File

@@ -0,0 +1,98 @@
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;
using System.Text.Json.Serialization;
namespace Shogi.UI.Pages.Home.Api
{
public class ShogiApi : IShogiApi
{
public const string GuestClientName = "Guest";
public const string MsalClientName = "Msal";
public const string AnonymouseClientName = "Anonymous";
private readonly JsonSerializerOptions serializerOptions;
private readonly IHttpClientFactory clientFactory;
private readonly AccountState accountState;
public ShogiApi(IHttpClientFactory clientFactory, AccountState accountState)
{
serializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};
serializerOptions.Converters.Add(new JsonStringEnumConverter());
this.clientFactory = clientFactory;
this.accountState = accountState;
}
private HttpClient HttpClient => accountState.User?.WhichAccountPlatform switch
{
WhichAccountPlatform.Guest => clientFactory.CreateClient(GuestClientName),
WhichAccountPlatform.Microsoft => clientFactory.CreateClient(MsalClientName),
_ => clientFactory.CreateClient(AnonymouseClientName)
};
public async Task<CreateGuestTokenResponse?> GetGuestToken()
{
var response = await HttpClient.GetAsync(new Uri("User/GuestToken", UriKind.Relative));
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<CreateGuestTokenResponse>(serializerOptions);
}
return null;
}
public async Task GuestLogout()
{
var response = await HttpClient.PutAsync(new Uri("User/GuestLogout", UriKind.Relative), null);
response.EnsureSuccessStatusCode();
}
public async Task<Session?> GetSession(string name)
{
var response = await HttpClient.GetAsync(new Uri($"Session/{name}", UriKind.Relative));
if (response.IsSuccessStatusCode)
{
return (await response.Content.ReadFromJsonAsync<ReadSessionResponse>(serializerOptions))?.Session;
}
return null;
}
public async Task<ReadAllSessionsResponse?> GetSessions()
{
var response = await HttpClient.GetAsync(new Uri("Session", UriKind.Relative));
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<ReadAllSessionsResponse>(serializerOptions);
}
return null;
}
public async Task<Guid?> GetToken()
{
var response = await HttpClient.GetAsync(new Uri("User/Token", UriKind.Relative));
var deserialized = await response.Content.ReadFromJsonAsync<CreateTokenResponse>(serializerOptions);
return deserialized?.OneTimeToken;
}
public async Task PostMove(string sessionName, Contracts.Types.Move move)
{
await this.HttpClient.PostAsJsonAsync($"{sessionName}/Move", new MovePieceCommand { Move = move });
}
public async Task<HttpStatusCode> PostSession(string name, bool isPrivate)
{
var response = await HttpClient.PostAsJsonAsync(new Uri("Session", UriKind.Relative), new CreateSessionCommand
{
Name = name,
IsPrivate = isPrivate
});
return response.StatusCode;
}
}
}

View File

@@ -0,0 +1,62 @@
@using Shogi.Contracts.Types;
@inject IShogiApi ShogiApi
@inject AccountState Account;
<section class="game-board" data-perspective="@Perspective">
@for (var rank = 9; rank > 0; rank--)
{
foreach (var file in Files)
{
var position = $"{file}{rank}";
var piece = session?.BoardState.Board[position];
<div class="tile" data-position="@(position)" style="grid-area: @(position)" @onclick="() => OnClickTile(piece, position)">
<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>
</section>
@code {
[Parameter]
public string? SessionName { get; set; }
static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" };
WhichPlayer Perspective => Account.User?.Id == session?.Player1 ? WhichPlayer.Player1 : WhichPlayer.Player2;
Session? session;
string? selectedPosition;
protected override async Task OnParametersSetAsync()
{
if (!string.IsNullOrWhiteSpace(SessionName))
{
this.session = await ShogiApi.GetSession(SessionName);
}
}
void OnClickTile(Piece? piece, string position)
{
}
}

View File

@@ -0,0 +1,63 @@
.game-board {
background-color: #444;
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, minmax(0, 1fr));
grid-template-rows: repeat(9, minmax(0, 1fr)) auto;
max-height: calc(100vh - 3rem);
width: calc(100vmin * 0.9167);
aspect-ratio: 0.9167;
gap: 3px;
}
.game-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 {
background-color: beige;
display: grid;
place-content: center;
padding: 0.25rem;
}
.ruler {
color: beige;
display: flex;
flex-direction: row;
justify-content: space-around;
}
.ruler.vertical {
flex-direction: column;
}
.game-board[data-perspective="Player2"] .ruler {
flex-direction: row-reverse;
}
.game-board[data-perspective="Player2"] .ruler.vertical {
flex-direction: column-reverse;
}

View File

@@ -0,0 +1,109 @@
@using Shogi.Contracts.Types
@using System.ComponentModel.DataAnnotations
@using System.Net
@inject IShogiApi ShogiApi;
@inject ShogiSocket ShogiSocket;
<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">
<div class="list-group">
@if (!sessions.Any())
{
<p>No games exist</p>
}
@foreach (var session in sessions)
{
<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; }
CreateForm createForm = new();
SessionMetadata[] sessions = Array.Empty<SessionMetadata>();
SessionMetadata? activeSession;
HttpStatusCode? createSessionStatusCode;
protected override async Task OnInitializedAsync()
{
ShogiSocket.OnCreateGameMessage += async (sender, message) => await FetchSessions();
await FetchSessions();
}
string ActiveCss(SessionMetadata s) => s == activeSession ? "active" : string.Empty;
void OnClickSession(SessionMetadata s)
{
activeSession = s;
ActiveSessionChanged?.Invoke(s);
}
async Task FetchSessions()
{
var sessions = await ShogiApi.GetSessions();
if (sessions != null)
{
this.sessions = sessions.PlayerHasJoinedSessions.Concat(sessions.AllOtherSessions).ToArray();
}
}
async Task CreateSession()
{
createSessionStatusCode = await ShogiApi.PostSession(createForm.Name, createForm.IsPrivate);
}
private class CreateForm
{
[Required]
public string Name { get; set; } = string.Empty;
public bool IsPrivate { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
.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

@@ -0,0 +1,44 @@
@using Shogi.Contracts.Types
<div data-upsidedown="@(Piece?.Owner != Perspective)" data-owner="@Piece?.Owner.ToString()">
@switch (Piece?.WhichPiece)
{
case WhichPiece.Bishop:
<Bishop IsPromoted="@IsPromoted" />
break;
case WhichPiece.GoldGeneral:
<GoldGeneral />
break;
case WhichPiece.King:
<ChallengingKing />
break;
case WhichPiece.Knight:
<Knight IsPromoted="@IsPromoted" />
break;
case WhichPiece.Lance:
<Lance IsPromoted="@IsPromoted" />
break;
case WhichPiece.Pawn:
<Pawn IsPromoted="@IsPromoted" />
break;
case WhichPiece.Rook:
<Rook IsPromoted="@IsPromoted" />
break;
case WhichPiece.SilverGeneral:
<SilverGeneral IsPromoted="@IsPromoted" />
break;
default:
@*render nothing*@
break;
}
</div>
@code {
[Parameter]
public Contracts.Types.Piece? Piece { get; set; }
[Parameter]
public WhichPlayer Perspective { get; set; }
private bool IsPromoted => Piece != null && Piece.IsPromoted;
}

View File

@@ -0,0 +1,8 @@
::deep svg {
max-width: 100%;
max-height: 100%;
}
[data-upsidedown] {
transform: rotateZ(180deg);
}

View File

@@ -0,0 +1,46 @@
@page "/"
@using Shogi.Contracts.Types
@using System.Net.WebSockets
@using System.Text
@inject ModalService modalService
@inject AccountManager AccountManager
@inject AccountState Account
@*<Modals />*@
<main class="shogi">
@if (welcomeModalIsVisible)
{
<LoginModal />
}
<PageHeader />
<GameBrowser ActiveSessionChanged="OnChangeSession" />
<GameBoard SessionName="@activeSessionName" />
</main>
@code {
bool welcomeModalIsVisible = false;
string activeSessionName = string.Empty;
ClientWebSocket socket = new ClientWebSocket();
protected override async Task OnInitializedAsync()
{
Account.LoginChangedEvent += OnLoginChanged;
var success = await AccountManager.TryLoginSilentAsync();
if (!success)
{
welcomeModalIsVisible = true;
}
}
private void OnLoginChanged(object? sender, LoginEventArgs args)
{
welcomeModalIsVisible = args.User == null;
StateHasChanged();
}
private void OnChangeSession(SessionMetadata s)
{
activeSessionName = s.Name;
StateHasChanged();
}
}

View File

@@ -0,0 +1,30 @@
.shogi {
display: grid;
grid-template-areas:
"pageHeader board"
"browser board";
grid-template-columns: minmax(25rem, 25vw) 1fr;
grid-template-rows: max-content 1fr;
place-items: stretch;
gap: 1rem;
padding: 0.5rem;
position: relative; /* For absolute positioned children. */
background-color: var(--primary-color);
}
.shogi > ::deep .game-board {
grid-area: board;
place-self: center;
}
.shogi > ::deep .pageHeader {
grid-area: pageHeader;
}
.shogi > ::deep .game-browser {
grid-area: browser;
}
Modals {
display: none;
}

View File

@@ -0,0 +1,56 @@
@inject AccountManager Account
<div class="my-modal-background">
<div class="my-modal">
@if (guestAccountDescriptionIsVisible)
{
<h1>What&apos;s the difference?</h1>
@*<div class="account-description mb-4 bg-light p-2">
<h4>Feature</h4>
<h4>Guest Accounts</h4>
<h4>Email Accounts</h4>
<div>Resume in-progress games from any browser on any device.</div>
<span class="oi oi-circle-x" title="circle-x" aria-hidden="true"></span>
<span class="oi oi-circle-check" title="circle-check" aria-hidden="true"></span>
</div>*@
<p>
Guest accounts are session based, meaning that the account lives exclusively within the device and browser you create the account on.
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="Account.LoginWithMicrosoftAccount">Log in</button>
<button @onclick="Account.LoginWithGuestAccount">Proceed as Guest</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

@@ -0,0 +1,21 @@
.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

@@ -0,0 +1,31 @@
@inject AccountState Account
@inject AccountManager AccountManager
<div class="pageHeader">
<h1>Shogi</h1>
@if (user != null)
{
<div class="user">
<div>@user.DisplayName</div>
<button type="button" class="logout" @onclick="AccountManager.LogoutAsync">Logout</button>
</div>
}
@*<LoginDisplay />*@
</div>
@code {
private User? user;
protected override void OnInitialized()
{
Account.LoginChangedEvent += OnLoginChange;
}
private void OnLoginChange(object? sender, LoginEventArgs args)
{
if (args == null)
throw new ArgumentException(nameof(args));
user = args.User;
StateHasChanged();
}
}

View File

@@ -0,0 +1,20 @@
.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;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

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

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long