897 lines
35 KiB
Plaintext
897 lines
35 KiB
Plaintext
@page "/application"
|
|
@page "/application.html"
|
|
@using System.Net
|
|
@using System.Net.Http.Json
|
|
@using System.Security.Claims
|
|
@using ChessCubing.App.Models.Users
|
|
@implements IDisposable
|
|
@inject BrowserBridge Browser
|
|
@inject MatchStore Store
|
|
@inject NavigationManager Navigation
|
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
|
@inject HttpClient Http
|
|
@inject SocialRealtimeService Realtime
|
|
|
|
<PageTitle>ChessCubing Arena | Application</PageTitle>
|
|
<PageBody Page="setup" BodyClass="@SetupBodyClass" />
|
|
|
|
<div class="ambient ambient-left"></div>
|
|
<div class="ambient ambient-right"></div>
|
|
|
|
<div class="setup-shell">
|
|
<header class="hero hero-setup">
|
|
<div class="hero-copy">
|
|
<a class="logo-lockup" href="index.html" aria-label="Accueil ChessCubing">
|
|
<img class="hero-logo-icon" src="logo.png" alt="Icone ChessCubing" />
|
|
<img class="hero-logo" src="transparent.png" alt="Logo ChessCubing" />
|
|
</a>
|
|
<p class="eyebrow">Application officielle de match</p>
|
|
<h1>ChessCubing Arena</h1>
|
|
<div class="hero-actions">
|
|
<a class="button ghost" href="index.html">Accueil du site</a>
|
|
<a class="button secondary" href="reglement.html">Consulter le reglement</a>
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="hero-preview">
|
|
<div class="preview-card">
|
|
<p class="micro-label">Flux de match</p>
|
|
<ol class="phase-list">
|
|
<li>Configurer la rencontre</li>
|
|
<li>Passer a la page chrono</li>
|
|
<li>Basculer automatiquement sur la page cube</li>
|
|
<li>Revenir sur la page chrono pour le Block suivant</li>
|
|
</ol>
|
|
</div>
|
|
</aside>
|
|
</header>
|
|
|
|
<main class="setup-grid">
|
|
<section class="panel">
|
|
<div class="section-heading">
|
|
<div>
|
|
<p class="eyebrow">Nouvelle rencontre</p>
|
|
<h2>Configuration</h2>
|
|
</div>
|
|
<p class="section-copy">
|
|
Les reglages ci-dessous preparent les pages chrono et cube.
|
|
</p>
|
|
</div>
|
|
|
|
<form class="setup-form" @onsubmit="HandleSubmit" @onsubmit:preventDefault="true" novalidate>
|
|
<label class="option-card competition-option span-2 @(Form.CompetitionMode ? "is-selected" : string.Empty)">
|
|
<input @bind="Form.CompetitionMode" id="competitionMode" name="competitionMode" type="checkbox" />
|
|
<strong>Mode competition</strong>
|
|
<span>
|
|
Affiche le nom de la rencontre, l'arbitre, le club ou l'evenement
|
|
et les notes d'organisation.
|
|
</span>
|
|
</label>
|
|
|
|
@if (Form.CompetitionMode)
|
|
{
|
|
<label class="field span-2" id="matchLabelField">
|
|
<span>Nom de la rencontre</span>
|
|
<input @bind="Form.MatchLabel" @bind:event="oninput" name="matchLabel" type="text" maxlength="80" placeholder="Open ChessCubing de Paris" />
|
|
</label>
|
|
}
|
|
|
|
<fieldset class="field span-2">
|
|
<legend>Mode officiel</legend>
|
|
<div class="option-grid mode-grid">
|
|
<label class="option-card @(Form.Mode == MatchEngine.ModeTwice ? "is-selected" : string.Empty)">
|
|
<input type="radio" name="mode" checked="@(Form.Mode == MatchEngine.ModeTwice)" @onchange="() => SetMode(MatchEngine.ModeTwice)" />
|
|
<strong>ChessCubing Twice</strong>
|
|
<span>
|
|
Le gagnant du cube ouvre le Block suivant et peut obtenir un
|
|
double coup V2.
|
|
</span>
|
|
</label>
|
|
<label class="option-card @(Form.Mode == MatchEngine.ModeTime ? "is-selected" : string.Empty)">
|
|
<input type="radio" name="mode" checked="@(Form.Mode == MatchEngine.ModeTime)" @onchange="() => SetMode(MatchEngine.ModeTime)" />
|
|
<strong>ChessCubing Time</strong>
|
|
<span>
|
|
Meme structure de Blocks, avec chronos cumules et alternance
|
|
bloc - / bloc +.
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="field span-2">
|
|
<legend>Cadence du match</legend>
|
|
<div class="option-grid preset-grid">
|
|
<label class="option-card @(Form.Preset == MatchEngine.PresetFast ? "is-selected" : string.Empty)">
|
|
<input type="radio" name="preset" checked="@(Form.Preset == MatchEngine.PresetFast)" @onchange="() => SetPreset(MatchEngine.PresetFast)" />
|
|
<strong>FAST</strong>
|
|
<span>6 coups par joueur</span>
|
|
</label>
|
|
<label class="option-card @(Form.Preset == MatchEngine.PresetFreeze ? "is-selected" : string.Empty)">
|
|
<input type="radio" name="preset" checked="@(Form.Preset == MatchEngine.PresetFreeze)" @onchange="() => SetPreset(MatchEngine.PresetFreeze)" />
|
|
<strong>FREEZE</strong>
|
|
<span>8 coups par joueur</span>
|
|
</label>
|
|
<label class="option-card @(Form.Preset == MatchEngine.PresetMasters ? "is-selected" : string.Empty)">
|
|
<input type="radio" name="preset" checked="@(Form.Preset == MatchEngine.PresetMasters)" @onchange="() => SetPreset(MatchEngine.PresetMasters)" />
|
|
<strong>MASTERS</strong>
|
|
<span>10 coups par joueur</span>
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="field span-2">
|
|
<legend>Temps personnalises</legend>
|
|
<div class="timing-grid">
|
|
<label class="field">
|
|
<span>Temps du Block (minutes)</span>
|
|
<input @bind="Form.BlockMinutes" name="blockMinutes" type="number" min="1" max="180" step="1" />
|
|
</label>
|
|
|
|
@if (!UsesMoveLimit)
|
|
{
|
|
<label class="field" id="timeInitialField">
|
|
<span>Temps de chaque joueur (minutes)</span>
|
|
<input @bind="Form.TimeInitialMinutes" name="timeInitialMinutes" type="number" min="1" max="180" step="1" />
|
|
</label>
|
|
}
|
|
|
|
@if (UsesMoveLimit)
|
|
{
|
|
<label class="field" id="moveSecondsField">
|
|
<span>Temps coup (secondes)</span>
|
|
<input @bind="Form.MoveSeconds" name="moveSeconds" type="number" min="5" max="300" step="1" />
|
|
</label>
|
|
}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<label class="field player-name-field">
|
|
<div class="field-heading">
|
|
<span class="field-label-text">Joueur blanc</span>
|
|
|
|
@if (CanUseConnectedPlayerName)
|
|
{
|
|
<button class="button ghost small icon-button player-fill-button"
|
|
type="button"
|
|
title="@BuildPrefillTitle("blanc")"
|
|
aria-label="@BuildPrefillTitle("blanc")"
|
|
@onclick="FillWhiteWithConnectedPlayer">
|
|
<span class="material-icons action-icon" aria-hidden="true">account_circle</span>
|
|
</button>
|
|
}
|
|
</div>
|
|
<input @bind="WhitePlayerName" @bind:event="oninput" name="whiteName" type="text" maxlength="40" placeholder="Blanc" />
|
|
</label>
|
|
|
|
<label class="field player-name-field">
|
|
<div class="field-heading">
|
|
<span class="field-label-text">Joueur noir</span>
|
|
|
|
@if (CanUseConnectedPlayerName)
|
|
{
|
|
<button class="button ghost small icon-button player-fill-button"
|
|
type="button"
|
|
title="@BuildPrefillTitle("noir")"
|
|
aria-label="@BuildPrefillTitle("noir")"
|
|
@onclick="FillBlackWithConnectedPlayer">
|
|
<span class="material-icons action-icon" aria-hidden="true">account_circle</span>
|
|
</button>
|
|
}
|
|
</div>
|
|
<input @bind="BlackPlayerName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" />
|
|
</label>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(SetupError))
|
|
{
|
|
<p class="profile-feedback error span-2">@SetupError</p>
|
|
}
|
|
|
|
@if (IsAuthenticated)
|
|
{
|
|
<section class="setup-social-card span-2">
|
|
<div class="social-card-head">
|
|
<div>
|
|
<span class="micro-label">Amis connectes</span>
|
|
<strong>Inviter un ami a jouer</strong>
|
|
</div>
|
|
|
|
<button class="button ghost small icon-button" type="button" title="Rafraichir les amis" aria-label="Rafraichir les amis" @onclick="LoadSocialOverviewAsync">
|
|
<span class="material-icons action-icon" aria-hidden="true">refresh</span>
|
|
</button>
|
|
</div>
|
|
|
|
<p class="social-empty">
|
|
Choisis rapidement un ami en ligne et decide de quel cote il doit etre pre-rempli.
|
|
</p>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(SocialLoadError))
|
|
{
|
|
<p class="profile-feedback error">@SocialLoadError</p>
|
|
}
|
|
|
|
@if (!string.IsNullOrWhiteSpace(InviteActionError))
|
|
{
|
|
<p class="profile-feedback error">@InviteActionError</p>
|
|
}
|
|
|
|
@if (!string.IsNullOrWhiteSpace(Realtime.LastInviteNotice))
|
|
{
|
|
<div class="social-inline-feedback">
|
|
<span>@Realtime.LastInviteNotice</span>
|
|
<button class="button ghost small" type="button" @onclick="Realtime.ClearInviteNotice">Masquer</button>
|
|
</div>
|
|
}
|
|
|
|
@if (Realtime.OutgoingPlayInvite is not null)
|
|
{
|
|
<div class="play-invite-pending">
|
|
<div>
|
|
<span class="micro-label">En attente</span>
|
|
<strong>@Realtime.OutgoingPlayInvite.RecipientDisplayName</strong>
|
|
<p>Invitation envoyee pour jouer cote @(BuildColorLabel(Realtime.OutgoingPlayInvite.RecipientColor)).</p>
|
|
</div>
|
|
<button class="button ghost small" type="button" @onclick="CancelOutgoingPlayInviteAsync">
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
@if (IsSocialLoading)
|
|
{
|
|
<p class="social-empty">Chargement des amis connectes...</p>
|
|
}
|
|
else if (OnlineFriends.Length > 0)
|
|
{
|
|
<div class="play-friends-list">
|
|
@foreach (var friend in OnlineFriends)
|
|
{
|
|
<article class="play-friend-item">
|
|
<div class="social-item-meta">
|
|
<div class="social-item-title-row">
|
|
<strong>@friend.DisplayName</strong>
|
|
<span class="@BuildPresenceClass(friend.Subject, friend.IsOnline)">@BuildPresenceLabel(friend.Subject, friend.IsOnline)</span>
|
|
</div>
|
|
<span class="social-item-subtitle">@friend.Username</span>
|
|
</div>
|
|
|
|
<div class="play-friend-actions">
|
|
<button class="button ghost small" type="button" disabled="@(Realtime.OutgoingPlayInvite is not null)" @onclick="() => InviteFriendAsWhiteAsync(friend.Subject)">
|
|
Ami blanc
|
|
</button>
|
|
<button class="button ghost small" type="button" disabled="@(Realtime.OutgoingPlayInvite is not null)" @onclick="() => InviteFriendAsBlackAsync(friend.Subject)">
|
|
Ami noir
|
|
</button>
|
|
</div>
|
|
</article>
|
|
}
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<p class="social-empty">Aucun ami connecte. Gere tes amis sur la page utilisateur puis attends qu'ils se connectent.</p>
|
|
}
|
|
</section>
|
|
}
|
|
|
|
@if (Form.CompetitionMode)
|
|
{
|
|
<label class="field" id="arbiterField">
|
|
<span>Arbitre</span>
|
|
<input @bind="Form.ArbiterName" @bind:event="oninput" name="arbiterName" type="text" maxlength="40" placeholder="Arbitre principal" />
|
|
</label>
|
|
|
|
<label class="field" id="eventField">
|
|
<span>Club / evenement</span>
|
|
<input @bind="Form.EventName" @bind:event="oninput" name="eventName" type="text" maxlength="60" placeholder="Club, tournoi, demonstration" />
|
|
</label>
|
|
|
|
<label class="field span-2" id="notesField">
|
|
<span>Notes</span>
|
|
<textarea @bind="Form.Notes" @bind:event="oninput" name="notes" rows="3" placeholder="Tirage au sort effectue, 8 cubes verifies, variante prete..."></textarea>
|
|
</label>
|
|
}
|
|
|
|
<div id="setupSummary" class="setup-summary span-2">
|
|
<strong>@CurrentMode.Label</strong>
|
|
<span>@CurrentPreset.Description</span>
|
|
<span>@TimingText</span>
|
|
<span>@TimeImpact</span>
|
|
<span>@QuotaText</span>
|
|
</div>
|
|
|
|
<div class="setup-actions span-2">
|
|
<button class="button primary" type="button" @onclick="HandleSubmit">Ouvrir la page chrono</button>
|
|
<button class="button secondary" id="loadDemoButton" type="button" @onclick="LoadDemo">Charger une demo</button>
|
|
<a class="button ghost" href="reglement.html">Consulter le reglement</a>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<aside class="panel side-panel">
|
|
<div class="section-heading">
|
|
<div>
|
|
<p class="eyebrow">Match en memoire</p>
|
|
<h2>Reprise rapide</h2>
|
|
</div>
|
|
</div>
|
|
|
|
@if (!_ready || CurrentMatch is null)
|
|
{
|
|
<div id="resumeCard" class="resume-card empty">
|
|
<p>Aucun match en cours pour l'instant.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div id="resumeCard" class="resume-card">
|
|
<strong>@ResumeMatchLabel(CurrentMatch)</strong>
|
|
<p>@CurrentMatchMode(CurrentMatch)</p>
|
|
<p>@($"{CurrentMatch.Config.WhiteName} vs {CurrentMatch.Config.BlackName}")</p>
|
|
<p>@ResumePhaseLabel(CurrentMatch)</p>
|
|
<div class="resume-actions">
|
|
<button class="button primary" id="resumeMatchButton" type="button" @onclick="ResumeMatch">
|
|
@(string.IsNullOrEmpty(CurrentMatch.Result) ? "Reprendre la phase" : "Voir le match")
|
|
</button>
|
|
<button class="button ghost" id="clearMatchButton" type="button" @onclick="ClearMatchAsync">Effacer le match</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div class="rules-stack">
|
|
<article class="rule-card">
|
|
<span class="micro-label">Page chrono</span>
|
|
<strong>Gros boutons uniquement</strong>
|
|
<p>
|
|
Chaque joueur dispose d'une grande zone tactile pour signaler la
|
|
fin de son coup, puis l'app ouvre automatiquement la phase cube
|
|
quand le Block d'echecs est termine.
|
|
</p>
|
|
</article>
|
|
<article class="rule-card">
|
|
<span class="micro-label">Page cube</span>
|
|
<strong>Une page dediee</strong>
|
|
<p>
|
|
Les deux joueurs lancent et arretent leur propre chrono cube sur
|
|
un ecran separe, toujours en face-a-face sur mobile.
|
|
</p>
|
|
</article>
|
|
<article class="rule-card">
|
|
<span class="micro-label">Sources</span>
|
|
<strong>Reglements integres</strong>
|
|
<p>
|
|
<a href="reglement.html">Page reglement du site</a>
|
|
<br />
|
|
<a href="ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" target="_blank">Reglement ChessCubing Twice</a>
|
|
<br />
|
|
<a href="ChessCubing_Time_Reglement_Officiel_V1-1.pdf" target="_blank">Reglement ChessCubing Time</a>
|
|
</p>
|
|
</article>
|
|
</div>
|
|
</aside>
|
|
</main>
|
|
|
|
<div class="setup-refresh-footer">
|
|
<button class="refresh-link-button" id="refreshAppButton" type="button" @onclick="ForceRefreshAsync">
|
|
Rafraichir l'app
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
@code {
|
|
private SetupFormModel Form { get; set; } = new();
|
|
private bool _ready;
|
|
private bool IsAuthenticated;
|
|
private bool IsSocialLoading;
|
|
private int _knownSocialVersion;
|
|
private long _knownCollaborativeRevision;
|
|
private string? _appliedActiveSessionId;
|
|
private string? ConnectedPlayerName;
|
|
private string? SetupError;
|
|
private string? SocialLoadError;
|
|
private string? InviteActionError;
|
|
private SocialOverviewResponse? SocialOverview;
|
|
|
|
private MatchState? CurrentMatch => Store.Current;
|
|
|
|
private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode";
|
|
private bool CanUseConnectedPlayerName => !string.IsNullOrWhiteSpace(ConnectedPlayerName);
|
|
private string WhitePlayerName
|
|
{
|
|
get => Form.WhiteName;
|
|
set
|
|
{
|
|
Form.WhiteName = value;
|
|
SetupError = null;
|
|
}
|
|
}
|
|
|
|
private string BlackPlayerName
|
|
{
|
|
get => Form.BlackName;
|
|
set
|
|
{
|
|
Form.BlackName = value;
|
|
SetupError = null;
|
|
}
|
|
}
|
|
|
|
private SocialFriendResponse[] OnlineFriends
|
|
=> SocialOverview?.Friends
|
|
.Where(friend => ResolveOnlineStatus(friend.Subject, friend.IsOnline))
|
|
.OrderBy(friend => friend.DisplayName, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray()
|
|
?? [];
|
|
|
|
private bool UsesMoveLimit => MatchEngine.UsesMoveLimit(Form.Mode);
|
|
|
|
private MatchPresetInfo CurrentPreset =>
|
|
MatchEngine.Presets.TryGetValue(Form.Preset, out var preset)
|
|
? preset
|
|
: MatchEngine.Presets[MatchEngine.PresetFast];
|
|
|
|
private MatchModeInfo CurrentMode =>
|
|
MatchEngine.Modes.TryGetValue(Form.Mode, out var mode)
|
|
? mode
|
|
: MatchEngine.Modes[MatchEngine.ModeTwice];
|
|
|
|
private string TimingText =>
|
|
UsesMoveLimit
|
|
? $"Temps configures : Block {MatchEngine.FormatClock(Form.BlockMinutes * 60_000L)}, coup {MatchEngine.FormatClock(Form.MoveSeconds * 1_000L)}."
|
|
: $"Temps configures : Block {MatchEngine.FormatClock(Form.BlockMinutes * 60_000L)}, temps de chaque joueur {MatchEngine.FormatClock(Form.TimeInitialMinutes * 60_000L)}.";
|
|
|
|
private string TimeImpact =>
|
|
Form.Mode == MatchEngine.ModeTime
|
|
? $"Chronos cumules de {MatchEngine.FormatClock(Form.TimeInitialMinutes * 60_000L)} par joueur, ajustes apres chaque phase cube avec plafond de 120 s pris en compte. Aucun temps par coup en mode Time."
|
|
: "Le gagnant du cube commence le Block suivant, avec double coup V2 possible.";
|
|
|
|
private string QuotaText =>
|
|
UsesMoveLimit
|
|
? $"Quota actif : {CurrentPreset.Quota} coups par joueur."
|
|
: $"Quota actif : {CurrentPreset.Quota} coups par joueur et par Block.";
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
|
Realtime.Changed += HandleRealtimeChanged;
|
|
await Realtime.EnsureStartedAsync();
|
|
await LoadApplicationContextAsync();
|
|
}
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
if (!firstRender)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await Store.EnsureLoadedAsync();
|
|
if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId))
|
|
{
|
|
await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId);
|
|
}
|
|
|
|
_ready = true;
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
|
=> _ = InvokeAsync(LoadApplicationContextAsync);
|
|
|
|
private void HandleRealtimeChanged()
|
|
=> _ = InvokeAsync(HandleRealtimeChangedAsync);
|
|
|
|
private async Task HandleRealtimeChangedAsync()
|
|
{
|
|
ApplyAcceptedPlaySession();
|
|
await ApplyCollaborativeSyncAsync();
|
|
|
|
if (IsAuthenticated && _knownSocialVersion != Realtime.SocialVersion)
|
|
{
|
|
_knownSocialVersion = Realtime.SocialVersion;
|
|
await LoadSocialOverviewAsync();
|
|
return;
|
|
}
|
|
|
|
StateHasChanged();
|
|
}
|
|
|
|
private async Task LoadApplicationContextAsync()
|
|
{
|
|
string? fallbackName = null;
|
|
|
|
try
|
|
{
|
|
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
|
var user = authState.User;
|
|
|
|
if (user.Identity?.IsAuthenticated != true)
|
|
{
|
|
IsAuthenticated = false;
|
|
ConnectedPlayerName = null;
|
|
ResetSocialState();
|
|
await InvokeAsync(StateHasChanged);
|
|
return;
|
|
}
|
|
|
|
IsAuthenticated = true;
|
|
fallbackName = BuildConnectedPlayerFallback(user);
|
|
|
|
var response = await Http.GetAsync("api/users/me");
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
|
{
|
|
IsAuthenticated = false;
|
|
ConnectedPlayerName = null;
|
|
ResetSocialState();
|
|
await InvokeAsync(StateHasChanged);
|
|
return;
|
|
}
|
|
|
|
ConnectedPlayerName = fallbackName;
|
|
await LoadSocialOverviewAsync();
|
|
|
|
await InvokeAsync(StateHasChanged);
|
|
return;
|
|
}
|
|
|
|
var profile = await response.Content.ReadFromJsonAsync<UserProfileResponse>();
|
|
ConnectedPlayerName = !string.IsNullOrWhiteSpace(profile?.DisplayName)
|
|
? profile.DisplayName
|
|
: fallbackName;
|
|
await LoadSocialOverviewAsync();
|
|
}
|
|
catch
|
|
{
|
|
ConnectedPlayerName = fallbackName;
|
|
}
|
|
|
|
ApplyAcceptedPlaySession();
|
|
await ApplyCollaborativeSyncAsync();
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
private async Task LoadSocialOverviewAsync()
|
|
{
|
|
if (!IsAuthenticated)
|
|
{
|
|
ResetSocialState();
|
|
return;
|
|
}
|
|
|
|
IsSocialLoading = true;
|
|
SocialLoadError = null;
|
|
|
|
try
|
|
{
|
|
var response = await Http.GetAsync("api/social/overview");
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
SocialLoadError = response.StatusCode switch
|
|
{
|
|
HttpStatusCode.Unauthorized => "La session a expire. Reconnecte-toi puis recharge la page.",
|
|
_ => "Le chargement des amis a echoue.",
|
|
};
|
|
SocialOverview = null;
|
|
return;
|
|
}
|
|
|
|
SocialOverview = await response.Content.ReadFromJsonAsync<SocialOverviewResponse>() ?? new SocialOverviewResponse();
|
|
_knownSocialVersion = Realtime.SocialVersion;
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
SocialLoadError = "Le service social est temporairement indisponible.";
|
|
SocialOverview = null;
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
SocialLoadError = "Le chargement des amis a pris trop de temps.";
|
|
SocialOverview = null;
|
|
}
|
|
finally
|
|
{
|
|
IsSocialLoading = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private async Task HandleSubmit()
|
|
{
|
|
SetupError = ValidateDistinctPlayers();
|
|
if (!string.IsNullOrWhiteSpace(SetupError))
|
|
{
|
|
StateHasChanged();
|
|
return;
|
|
}
|
|
|
|
await Store.EnsureLoadedAsync();
|
|
var match = MatchEngine.CreateMatch(Form.ToMatchConfig());
|
|
if (!string.IsNullOrWhiteSpace(Realtime.ActivePlaySession?.SessionId))
|
|
{
|
|
match.CollaborationSessionId = Realtime.ActivePlaySession.SessionId;
|
|
await Realtime.EnsureJoinedPlaySessionAsync(match.CollaborationSessionId);
|
|
}
|
|
|
|
Store.SetCurrent(match);
|
|
|
|
try
|
|
{
|
|
await Store.SaveAsync();
|
|
await Realtime.PublishMatchStateAsync(match, "/chrono.html");
|
|
}
|
|
catch
|
|
{
|
|
// La navigation vers la phase chrono doit rester possible meme si la persistence
|
|
// du navigateur echoue ponctuellement.
|
|
}
|
|
|
|
Navigation.NavigateTo("/chrono.html");
|
|
}
|
|
|
|
private void LoadDemo()
|
|
{
|
|
Form = SetupFormModel.CreateDemo();
|
|
SetupError = null;
|
|
}
|
|
|
|
private void FillWhiteWithConnectedPlayer()
|
|
{
|
|
if (!CanUseConnectedPlayerName)
|
|
{
|
|
return;
|
|
}
|
|
|
|
AssignConnectedPlayerToWhite();
|
|
}
|
|
|
|
private void FillBlackWithConnectedPlayer()
|
|
{
|
|
if (!CanUseConnectedPlayerName)
|
|
{
|
|
return;
|
|
}
|
|
|
|
AssignConnectedPlayerToBlack();
|
|
}
|
|
|
|
private async Task InviteFriendToPlayAsync(string friendSubject, string recipientColor)
|
|
{
|
|
InviteActionError = null;
|
|
|
|
try
|
|
{
|
|
await Realtime.SendPlayInviteAsync(friendSubject, recipientColor);
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
InviteActionError = exception.Message;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
InviteActionError = string.IsNullOrWhiteSpace(exception.Message)
|
|
? "L'invitation de partie n'a pas pu etre envoyee."
|
|
: exception.Message;
|
|
}
|
|
}
|
|
|
|
private async Task CancelOutgoingPlayInviteAsync()
|
|
{
|
|
InviteActionError = null;
|
|
|
|
try
|
|
{
|
|
await Realtime.CancelOutgoingPlayInviteAsync();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
InviteActionError = string.IsNullOrWhiteSpace(exception.Message)
|
|
? "L'invitation de partie n'a pas pu etre annulee."
|
|
: exception.Message;
|
|
}
|
|
}
|
|
|
|
private Task InviteFriendAsWhiteAsync(string friendSubject)
|
|
=> InviteFriendToPlayAsync(friendSubject, "white");
|
|
|
|
private Task InviteFriendAsBlackAsync(string friendSubject)
|
|
=> InviteFriendToPlayAsync(friendSubject, "black");
|
|
|
|
private void SetMode(string mode)
|
|
=> Form.Mode = mode;
|
|
|
|
private void SetPreset(string preset)
|
|
=> Form.Preset = preset;
|
|
|
|
private void ResumeMatch()
|
|
{
|
|
if (CurrentMatch is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Navigation.NavigateTo(MatchEngine.RouteForMatch(CurrentMatch));
|
|
}
|
|
|
|
private async Task ClearMatchAsync()
|
|
{
|
|
var sessionId = CurrentMatch?.CollaborationSessionId;
|
|
await Store.ClearAsync();
|
|
|
|
if (!string.IsNullOrWhiteSpace(sessionId))
|
|
{
|
|
await Realtime.PublishMatchStateAsync(null, "/application.html");
|
|
}
|
|
|
|
StateHasChanged();
|
|
}
|
|
|
|
private Task ForceRefreshAsync()
|
|
=> Browser.ForceRefreshAsync("/application.html").AsTask();
|
|
|
|
private static string ResumeMatchLabel(MatchState match)
|
|
=> string.IsNullOrWhiteSpace(match.Config.MatchLabel) ? "Rencontre ChessCubing" : match.Config.MatchLabel;
|
|
|
|
private static string CurrentMatchMode(MatchState match)
|
|
=> MatchEngine.Modes.TryGetValue(match.Config.Mode, out var mode) ? mode.Label : "ChessCubing Twice";
|
|
|
|
private static string ResumePhaseLabel(MatchState match)
|
|
{
|
|
if (!string.IsNullOrEmpty(match.Result))
|
|
{
|
|
return MatchEngine.ResultText(match);
|
|
}
|
|
|
|
return match.Phase == MatchEngine.PhaseCube ? "Page cube prete" : "Page chrono prete";
|
|
}
|
|
|
|
private string BuildPrefillTitle(string color)
|
|
=> $"Utiliser mon nom cote {color}";
|
|
|
|
private void ApplyAcceptedPlaySession()
|
|
{
|
|
var session = Realtime.ActivePlaySession;
|
|
if (session is null || string.Equals(_appliedActiveSessionId, session.SessionId, StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Form.WhiteName = session.WhiteName;
|
|
Form.BlackName = session.BlackName;
|
|
SetupError = null;
|
|
_appliedActiveSessionId = session.SessionId;
|
|
}
|
|
|
|
private void AssignConnectedPlayerToWhite()
|
|
{
|
|
if (!CanUseConnectedPlayerName)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var connectedName = ConnectedPlayerName!;
|
|
Form.WhiteName = connectedName;
|
|
|
|
if (SamePlayerName(Form.BlackName, connectedName))
|
|
{
|
|
Form.BlackName = "Noir";
|
|
}
|
|
|
|
SetupError = null;
|
|
}
|
|
|
|
private void AssignConnectedPlayerToBlack()
|
|
{
|
|
if (!CanUseConnectedPlayerName)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var connectedName = ConnectedPlayerName!;
|
|
Form.BlackName = connectedName;
|
|
|
|
if (SamePlayerName(Form.WhiteName, connectedName))
|
|
{
|
|
Form.WhiteName = "Blanc";
|
|
}
|
|
|
|
SetupError = null;
|
|
}
|
|
|
|
private string? ValidateDistinctPlayers()
|
|
{
|
|
var whiteName = NormalizePlayerName(Form.WhiteName, "Blanc");
|
|
var blackName = NormalizePlayerName(Form.BlackName, "Noir");
|
|
|
|
return SamePlayerName(whiteName, blackName)
|
|
? "Le meme joueur ne peut pas etre renseigne des deux cotes. Choisis deux noms differents pour Blanc et Noir."
|
|
: null;
|
|
}
|
|
|
|
private async Task ApplyCollaborativeSyncAsync()
|
|
{
|
|
var snapshot = Realtime.CollaborativeSnapshot;
|
|
if (snapshot is null || snapshot.Revision <= _knownCollaborativeRevision)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_knownCollaborativeRevision = snapshot.Revision;
|
|
Store.ReplaceCurrent(snapshot.Match);
|
|
await Store.SaveAsync();
|
|
|
|
if (snapshot.Match is not null)
|
|
{
|
|
Form.WhiteName = snapshot.Match.Config.WhiteName;
|
|
Form.BlackName = snapshot.Match.Config.BlackName;
|
|
SetupError = null;
|
|
}
|
|
|
|
var route = NormalizeRoute(snapshot.Route);
|
|
if (!string.Equals(route, "/application.html", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Navigation.NavigateTo(route, replace: true);
|
|
}
|
|
}
|
|
|
|
private bool ResolveOnlineStatus(string subject, bool fallbackStatus)
|
|
=> Realtime.GetKnownOnlineStatus(subject) ?? fallbackStatus;
|
|
|
|
private string BuildPresenceClass(string subject, bool fallbackStatus)
|
|
=> ResolveOnlineStatus(subject, fallbackStatus)
|
|
? "presence-badge online"
|
|
: "presence-badge";
|
|
|
|
private string BuildPresenceLabel(string subject, bool fallbackStatus)
|
|
=> ResolveOnlineStatus(subject, fallbackStatus)
|
|
? "En ligne"
|
|
: "Hors ligne";
|
|
|
|
private static string BuildColorLabel(string color)
|
|
=> string.Equals(color, "white", StringComparison.OrdinalIgnoreCase)
|
|
? "blanc"
|
|
: "noir";
|
|
|
|
private static string NormalizePlayerName(string? value, string fallback)
|
|
=> MatchEngine.SanitizeText(value) is { Length: > 0 } normalized
|
|
? normalized
|
|
: fallback;
|
|
|
|
private static bool SamePlayerName(string? left, string? right)
|
|
=> string.Equals(
|
|
MatchEngine.SanitizeText(left) ?? string.Empty,
|
|
MatchEngine.SanitizeText(right) ?? string.Empty,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static string NormalizeRoute(string? route)
|
|
{
|
|
var normalized = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim();
|
|
return normalized.StartsWith('/') ? normalized : $"/{normalized}";
|
|
}
|
|
|
|
private void ResetSocialState()
|
|
{
|
|
SocialOverview = null;
|
|
SocialLoadError = null;
|
|
InviteActionError = null;
|
|
_knownSocialVersion = Realtime.SocialVersion;
|
|
_knownCollaborativeRevision = 0;
|
|
}
|
|
|
|
private static string? BuildConnectedPlayerFallback(ClaimsPrincipal user)
|
|
=> FirstNonEmpty(
|
|
user.FindFirst("name")?.Value,
|
|
user.FindFirst(ClaimTypes.Name)?.Value,
|
|
user.FindFirst("preferred_username")?.Value,
|
|
user.FindFirst(ClaimTypes.Email)?.Value);
|
|
|
|
private static string? FirstNonEmpty(params string?[] candidates)
|
|
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
|
|
|
|
public void Dispose()
|
|
{
|
|
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
|
Realtime.Changed -= HandleRealtimeChanged;
|
|
}
|
|
}
|