Compare commits
51 Commits
1f8127641d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 407e5e8ed5 | |||
| db233e7110 | |||
| e0c3a41ccd | |||
| 3b88b9abe6 | |||
| 8ea6ef8424 | |||
| 9aae4cadc0 | |||
| b2cbab7891 | |||
| 0db95ee6ec | |||
| c92df9a8f0 | |||
| 52d5bf4e98 | |||
| 06405fd6a1 | |||
| cd576f941d | |||
| 99a3f6d0aa | |||
| 0e6115e423 | |||
| 655637072c | |||
| 1d18a070e5 | |||
| 106786a638 | |||
| 9fb7d2ce1b | |||
| c23dcc8484 | |||
| 260b839f93 | |||
| 95575bef5f | |||
| d7b743606a | |||
| d36da7c993 | |||
| 5cf46dce31 | |||
| d0f9c76b26 | |||
| 1c5a003f7f | |||
| 9b739b02f6 | |||
| 53f0af761e | |||
| e3d0a9faf2 | |||
| f2fdf00cb9 | |||
| f4bdbf9e2b | |||
| 7efdbc57a8 | |||
| aac7977620 | |||
| 740074c49e | |||
| 7080fa8450 | |||
| c8c3ba2253 | |||
| 525f804e0b | |||
| 1d0a3f551f | |||
| 6c3b9b77c6 | |||
| 6202b8b829 | |||
| 70f693d85b | |||
| cdc8792972 | |||
| 989b61e772 | |||
| 90f17c9c89 | |||
| b11056097d | |||
| 5c53b475b2 | |||
| 0db53a42db | |||
| d68b90c4ea | |||
| 307085711b | |||
| 851e2a40ba | |||
| c8f53bff13 |
@@ -1,3 +1,5 @@
|
|||||||
.git
|
.git
|
||||||
.codex
|
.codex
|
||||||
WhatsApp Video 2026-04-11 at 20.38.50.mp4
|
WhatsApp Video 2026-04-11 at 20.38.50.mp4
|
||||||
|
ChessCubing.App/bin
|
||||||
|
ChessCubing.App/obj
|
||||||
|
|||||||
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
WEB_PORT=8080
|
||||||
|
PUBLIC_BASE_URL=http://localhost:8080
|
||||||
|
KEYCLOAK_DB_NAME=keycloak
|
||||||
|
KEYCLOAK_DB_USER=keycloak
|
||||||
|
KEYCLOAK_DB_PASSWORD=change-me
|
||||||
|
KEYCLOAK_ADMIN_USER=admin
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=change-me
|
||||||
|
SITE_DB_NAME=chesscubing_site
|
||||||
|
SITE_DB_USER=chesscubing
|
||||||
|
SITE_DB_PASSWORD=change-me
|
||||||
|
SITE_DB_ROOT_PASSWORD=change-me
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,2 +1,7 @@
|
|||||||
.codex
|
.codex
|
||||||
|
.env
|
||||||
WhatsApp Video 2026-04-11 at 20.38.50.mp4
|
WhatsApp Video 2026-04-11 at 20.38.50.mp4
|
||||||
|
ChessCubing.App/bin/
|
||||||
|
ChessCubing.App/obj/
|
||||||
|
ChessCubing.Server/bin/
|
||||||
|
ChessCubing.Server/obj/
|
||||||
|
|||||||
8
ChessCubing.App/App.razor
Normal file
8
ChessCubing.App/App.razor
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<CascadingAuthenticationState>
|
||||||
|
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
|
||||||
|
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
|
</CascadingAuthenticationState>
|
||||||
31
ChessCubing.App/ChessCubing.App.csproj
Normal file
31
ChessCubing.App/ChessCubing.App.csproj
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
|
||||||
|
<RootNamespace>ChessCubing.App</RootNamespace>
|
||||||
|
<AssemblyName>ChessCubing.App</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.2" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\favicon.png" Link="wwwroot/favicon.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<Content Include="..\logo.png" Link="wwwroot/logo.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<Content Include="..\transparent.png" Link="wwwroot/transparent.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<Content Include="..\styles.css" Link="wwwroot/styles.css" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<Content Include="..\site.webmanifest" Link="wwwroot/site.webmanifest" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<Content Include="..\ChessCubing_Time_Reglement_Officiel_V1-1.pdf" Link="wwwroot/ChessCubing_Time_Reglement_Officiel_V1-1.pdf" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<Content Include="..\ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" Link="wwwroot/ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<Content Include="..\ethan\**" Link="wwwroot/ethan/%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<Content Include="..\brice\www\**" Link="wwwroot/brice/%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
28
ChessCubing.App/Components/PageBody.razor
Normal file
28
ChessCubing.App/Components/PageBody.razor
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
@inject BrowserBridge Browser
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string? Page { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? BodyClass { get; set; }
|
||||||
|
|
||||||
|
private string? _lastSignature;
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
await Browser.StartViewportAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var signature = $"{Page ?? string.Empty}|{BodyClass ?? string.Empty}";
|
||||||
|
if (signature == _lastSignature)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastSignature = signature;
|
||||||
|
await Browser.SetBodyStateAsync(Page, BodyClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
ChessCubing.App/Components/PlayInviteOverlay.razor
Normal file
61
ChessCubing.App/Components/PlayInviteOverlay.razor
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
@implements IDisposable
|
||||||
|
@inject SocialRealtimeService Realtime
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
@if (Realtime.IncomingPlayInvite is not null)
|
||||||
|
{
|
||||||
|
<div class="play-overlay">
|
||||||
|
<div class="modal-backdrop"></div>
|
||||||
|
<section class="modal-card play-overlay-card" role="dialog" aria-modal="true" aria-labelledby="playInviteTitle">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Invitation de partie</p>
|
||||||
|
<h2 id="playInviteTitle">@Realtime.IncomingPlayInvite.SenderDisplayName veut jouer</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="play-overlay-copy">
|
||||||
|
@Realtime.IncomingPlayInvite.SenderDisplayName te propose une partie ChessCubing et te place cote @RecipientColorLabel.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="play-overlay-actions">
|
||||||
|
<button class="button secondary small" type="button" @onclick="AcceptAsync">Accepter</button>
|
||||||
|
<button class="button ghost small" type="button" @onclick="DeclineAsync">Refuser</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string RecipientColorLabel
|
||||||
|
=> string.Equals(Realtime.IncomingPlayInvite?.RecipientColor, "white", StringComparison.Ordinal)
|
||||||
|
? "blanc"
|
||||||
|
: "noir";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
Realtime.Changed += HandleRealtimeChanged;
|
||||||
|
await Realtime.EnsureStartedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleRealtimeChanged()
|
||||||
|
=> _ = InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
private async Task AcceptAsync()
|
||||||
|
{
|
||||||
|
await Realtime.RespondToIncomingPlayInviteAsync(accept: true);
|
||||||
|
|
||||||
|
var currentPath = new Uri(Navigation.Uri).AbsolutePath.Trim('/');
|
||||||
|
if (!string.Equals(currentPath, "application", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.Equals(currentPath, "application.html", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/application.html");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task DeclineAsync()
|
||||||
|
=> Realtime.RespondToIncomingPlayInviteAsync(accept: false);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
=> Realtime.Changed -= HandleRealtimeChanged;
|
||||||
|
}
|
||||||
13
ChessCubing.App/Components/RedirectToLogin.razor
Normal file
13
ChessCubing.App/Components/RedirectToLogin.razor
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||||
|
var returnUrl = string.IsNullOrWhiteSpace(relativePath)
|
||||||
|
? "/"
|
||||||
|
: relativePath.StartsWith("/", StringComparison.Ordinal) ? relativePath : $"/{relativePath}";
|
||||||
|
|
||||||
|
Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
568
ChessCubing.App/Components/SiteMenu.razor
Normal file
568
ChessCubing.App/Components/SiteMenu.razor
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using System.Net
|
||||||
|
@using System.Net.Http.Json
|
||||||
|
@using System.Security.Claims
|
||||||
|
@using ChessCubing.App.Models.Auth
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@implements IDisposable
|
||||||
|
@inject AppAuthenticationStateProvider AuthenticationStateProvider
|
||||||
|
@inject BrowserBridge Browser
|
||||||
|
@inject HttpClient Http
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<div class="site-menu-shell">
|
||||||
|
<header class="site-menu-bar">
|
||||||
|
<div class="site-menu-main @(IsMobileMenuOpen ? "is-mobile-open" : string.Empty)">
|
||||||
|
<a class="site-menu-brand" href="index.html" aria-label="Accueil ChessCubing">
|
||||||
|
<img class="site-menu-brand-icon" src="logo.png" alt="Icone ChessCubing" />
|
||||||
|
<span class="site-menu-brand-copy">
|
||||||
|
<span class="micro-label">ChessCubing Arena</span>
|
||||||
|
<strong>ChessCubing</strong>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="site-menu-desktop">
|
||||||
|
<nav class="site-menu-links" aria-label="Navigation principale">
|
||||||
|
<a class="@BuildNavLinkClass(HomePaths)" href="index.html" aria-current="@BuildAriaCurrent(HomePaths)">Accueil</a>
|
||||||
|
<a class="@BuildNavLinkClass(ApplicationPaths)" href="application.html" aria-current="@BuildAriaCurrent(ApplicationPaths)">Application</a>
|
||||||
|
<a class="@BuildNavLinkClass(RulesPaths)" href="reglement.html" aria-current="@BuildAriaCurrent(RulesPaths)">Reglement</a>
|
||||||
|
<a class="@BuildNavLinkClass(UserPaths)" href="utilisateur.html" aria-current="@BuildAriaCurrent(UserPaths)">Utilisateur</a>
|
||||||
|
@if (IsAdmin)
|
||||||
|
{
|
||||||
|
<a class="@BuildNavLinkClass(AdminPaths)" href="administration.html" aria-current="@BuildAriaCurrent(AdminPaths)">Administration</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="site-menu-account">
|
||||||
|
<span class="micro-label">Compte joueur</span>
|
||||||
|
@if (IsAuthenticated)
|
||||||
|
{
|
||||||
|
<div class="site-menu-account-panel">
|
||||||
|
<div class="site-menu-user">
|
||||||
|
<strong>@DisplayName</strong>
|
||||||
|
<span>@DisplayMeta</span>
|
||||||
|
</div>
|
||||||
|
<a class="button ghost small" href="api/auth/logout/browser">Se deconnecter</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="site-menu-account-actions">
|
||||||
|
<button class="button secondary small" type="button" @onclick="OpenLoginModal">Se connecter</button>
|
||||||
|
<button class="button ghost small" type="button" @onclick="OpenRegisterModal">Creer un compte</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="site-menu-mobile-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-label="@BuildMobileMenuToggleLabel()"
|
||||||
|
aria-controls="site-mobile-menu-panel"
|
||||||
|
aria-expanded="@BoolString(IsMobileMenuOpen)"
|
||||||
|
@onclick="ToggleMobileMenu">
|
||||||
|
<span class="site-menu-mobile-toggle-icon" aria-hidden="true">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="site-menu-mobile-panel" id="site-mobile-menu-panel">
|
||||||
|
<nav class="site-menu-mobile-links" aria-label="Navigation principale">
|
||||||
|
<a class="@BuildNavLinkClass(HomePaths)" href="index.html" aria-current="@BuildAriaCurrent(HomePaths)">Accueil</a>
|
||||||
|
<a class="@BuildNavLinkClass(ApplicationPaths)" href="application.html" aria-current="@BuildAriaCurrent(ApplicationPaths)">Application</a>
|
||||||
|
<a class="@BuildNavLinkClass(RulesPaths)" href="reglement.html" aria-current="@BuildAriaCurrent(RulesPaths)">Reglement</a>
|
||||||
|
<a class="@BuildNavLinkClass(UserPaths)" href="utilisateur.html" aria-current="@BuildAriaCurrent(UserPaths)">Utilisateur</a>
|
||||||
|
@if (IsAdmin)
|
||||||
|
{
|
||||||
|
<a class="@BuildNavLinkClass(AdminPaths)" href="administration.html" aria-current="@BuildAriaCurrent(AdminPaths)">Administration</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="site-menu-mobile-account">
|
||||||
|
<span class="micro-label">Compte joueur</span>
|
||||||
|
@if (IsAuthenticated)
|
||||||
|
{
|
||||||
|
<div class="site-menu-mobile-account-panel">
|
||||||
|
<div class="site-menu-mobile-user">
|
||||||
|
<strong>@DisplayName</strong>
|
||||||
|
<span>@DisplayMeta</span>
|
||||||
|
</div>
|
||||||
|
<a class="button ghost small" href="api/auth/logout/browser">Se deconnecter</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="site-menu-mobile-account-actions">
|
||||||
|
<button class="button secondary small" type="button" @onclick="OpenLoginModal">Se connecter</button>
|
||||||
|
<button class="button ghost small" type="button" @onclick="OpenRegisterModal">Creer un compte</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="modal @(ShowAuthModal ? string.Empty : "hidden")" aria-hidden="@BoolString(!ShowAuthModal)">
|
||||||
|
<div class="modal-backdrop" @onclick="CloseAuthModal"></div>
|
||||||
|
<div class="modal-card auth-modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Compte joueur</p>
|
||||||
|
<h2>@AuthModalTitle</h2>
|
||||||
|
</div>
|
||||||
|
<button class="button ghost small" type="button" @onclick="CloseAuthModal" disabled="@IsSubmitting">Fermer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="auth-modal-copy">
|
||||||
|
L'authentification se fait maintenant directement dans l'application, sans redirection vers une page externe.
|
||||||
|
</p>
|
||||||
|
<p class="auth-modal-copy">
|
||||||
|
La connexion reste memorisee sur ce navigateur pendant 30 jours.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="auth-modal-switch">
|
||||||
|
<button class="@BuildModeButtonClass(AuthMode.Login)" type="button" @onclick="SwitchToLogin" disabled="@IsSubmitting">Se connecter</button>
|
||||||
|
<button class="@BuildModeButtonClass(AuthMode.Register)" type="button" @onclick="SwitchToRegister" disabled="@IsSubmitting">Creer un compte</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(FormError))
|
||||||
|
{
|
||||||
|
<p class="auth-form-error">@FormError</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Mode == AuthMode.Login)
|
||||||
|
{
|
||||||
|
<EditForm Model="@LoginModel" OnValidSubmit="SubmitLoginAsync">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<div class="auth-form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>Identifiant ou email</span>
|
||||||
|
<InputText @bind-Value="LoginModel.Username" autocomplete="username email" />
|
||||||
|
<ValidationMessage For="@(() => LoginModel.Username)" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Mot de passe</span>
|
||||||
|
<InputText @bind-Value="LoginModel.Password" type="password" autocomplete="current-password" />
|
||||||
|
<ValidationMessage For="@(() => LoginModel.Password)" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="button secondary" type="submit" disabled="@IsSubmitting">
|
||||||
|
@(IsSubmitting ? "Connexion..." : "Se connecter")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<EditForm Model="@RegisterModel" OnValidSubmit="SubmitRegisterAsync">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<div class="auth-form-grid two-columns">
|
||||||
|
<label class="field">
|
||||||
|
<span>Prenom</span>
|
||||||
|
<InputText @bind-Value="RegisterModel.FirstName" autocomplete="given-name" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Nom</span>
|
||||||
|
<InputText @bind-Value="RegisterModel.LastName" autocomplete="family-name" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Nom d'utilisateur</span>
|
||||||
|
<InputText @bind-Value="RegisterModel.Username" autocomplete="username" />
|
||||||
|
<ValidationMessage For="@(() => RegisterModel.Username)" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Email</span>
|
||||||
|
<InputText @bind-Value="RegisterModel.Email" autocomplete="email" />
|
||||||
|
<ValidationMessage For="@(() => RegisterModel.Email)" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Mot de passe</span>
|
||||||
|
<InputText @bind-Value="RegisterModel.Password" type="password" autocomplete="new-password" />
|
||||||
|
<ValidationMessage For="@(() => RegisterModel.Password)" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Confirmation du mot de passe</span>
|
||||||
|
<InputText @bind-Value="RegisterModel.ConfirmPassword" type="password" autocomplete="new-password" />
|
||||||
|
<ValidationMessage For="@(() => RegisterModel.ConfirmPassword)" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="button secondary" type="submit" disabled="@IsSubmitting">
|
||||||
|
@(IsSubmitting ? "Creation..." : "Creer mon compte")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private static readonly string[] HomePaths = ["", "index.html"];
|
||||||
|
private static readonly string[] ApplicationPaths = ["application", "application.html"];
|
||||||
|
private static readonly string[] RulesPaths = ["reglement", "reglement.html"];
|
||||||
|
private static readonly string[] UserPaths = ["utilisateur", "utilisateur.html"];
|
||||||
|
private static readonly string[] AdminPaths = ["administration", "administration.html"];
|
||||||
|
|
||||||
|
private readonly LoginFormModel LoginModel = new();
|
||||||
|
private readonly RegisterFormModel RegisterModel = new();
|
||||||
|
|
||||||
|
private bool IsAuthenticated;
|
||||||
|
private bool IsAdmin;
|
||||||
|
private bool ShowAuthModal;
|
||||||
|
private bool IsSubmitting;
|
||||||
|
private bool IsMobileMenuOpen;
|
||||||
|
private string? FormError;
|
||||||
|
private string AuthModalTitle = "Se connecter";
|
||||||
|
private AuthMode Mode = AuthMode.Login;
|
||||||
|
private string DisplayName = "Utilisateur connecte";
|
||||||
|
private string DisplayMeta = "Session active";
|
||||||
|
private bool _syncMenuAfterRender;
|
||||||
|
|
||||||
|
private string CurrentPath
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var absolutePath = new Uri(Navigation.Uri).AbsolutePath;
|
||||||
|
return absolutePath.Trim('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
||||||
|
await RefreshAuthenticationStateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!_syncMenuAfterRender)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncMenuAfterRender = false;
|
||||||
|
await Browser.SyncMenuAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildNavLinkClass(string[] paths)
|
||||||
|
=> IsCurrentPage(paths) ? "site-menu-link is-active" : "site-menu-link";
|
||||||
|
|
||||||
|
private string? BuildAriaCurrent(string[] paths)
|
||||||
|
=> IsCurrentPage(paths) ? "page" : null;
|
||||||
|
|
||||||
|
private bool IsCurrentPage(string[] paths)
|
||||||
|
=> paths.Any(path => string.Equals(CurrentPath, path, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private void OpenLoginModal()
|
||||||
|
{
|
||||||
|
CloseMobileMenu();
|
||||||
|
ShowAuthModal = true;
|
||||||
|
SwitchToLogin();
|
||||||
|
RequestMenuSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenRegisterModal()
|
||||||
|
{
|
||||||
|
CloseMobileMenu();
|
||||||
|
ShowAuthModal = true;
|
||||||
|
SwitchToRegister();
|
||||||
|
RequestMenuSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseAuthModal()
|
||||||
|
{
|
||||||
|
if (IsSubmitting)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShowAuthModal = false;
|
||||||
|
FormError = null;
|
||||||
|
RequestMenuSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SwitchToLogin()
|
||||||
|
{
|
||||||
|
Mode = AuthMode.Login;
|
||||||
|
AuthModalTitle = "Se connecter";
|
||||||
|
FormError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SwitchToRegister()
|
||||||
|
{
|
||||||
|
Mode = AuthMode.Register;
|
||||||
|
AuthModalTitle = "Creer un compte";
|
||||||
|
FormError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildModeButtonClass(AuthMode mode)
|
||||||
|
=> mode == Mode ? "button secondary small" : "button ghost small";
|
||||||
|
|
||||||
|
private async Task SubmitLoginAsync()
|
||||||
|
{
|
||||||
|
await SubmitAsync(
|
||||||
|
"api/auth/login",
|
||||||
|
new LoginRequest
|
||||||
|
{
|
||||||
|
Username = LoginModel.Username.Trim(),
|
||||||
|
Password = LoginModel.Password,
|
||||||
|
},
|
||||||
|
"Connexion impossible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitRegisterAsync()
|
||||||
|
{
|
||||||
|
await SubmitAsync(
|
||||||
|
"api/auth/register",
|
||||||
|
new RegisterRequest
|
||||||
|
{
|
||||||
|
Username = RegisterModel.Username.Trim(),
|
||||||
|
Email = RegisterModel.Email.Trim(),
|
||||||
|
Password = RegisterModel.Password,
|
||||||
|
ConfirmPassword = RegisterModel.ConfirmPassword,
|
||||||
|
FirstName = RegisterModel.FirstName?.Trim(),
|
||||||
|
LastName = RegisterModel.LastName?.Trim(),
|
||||||
|
},
|
||||||
|
"Creation du compte impossible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitAsync<TRequest>(string endpoint, TRequest payload, string fallbackMessage)
|
||||||
|
{
|
||||||
|
if (IsSubmitting)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsSubmitting = true;
|
||||||
|
FormError = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await EnsureAuthServiceReadyAsync())
|
||||||
|
{
|
||||||
|
FormError = "Le service d'authentification demarre encore. Reessaie dans quelques secondes.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await Http.PostAsJsonAsync(endpoint, payload);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
FormError = await ReadErrorAsync(response, fallbackMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = await response.Content.ReadFromJsonAsync<AuthSessionResponse>();
|
||||||
|
if (session is not null && session.IsAuthenticated)
|
||||||
|
{
|
||||||
|
AuthenticationStateProvider.SetAuthenticated(session);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await AuthenticationStateProvider.RefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
ShowAuthModal = false;
|
||||||
|
FormError = null;
|
||||||
|
ResetForms();
|
||||||
|
CloseMobileMenu();
|
||||||
|
await RefreshAuthenticationStateAsync();
|
||||||
|
RequestMenuSync();
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
FormError = "Le service d'authentification est temporairement indisponible. Reessaie dans quelques secondes.";
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
FormError = "La reponse du service d'authentification a pris trop de temps. Reessaie dans quelques secondes.";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
FormError = fallbackMessage;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
||||||
|
=> _ = InvokeAsync(RefreshAuthenticationStateAsync);
|
||||||
|
|
||||||
|
private void ToggleMobileMenu()
|
||||||
|
{
|
||||||
|
IsMobileMenuOpen = !IsMobileMenuOpen;
|
||||||
|
RequestMenuSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseMobileMenu()
|
||||||
|
{
|
||||||
|
if (!IsMobileMenuOpen)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsMobileMenuOpen = false;
|
||||||
|
RequestMenuSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildMobileMenuToggleLabel()
|
||||||
|
=> IsMobileMenuOpen ? "Fermer le menu mobile" : "Ouvrir le menu mobile";
|
||||||
|
|
||||||
|
private void RequestMenuSync()
|
||||||
|
=> _syncMenuAfterRender = true;
|
||||||
|
|
||||||
|
private async Task RefreshAuthenticationStateAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var user = authState.User;
|
||||||
|
|
||||||
|
if (user.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
IsAuthenticated = true;
|
||||||
|
IsAdmin = user.IsInRole("admin");
|
||||||
|
DisplayName = BuildDisplayName(user);
|
||||||
|
DisplayMeta = BuildMeta(user);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ResetAuthenticationDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
ResetAuthenticationDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetAuthenticationDisplay()
|
||||||
|
{
|
||||||
|
IsAuthenticated = false;
|
||||||
|
IsAdmin = false;
|
||||||
|
DisplayName = "Utilisateur connecte";
|
||||||
|
DisplayMeta = "Session active";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetForms()
|
||||||
|
{
|
||||||
|
LoginModel.Username = string.Empty;
|
||||||
|
LoginModel.Password = string.Empty;
|
||||||
|
RegisterModel.FirstName = string.Empty;
|
||||||
|
RegisterModel.LastName = string.Empty;
|
||||||
|
RegisterModel.Username = string.Empty;
|
||||||
|
RegisterModel.Email = string.Empty;
|
||||||
|
RegisterModel.Password = string.Empty;
|
||||||
|
RegisterModel.ConfirmPassword = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildDisplayName(ClaimsPrincipal user)
|
||||||
|
=> user.FindFirst("name")?.Value
|
||||||
|
?? user.FindFirst("preferred_username")?.Value
|
||||||
|
?? user.Identity?.Name
|
||||||
|
?? "Utilisateur connecte";
|
||||||
|
|
||||||
|
private static string BuildMeta(ClaimsPrincipal user)
|
||||||
|
=> user.FindFirst("email")?.Value
|
||||||
|
?? "Session active";
|
||||||
|
|
||||||
|
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, string fallbackMessage)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadFromJsonAsync<ApiErrorMessage>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(error?.Message))
|
||||||
|
{
|
||||||
|
return error.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.StatusCode switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
|
||||||
|
=> "Le service d'authentification demarre encore. Reessaie dans quelques secondes.",
|
||||||
|
HttpStatusCode.Conflict
|
||||||
|
=> "Ce nom d'utilisateur ou cet email existe deja.",
|
||||||
|
_ => fallbackMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> EnsureAuthServiceReadyAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await Http.GetAsync("api/health");
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BoolString(bool value)
|
||||||
|
=> value ? "true" : "false";
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
||||||
|
|
||||||
|
private enum AuthMode
|
||||||
|
{
|
||||||
|
Login,
|
||||||
|
Register,
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ApiErrorMessage
|
||||||
|
{
|
||||||
|
public string? Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class LoginFormModel
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "L'identifiant est obligatoire.")]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Le mot de passe est obligatoire.")]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RegisterFormModel
|
||||||
|
{
|
||||||
|
public string? FirstName { get; set; }
|
||||||
|
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Le nom d'utilisateur est obligatoire.")]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "L'email est obligatoire.")]
|
||||||
|
[EmailAddress(ErrorMessage = "L'email n'est pas valide.")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "Le mot de passe est obligatoire.")]
|
||||||
|
[MinLength(8, ErrorMessage = "Le mot de passe doit contenir au moins 8 caracteres.")]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required(ErrorMessage = "La confirmation est obligatoire.")]
|
||||||
|
[Compare(nameof(Password), ErrorMessage = "Les mots de passe ne correspondent pas.")]
|
||||||
|
public string ConfirmPassword { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
ChessCubing.App/Layout/MainLayout.razor
Normal file
33
ChessCubing.App/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
@if (!HideGlobalMenu)
|
||||||
|
{
|
||||||
|
<SiteMenu />
|
||||||
|
<main class="site-layout-body">
|
||||||
|
@Body
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@Body
|
||||||
|
}
|
||||||
|
|
||||||
|
<PlayInviteOverlay />
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool HideGlobalMenu
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var currentPath = new Uri(Navigation.Uri).AbsolutePath.Trim('/');
|
||||||
|
|
||||||
|
return string.Equals(currentPath, "chrono", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(currentPath, "chrono.html", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(currentPath, "cube", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(currentPath, "cube.html", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(currentPath, "application", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(currentPath, "application.html", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
ChessCubing.App/Models/Auth/AuthSessionResponse.cs
Normal file
16
ChessCubing.App/Models/Auth/AuthSessionResponse.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ChessCubing.App.Models.Auth;
|
||||||
|
|
||||||
|
public sealed class AuthSessionResponse
|
||||||
|
{
|
||||||
|
public bool IsAuthenticated { get; set; }
|
||||||
|
|
||||||
|
public string? Subject { get; set; }
|
||||||
|
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string[] Roles { get; set; } = [];
|
||||||
|
}
|
||||||
8
ChessCubing.App/Models/Auth/LoginRequest.cs
Normal file
8
ChessCubing.App/Models/Auth/LoginRequest.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ChessCubing.App.Models.Auth;
|
||||||
|
|
||||||
|
public sealed class LoginRequest
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
16
ChessCubing.App/Models/Auth/RegisterRequest.cs
Normal file
16
ChessCubing.App/Models/Auth/RegisterRequest.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ChessCubing.App.Models.Auth;
|
||||||
|
|
||||||
|
public sealed class RegisterRequest
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ConfirmPassword { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? FirstName { get; set; }
|
||||||
|
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
}
|
||||||
300
ChessCubing.App/Models/MatchModels.cs
Normal file
300
ChessCubing.App/Models/MatchModels.cs
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ChessCubing.App.Services;
|
||||||
|
|
||||||
|
namespace ChessCubing.App.Models;
|
||||||
|
|
||||||
|
public sealed class MatchConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("matchLabel")]
|
||||||
|
public string MatchLabel { get; set; } = "Rencontre ChessCubing";
|
||||||
|
|
||||||
|
[JsonPropertyName("competitionMode")]
|
||||||
|
public bool CompetitionMode { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("mode")]
|
||||||
|
public string Mode { get; set; } = MatchEngine.ModeTwice;
|
||||||
|
|
||||||
|
[JsonPropertyName("preset")]
|
||||||
|
public string Preset { get; set; } = MatchEngine.PresetFast;
|
||||||
|
|
||||||
|
[JsonPropertyName("blockDurationMs")]
|
||||||
|
public long BlockDurationMs { get; set; } = MatchEngine.DefaultBlockDurationMs;
|
||||||
|
|
||||||
|
[JsonPropertyName("moveLimitMs")]
|
||||||
|
public long MoveLimitMs { get; set; } = MatchEngine.DefaultMoveLimitMs;
|
||||||
|
|
||||||
|
[JsonPropertyName("timeInitialMs")]
|
||||||
|
public long TimeInitialMs { get; set; } = MatchEngine.TimeModeInitialClockMs;
|
||||||
|
|
||||||
|
[JsonPropertyName("whiteName")]
|
||||||
|
public string WhiteName { get; set; } = "Blanc";
|
||||||
|
|
||||||
|
[JsonPropertyName("blackName")]
|
||||||
|
public string BlackName { get; set; } = "Noir";
|
||||||
|
|
||||||
|
[JsonPropertyName("arbiterName")]
|
||||||
|
public string ArbiterName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("eventName")]
|
||||||
|
public string EventName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("notes")]
|
||||||
|
public string Notes { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MatchState
|
||||||
|
{
|
||||||
|
[JsonPropertyName("schemaVersion")]
|
||||||
|
public int SchemaVersion { get; set; } = 4;
|
||||||
|
|
||||||
|
[JsonPropertyName("matchId")]
|
||||||
|
public string MatchId { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
[JsonPropertyName("collaborationSessionId")]
|
||||||
|
public string? CollaborationSessionId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("whiteSubject")]
|
||||||
|
public string? WhiteSubject { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("blackSubject")]
|
||||||
|
public string? BlackSubject { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("config")]
|
||||||
|
public MatchConfig Config { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("phase")]
|
||||||
|
public string Phase { get; set; } = MatchEngine.PhaseBlock;
|
||||||
|
|
||||||
|
[JsonPropertyName("running")]
|
||||||
|
public bool Running { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("lastTickAt")]
|
||||||
|
public long? LastTickAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("blockNumber")]
|
||||||
|
public int BlockNumber { get; set; } = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("currentTurn")]
|
||||||
|
public string CurrentTurn { get; set; } = MatchEngine.ColorWhite;
|
||||||
|
|
||||||
|
[JsonPropertyName("blockRemainingMs")]
|
||||||
|
public long BlockRemainingMs { get; set; } = MatchEngine.DefaultBlockDurationMs;
|
||||||
|
|
||||||
|
[JsonPropertyName("moveRemainingMs")]
|
||||||
|
public long MoveRemainingMs { get; set; } = MatchEngine.DefaultMoveLimitMs;
|
||||||
|
|
||||||
|
[JsonPropertyName("quota")]
|
||||||
|
public int Quota { get; set; } = 6;
|
||||||
|
|
||||||
|
[JsonPropertyName("moves")]
|
||||||
|
public PlayerIntPair Moves { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("clocks")]
|
||||||
|
public PlayerLongPair? Clocks { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("lastMover")]
|
||||||
|
public string? LastMover { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("awaitingBlockClosure")]
|
||||||
|
public bool AwaitingBlockClosure { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("closureReason")]
|
||||||
|
public string ClosureReason { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("result")]
|
||||||
|
public string? Result { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("resultRecordedUtc")]
|
||||||
|
public DateTime? ResultRecordedUtc { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("cube")]
|
||||||
|
public CubeState Cube { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("doubleCoup")]
|
||||||
|
public DoubleCoupState DoubleCoup { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("history")]
|
||||||
|
public List<MatchHistoryEntry> History { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlayerIntPair
|
||||||
|
{
|
||||||
|
[JsonPropertyName("white")]
|
||||||
|
public int White { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("black")]
|
||||||
|
public int Black { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlayerLongPair
|
||||||
|
{
|
||||||
|
[JsonPropertyName("white")]
|
||||||
|
public long White { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("black")]
|
||||||
|
public long Black { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlayerNullableLongPair
|
||||||
|
{
|
||||||
|
[JsonPropertyName("white")]
|
||||||
|
public long? White { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("black")]
|
||||||
|
public long? Black { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CubeState
|
||||||
|
{
|
||||||
|
[JsonPropertyName("number")]
|
||||||
|
public int? Number { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("running")]
|
||||||
|
public bool Running { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("startedAt")]
|
||||||
|
public long? StartedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("elapsedMs")]
|
||||||
|
public long ElapsedMs { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("phaseAlertPending")]
|
||||||
|
public bool PhaseAlertPending { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("times")]
|
||||||
|
public PlayerNullableLongPair Times { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("playerState")]
|
||||||
|
public CubePlayerStates PlayerState { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("round")]
|
||||||
|
public int Round { get; set; } = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("history")]
|
||||||
|
public List<CubeHistoryEntry> History { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CubePlayerStates
|
||||||
|
{
|
||||||
|
[JsonPropertyName("white")]
|
||||||
|
public CubePlayerState White { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("black")]
|
||||||
|
public CubePlayerState Black { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CubePlayerState
|
||||||
|
{
|
||||||
|
[JsonPropertyName("running")]
|
||||||
|
public bool Running { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("startedAt")]
|
||||||
|
public long? StartedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("elapsedMs")]
|
||||||
|
public long ElapsedMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DoubleCoupState
|
||||||
|
{
|
||||||
|
[JsonPropertyName("eligible")]
|
||||||
|
public bool Eligible { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("step")]
|
||||||
|
public int Step { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("starter")]
|
||||||
|
public string Starter { get; set; } = MatchEngine.ColorWhite;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MatchHistoryEntry
|
||||||
|
{
|
||||||
|
[JsonPropertyName("message")]
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("time")]
|
||||||
|
public string Time { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CubeHistoryEntry
|
||||||
|
{
|
||||||
|
[JsonPropertyName("blockNumber")]
|
||||||
|
public int BlockNumber { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("number")]
|
||||||
|
public int? Number { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("white")]
|
||||||
|
public long? White { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("black")]
|
||||||
|
public long? Black { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SetupFormModel
|
||||||
|
{
|
||||||
|
public bool CompetitionMode { get; set; }
|
||||||
|
public string MatchLabel { get; set; } = string.Empty;
|
||||||
|
public string Mode { get; set; } = MatchEngine.ModeTwice;
|
||||||
|
public string Preset { get; set; } = MatchEngine.PresetFast;
|
||||||
|
public int BlockMinutes { get; set; } = 3;
|
||||||
|
public int MoveSeconds { get; set; } = 20;
|
||||||
|
public int TimeInitialMinutes { get; set; } = 10;
|
||||||
|
public string WhiteName { get; set; } = "Blanc";
|
||||||
|
public string BlackName { get; set; } = "Noir";
|
||||||
|
public string ArbiterName { get; set; } = string.Empty;
|
||||||
|
public string EventName { get; set; } = string.Empty;
|
||||||
|
public string Notes { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public MatchConfig ToMatchConfig()
|
||||||
|
{
|
||||||
|
return new MatchConfig
|
||||||
|
{
|
||||||
|
MatchLabel = MatchEngine.SanitizeText(MatchLabel) is { Length: > 0 } label ? label : "Rencontre ChessCubing",
|
||||||
|
CompetitionMode = CompetitionMode,
|
||||||
|
Mode = Mode,
|
||||||
|
Preset = Preset,
|
||||||
|
BlockDurationMs = MatchEngine.NormalizeDurationMs(BlockMinutes * 60_000L, MatchEngine.DefaultBlockDurationMs),
|
||||||
|
MoveLimitMs = MatchEngine.NormalizeDurationMs(MoveSeconds * 1_000L, MatchEngine.DefaultMoveLimitMs),
|
||||||
|
TimeInitialMs = MatchEngine.NormalizeDurationMs(TimeInitialMinutes * 60_000L, MatchEngine.TimeModeInitialClockMs),
|
||||||
|
WhiteName = MatchEngine.SanitizeText(WhiteName) is { Length: > 0 } white ? white : "Blanc",
|
||||||
|
BlackName = MatchEngine.SanitizeText(BlackName) is { Length: > 0 } black ? black : "Noir",
|
||||||
|
ArbiterName = MatchEngine.SanitizeText(ArbiterName),
|
||||||
|
EventName = MatchEngine.SanitizeText(EventName),
|
||||||
|
Notes = MatchEngine.SanitizeText(Notes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SetupFormModel CreateDemo()
|
||||||
|
{
|
||||||
|
return new SetupFormModel
|
||||||
|
{
|
||||||
|
CompetitionMode = true,
|
||||||
|
MatchLabel = "Demo officielle ChessCubing",
|
||||||
|
Mode = MatchEngine.ModeTwice,
|
||||||
|
Preset = MatchEngine.PresetFreeze,
|
||||||
|
BlockMinutes = 3,
|
||||||
|
MoveSeconds = 20,
|
||||||
|
TimeInitialMinutes = 10,
|
||||||
|
WhiteName = "Nora",
|
||||||
|
BlackName = "Leo",
|
||||||
|
ArbiterName = "Arbitre demo",
|
||||||
|
EventName = "Session telephone",
|
||||||
|
Notes = "8 cubes verifies, variante prete, tirage au sort effectue.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MatchPresetInfo(string Label, int Quota, string Description);
|
||||||
|
|
||||||
|
public sealed record MatchModeInfo(string Label, string Subtitle);
|
||||||
|
|
||||||
|
public sealed record TimeAdjustmentPreview(
|
||||||
|
string BlockType,
|
||||||
|
string? Winner,
|
||||||
|
long CappedWhite,
|
||||||
|
long CappedBlack,
|
||||||
|
long WhiteDelta,
|
||||||
|
long BlackDelta,
|
||||||
|
long WhiteAfter,
|
||||||
|
long BlackAfter);
|
||||||
167
ChessCubing.App/Models/Social/SocialModels.cs
Normal file
167
ChessCubing.App/Models/Social/SocialModels.cs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
using ChessCubing.App.Models;
|
||||||
|
|
||||||
|
namespace ChessCubing.App.Models.Social;
|
||||||
|
|
||||||
|
public sealed class SocialOverviewResponse
|
||||||
|
{
|
||||||
|
public SocialFriendResponse[] Friends { get; init; } = [];
|
||||||
|
|
||||||
|
public SocialInvitationResponse[] ReceivedInvitations { get; init; } = [];
|
||||||
|
|
||||||
|
public SocialInvitationResponse[] SentInvitations { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SocialFriendResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string? Club { get; init; }
|
||||||
|
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
public bool IsOnline { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SocialInvitationResponse
|
||||||
|
{
|
||||||
|
public long InvitationId { get; init; }
|
||||||
|
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public bool IsOnline { get; init; }
|
||||||
|
|
||||||
|
public DateTime CreatedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SocialSearchUserResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string? Club { get; init; }
|
||||||
|
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
public bool IsOnline { get; init; }
|
||||||
|
|
||||||
|
public bool IsFriend { get; init; }
|
||||||
|
|
||||||
|
public bool HasSentInvitation { get; init; }
|
||||||
|
|
||||||
|
public bool HasReceivedInvitation { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SendFriendInvitationRequest
|
||||||
|
{
|
||||||
|
public string TargetSubject { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PresenceSnapshotMessage
|
||||||
|
{
|
||||||
|
public string[] OnlineSubjects { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PresenceChangedMessage
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsOnline { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlayInviteMessage
|
||||||
|
{
|
||||||
|
public string InviteId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string SenderSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string SenderUsername { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string SenderDisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string RecipientSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string RecipientUsername { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string RecipientDisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string RecipientColor { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime CreatedUtc { get; init; }
|
||||||
|
|
||||||
|
public DateTime ExpiresUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlayInviteClosedMessage
|
||||||
|
{
|
||||||
|
public string InviteId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Reason { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Message { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlaySessionResponse
|
||||||
|
{
|
||||||
|
public string SessionId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string WhiteSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string WhiteName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string BlackSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string BlackName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string InitiatorSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string RecipientSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime ConfirmedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CollaborativeMatchStateMessage
|
||||||
|
{
|
||||||
|
public string SessionId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? MatchJson { get; init; }
|
||||||
|
|
||||||
|
public string Route { get; init; } = "/application.html";
|
||||||
|
|
||||||
|
public string SenderSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public long Revision { get; init; }
|
||||||
|
|
||||||
|
public DateTime UpdatedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CollaborativeMatchSnapshot
|
||||||
|
{
|
||||||
|
public string SessionId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public MatchState? Match { get; init; }
|
||||||
|
|
||||||
|
public string Route { get; init; } = "/application.html";
|
||||||
|
|
||||||
|
public string SenderSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public long Revision { get; init; }
|
||||||
|
|
||||||
|
public DateTime UpdatedUtc { get; init; }
|
||||||
|
}
|
||||||
138
ChessCubing.App/Models/Stats/UserStatsModels.cs
Normal file
138
ChessCubing.App/Models/Stats/UserStatsModels.cs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
namespace ChessCubing.App.Models.Stats;
|
||||||
|
|
||||||
|
public sealed class UserStatsResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public int CurrentElo { get; init; }
|
||||||
|
|
||||||
|
public int RankedGames { get; init; }
|
||||||
|
|
||||||
|
public int CasualGames { get; init; }
|
||||||
|
|
||||||
|
public int Wins { get; init; }
|
||||||
|
|
||||||
|
public int Losses { get; init; }
|
||||||
|
|
||||||
|
public int StoppedGames { get; init; }
|
||||||
|
|
||||||
|
public int WhiteWins { get; init; }
|
||||||
|
|
||||||
|
public int BlackWins { get; init; }
|
||||||
|
|
||||||
|
public int WhiteLosses { get; init; }
|
||||||
|
|
||||||
|
public int BlackLosses { get; init; }
|
||||||
|
|
||||||
|
public int TotalMoves { get; init; }
|
||||||
|
|
||||||
|
public int TotalCubeRounds { get; init; }
|
||||||
|
|
||||||
|
public long? BestCubeTimeMs { get; init; }
|
||||||
|
|
||||||
|
public long? AverageCubeTimeMs { get; init; }
|
||||||
|
|
||||||
|
public DateTime? LastMatchUtc { get; init; }
|
||||||
|
|
||||||
|
public UserRecentMatchResponse[] RecentMatches { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserRecentMatchResponse
|
||||||
|
{
|
||||||
|
public string MatchId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime CompletedUtc { get; init; }
|
||||||
|
|
||||||
|
public string Result { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Mode { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Preset { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? MatchLabel { get; init; }
|
||||||
|
|
||||||
|
public string PlayerColor { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string PlayerName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string OpponentName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? OpponentSubject { get; init; }
|
||||||
|
|
||||||
|
public bool IsRanked { get; init; }
|
||||||
|
|
||||||
|
public bool IsWin { get; init; }
|
||||||
|
|
||||||
|
public bool IsLoss { get; init; }
|
||||||
|
|
||||||
|
public int PlayerMoves { get; init; }
|
||||||
|
|
||||||
|
public int OpponentMoves { get; init; }
|
||||||
|
|
||||||
|
public int CubeRounds { get; init; }
|
||||||
|
|
||||||
|
public long? PlayerBestCubeTimeMs { get; init; }
|
||||||
|
|
||||||
|
public long? PlayerAverageCubeTimeMs { get; init; }
|
||||||
|
|
||||||
|
public int? EloBefore { get; init; }
|
||||||
|
|
||||||
|
public int? EloAfter { get; init; }
|
||||||
|
|
||||||
|
public int? EloDelta { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReportCompletedMatchRequest
|
||||||
|
{
|
||||||
|
public string MatchId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? CollaborationSessionId { get; init; }
|
||||||
|
|
||||||
|
public string? WhiteSubject { get; init; }
|
||||||
|
|
||||||
|
public string WhiteName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? BlackSubject { get; init; }
|
||||||
|
|
||||||
|
public string BlackName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Result { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Mode { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Preset { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? MatchLabel { get; init; }
|
||||||
|
|
||||||
|
public int BlockNumber { get; init; }
|
||||||
|
|
||||||
|
public int WhiteMoves { get; init; }
|
||||||
|
|
||||||
|
public int BlackMoves { get; init; }
|
||||||
|
|
||||||
|
public ReportCompletedCubeRound[] CubeRounds { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReportCompletedCubeRound
|
||||||
|
{
|
||||||
|
public int BlockNumber { get; init; }
|
||||||
|
|
||||||
|
public int? Number { get; init; }
|
||||||
|
|
||||||
|
public long? White { get; init; }
|
||||||
|
|
||||||
|
public long? Black { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReportCompletedMatchResponse
|
||||||
|
{
|
||||||
|
public bool Recorded { get; init; }
|
||||||
|
|
||||||
|
public bool IsDuplicate { get; init; }
|
||||||
|
|
||||||
|
public bool IsRanked { get; init; }
|
||||||
|
|
||||||
|
public int? WhiteEloAfter { get; init; }
|
||||||
|
|
||||||
|
public int? BlackEloAfter { get; init; }
|
||||||
|
}
|
||||||
32
ChessCubing.App/Models/Users/AdminCreateUserRequest.cs
Normal file
32
ChessCubing.App/Models/Users/AdminCreateUserRequest.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace ChessCubing.App.Models.Users;
|
||||||
|
|
||||||
|
public sealed class AdminCreateUserRequest
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ConfirmPassword { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? FirstName { get; set; }
|
||||||
|
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public bool IsEmailVerified { get; set; }
|
||||||
|
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
|
||||||
|
public string? Club { get; set; }
|
||||||
|
|
||||||
|
public string? City { get; set; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; set; }
|
||||||
|
|
||||||
|
public string? FavoriteCube { get; set; }
|
||||||
|
|
||||||
|
public string? Bio { get; set; }
|
||||||
|
}
|
||||||
26
ChessCubing.App/Models/Users/AdminUpdateUserRequest.cs
Normal file
26
ChessCubing.App/Models/Users/AdminUpdateUserRequest.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ChessCubing.App.Models.Users;
|
||||||
|
|
||||||
|
public sealed class AdminUpdateUserRequest
|
||||||
|
{
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string? FirstName { get; set; }
|
||||||
|
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
|
||||||
|
public bool IsEmailVerified { get; set; }
|
||||||
|
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
|
||||||
|
public string? Club { get; set; }
|
||||||
|
|
||||||
|
public string? City { get; set; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; set; }
|
||||||
|
|
||||||
|
public string? FavoriteCube { get; set; }
|
||||||
|
|
||||||
|
public string? Bio { get; set; }
|
||||||
|
}
|
||||||
40
ChessCubing.App/Models/Users/AdminUserDetailResponse.cs
Normal file
40
ChessCubing.App/Models/Users/AdminUserDetailResponse.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
namespace ChessCubing.App.Models.Users;
|
||||||
|
|
||||||
|
public sealed class AdminUserDetailResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string? FirstName { get; set; }
|
||||||
|
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
|
public string IdentityDisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
|
||||||
|
public bool IsEmailVerified { get; set; }
|
||||||
|
|
||||||
|
public DateTime? AccountCreatedUtc { get; set; }
|
||||||
|
|
||||||
|
public bool HasSiteProfile { get; set; }
|
||||||
|
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Club { get; set; }
|
||||||
|
|
||||||
|
public string? City { get; set; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; set; }
|
||||||
|
|
||||||
|
public string? FavoriteCube { get; set; }
|
||||||
|
|
||||||
|
public string? Bio { get; set; }
|
||||||
|
|
||||||
|
public DateTime? SiteProfileCreatedUtc { get; set; }
|
||||||
|
|
||||||
|
public DateTime? SiteProfileUpdatedUtc { get; set; }
|
||||||
|
}
|
||||||
30
ChessCubing.App/Models/Users/AdminUserSummaryResponse.cs
Normal file
30
ChessCubing.App/Models/Users/AdminUserSummaryResponse.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace ChessCubing.App.Models.Users;
|
||||||
|
|
||||||
|
public sealed class AdminUserSummaryResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string IdentityDisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? SiteDisplayName { get; set; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
|
||||||
|
public bool IsEmailVerified { get; set; }
|
||||||
|
|
||||||
|
public bool HasSiteProfile { get; set; }
|
||||||
|
|
||||||
|
public string? Club { get; set; }
|
||||||
|
|
||||||
|
public string? City { get; set; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; set; }
|
||||||
|
|
||||||
|
public DateTime? AccountCreatedUtc { get; set; }
|
||||||
|
|
||||||
|
public DateTime? SiteProfileUpdatedUtc { get; set; }
|
||||||
|
}
|
||||||
16
ChessCubing.App/Models/Users/UpdateUserProfileRequest.cs
Normal file
16
ChessCubing.App/Models/Users/UpdateUserProfileRequest.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ChessCubing.App.Models.Users;
|
||||||
|
|
||||||
|
public sealed class UpdateUserProfileRequest
|
||||||
|
{
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
|
||||||
|
public string? Club { get; set; }
|
||||||
|
|
||||||
|
public string? City { get; set; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; set; }
|
||||||
|
|
||||||
|
public string? FavoriteCube { get; set; }
|
||||||
|
|
||||||
|
public string? Bio { get; set; }
|
||||||
|
}
|
||||||
26
ChessCubing.App/Models/Users/UserProfileResponse.cs
Normal file
26
ChessCubing.App/Models/Users/UserProfileResponse.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ChessCubing.App.Models.Users;
|
||||||
|
|
||||||
|
public sealed class UserProfileResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Club { get; set; }
|
||||||
|
|
||||||
|
public string? City { get; set; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; set; }
|
||||||
|
|
||||||
|
public string? FavoriteCube { get; set; }
|
||||||
|
|
||||||
|
public string? Bio { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedUtc { get; set; }
|
||||||
|
|
||||||
|
public DateTime UpdatedUtc { get; set; }
|
||||||
|
}
|
||||||
1216
ChessCubing.App/Pages/AdminPage.razor
Normal file
1216
ChessCubing.App/Pages/AdminPage.razor
Normal file
File diff suppressed because it is too large
Load Diff
957
ChessCubing.App/Pages/ApplicationPage.razor
Normal file
957
ChessCubing.App/Pages/ApplicationPage.razor
Normal file
@@ -0,0 +1,957 @@
|
|||||||
|
@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" disabled="@HasLockedPlaySession" />
|
||||||
|
</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" disabled="@HasLockedPlaySession" />
|
||||||
|
</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>
|
||||||
|
<span>@StatsEligibilityText</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? ConnectedPlayerSubject;
|
||||||
|
private string? AssignedWhiteSubject;
|
||||||
|
private string? AssignedBlackSubject;
|
||||||
|
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 bool HasLockedPlaySession => Realtime.ActivePlaySession is not null;
|
||||||
|
private string WhitePlayerName
|
||||||
|
{
|
||||||
|
get => Form.WhiteName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Form.WhiteName = value;
|
||||||
|
if (!HasLockedPlaySession &&
|
||||||
|
!string.IsNullOrWhiteSpace(AssignedWhiteSubject) &&
|
||||||
|
AssignedWhiteSubject == ConnectedPlayerSubject &&
|
||||||
|
!SamePlayerName(value, ConnectedPlayerName))
|
||||||
|
{
|
||||||
|
AssignedWhiteSubject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetupError = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BlackPlayerName
|
||||||
|
{
|
||||||
|
get => Form.BlackName;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
Form.BlackName = value;
|
||||||
|
if (!HasLockedPlaySession &&
|
||||||
|
!string.IsNullOrWhiteSpace(AssignedBlackSubject) &&
|
||||||
|
AssignedBlackSubject == ConnectedPlayerSubject &&
|
||||||
|
!SamePlayerName(value, ConnectedPlayerName))
|
||||||
|
{
|
||||||
|
AssignedBlackSubject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.";
|
||||||
|
|
||||||
|
private string StatsEligibilityText =>
|
||||||
|
Realtime.ActivePlaySession is not null
|
||||||
|
? "Match classe : les deux comptes sont identifies, la partie mettra a jour l'Elo et les statistiques."
|
||||||
|
: !string.IsNullOrWhiteSpace(AssignedWhiteSubject) || !string.IsNullOrWhiteSpace(AssignedBlackSubject)
|
||||||
|
? "Match amical : la partie sera enregistree pour le ou les comptes lies, sans impact Elo."
|
||||||
|
: "Match local uniquement : aucun compte joueur n'est lie des deux cotes, rien ne sera ajoute aux statistiques serveur.";
|
||||||
|
|
||||||
|
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;
|
||||||
|
ConnectedPlayerSubject = null;
|
||||||
|
AssignedWhiteSubject = null;
|
||||||
|
AssignedBlackSubject = null;
|
||||||
|
ResetSocialState();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsAuthenticated = true;
|
||||||
|
fallbackName = BuildConnectedPlayerFallback(user);
|
||||||
|
ConnectedPlayerSubject = ResolveSubject(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;
|
||||||
|
ConnectedPlayerSubject = null;
|
||||||
|
AssignedWhiteSubject = null;
|
||||||
|
AssignedBlackSubject = 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 (Realtime.ActivePlaySession is { } session)
|
||||||
|
{
|
||||||
|
match.CollaborationSessionId = session.SessionId;
|
||||||
|
match.WhiteSubject = NormalizeOptional(session.WhiteSubject);
|
||||||
|
match.BlackSubject = NormalizeOptional(session.BlackSubject);
|
||||||
|
match.Config.WhiteName = session.WhiteName;
|
||||||
|
match.Config.BlackName = session.BlackName;
|
||||||
|
await Realtime.EnsureJoinedPlaySessionAsync(match.CollaborationSessionId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
match.WhiteSubject = NormalizeOptional(AssignedWhiteSubject);
|
||||||
|
match.BlackSubject = NormalizeOptional(AssignedBlackSubject);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
AssignedWhiteSubject = null;
|
||||||
|
AssignedBlackSubject = null;
|
||||||
|
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;
|
||||||
|
AssignedWhiteSubject = NormalizeOptional(session.WhiteSubject);
|
||||||
|
AssignedBlackSubject = NormalizeOptional(session.BlackSubject);
|
||||||
|
SetupError = null;
|
||||||
|
_appliedActiveSessionId = session.SessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AssignConnectedPlayerToWhite()
|
||||||
|
{
|
||||||
|
if (!CanUseConnectedPlayerName)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedName = ConnectedPlayerName!;
|
||||||
|
Form.WhiteName = connectedName;
|
||||||
|
AssignedWhiteSubject = NormalizeOptional(ConnectedPlayerSubject);
|
||||||
|
|
||||||
|
if (SamePlayerName(Form.BlackName, connectedName) || string.Equals(AssignedBlackSubject, ConnectedPlayerSubject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Form.BlackName = "Noir";
|
||||||
|
AssignedBlackSubject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetupError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AssignConnectedPlayerToBlack()
|
||||||
|
{
|
||||||
|
if (!CanUseConnectedPlayerName)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedName = ConnectedPlayerName!;
|
||||||
|
Form.BlackName = connectedName;
|
||||||
|
AssignedBlackSubject = NormalizeOptional(ConnectedPlayerSubject);
|
||||||
|
|
||||||
|
if (SamePlayerName(Form.WhiteName, connectedName) || string.Equals(AssignedWhiteSubject, ConnectedPlayerSubject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Form.WhiteName = "Blanc";
|
||||||
|
AssignedWhiteSubject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
AssignedWhiteSubject = NormalizeOptional(snapshot.Match.WhiteSubject);
|
||||||
|
AssignedBlackSubject = NormalizeOptional(snapshot.Match.BlackSubject);
|
||||||
|
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? ResolveSubject(ClaimsPrincipal user)
|
||||||
|
=> user.FindFirst("sub")?.Value
|
||||||
|
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
|
||||||
|
private static string? FirstNonEmpty(params string?[] candidates)
|
||||||
|
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
|
||||||
|
|
||||||
|
private static string? NormalizeOptional(string? value)
|
||||||
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
||||||
|
Realtime.Changed -= HandleRealtimeChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
ChessCubing.App/Pages/Authentication.razor
Normal file
16
ChessCubing.App/Pages/Authentication.razor
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@page "/authentication/{action}"
|
||||||
|
|
||||||
|
<main class="rules-shell">
|
||||||
|
<section class="panel panel-wide cta-panel" style="margin-top: 2rem;">
|
||||||
|
<p class="eyebrow">Authentification</p>
|
||||||
|
<div>
|
||||||
|
<strong>Le systeme d'authentification est maintenant integre a l'application.</strong>
|
||||||
|
<p>Utilise les boutons du menu en haut de page pour te connecter ou creer un compte sans quitter le site.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string? Action { get; set; }
|
||||||
|
}
|
||||||
682
ChessCubing.App/Pages/ChronoPage.razor
Normal file
682
ChessCubing.App/Pages/ChronoPage.razor
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
@page "/chrono"
|
||||||
|
@page "/chrono.html"
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
@inject MatchStore Store
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject SocialRealtimeService Realtime
|
||||||
|
@inject MatchStatsService MatchStats
|
||||||
|
|
||||||
|
<PageTitle>ChessCubing Arena | Phase Chrono</PageTitle>
|
||||||
|
<PageBody Page="chrono" BodyClass="@ChronoBodyClass" />
|
||||||
|
|
||||||
|
@{
|
||||||
|
var summary = Match is null ? null : BuildSummary();
|
||||||
|
var blackZone = Match is null ? null : BuildZone(MatchEngine.ColorBlack);
|
||||||
|
var whiteZone = Match is null ? null : BuildZone(MatchEngine.ColorWhite);
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!_ready)
|
||||||
|
{
|
||||||
|
<main class="phase-shell chrono-stage">
|
||||||
|
<div class="panel" style="padding: 1.5rem;">Chargement de la phase chrono...</div>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
else if (Match is not null && summary is not null && blackZone is not null && whiteZone is not null)
|
||||||
|
{
|
||||||
|
<main class="phase-shell chrono-stage">
|
||||||
|
<header class="phase-header">
|
||||||
|
<a class="brand-link brand-link-logo" href="application.html" aria-label="Retour a l'application">
|
||||||
|
<img class="brand-link-icon" src="logo.png" alt="Icone ChessCubing" />
|
||||||
|
<img class="brand-link-mark" src="transparent.png" alt="Logo ChessCubing" />
|
||||||
|
</a>
|
||||||
|
<div class="phase-title">
|
||||||
|
<p class="eyebrow">Phase chrono</p>
|
||||||
|
<h1 id="chronoTitle">@summary.Title</h1>
|
||||||
|
<p id="chronoSubtitle" class="phase-subtitle">@summary.Subtitle</p>
|
||||||
|
</div>
|
||||||
|
<button class="button ghost small utility-button" id="openArbiterButton" type="button" @onclick="OpenArbiterModal">
|
||||||
|
Arbitre
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="status-strip">
|
||||||
|
<article class="status-card">
|
||||||
|
<span id="blockTimerLabel">Temps Block</span>
|
||||||
|
<strong id="blockTimer">@summary.BlockTimer</strong>
|
||||||
|
</article>
|
||||||
|
<article class="status-card" id="moveTimerCard" hidden="@summary.HideMoveTimer">
|
||||||
|
<span>Temps coup</span>
|
||||||
|
<strong id="moveTimer">@summary.MoveTimer</strong>
|
||||||
|
</article>
|
||||||
|
<article class="status-card wide">
|
||||||
|
<span id="chronoCenterLabel">@summary.CenterLabel</span>
|
||||||
|
<strong id="chronoCenterValue">@summary.CenterValue</strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="faceoff-board">
|
||||||
|
<article class="player-zone opponent-zone @(blackZone.ActiveZone ? "active-zone" : string.Empty) @(blackZone.HasPlayerClock ? "has-player-clock" : string.Empty)" id="blackZone">
|
||||||
|
<div class="zone-inner mirrored-mobile">
|
||||||
|
<div class="zone-head">
|
||||||
|
<div>
|
||||||
|
<span class="seat-tag dark-seat">Noir</span>
|
||||||
|
<h2 id="blackNameChrono">@blackZone.Name</h2>
|
||||||
|
</div>
|
||||||
|
<div class="zone-stats">
|
||||||
|
<strong id="blackMovesChrono">@blackZone.Moves</strong>
|
||||||
|
<span class="player-clock @(blackZone.NegativeClock ? "negative-clock" : string.Empty) @(blackZone.ActiveClock ? "active-clock" : string.Empty)" id="blackClockChrono">@blackZone.Clock</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="zone-button dark-button @(blackZone.ActiveTurn ? "active-turn" : string.Empty)" id="blackMoveButton" type="button" disabled="@blackZone.Disabled" @onclick="() => HandleChronoTapAsync(MatchEngine.ColorBlack)">
|
||||||
|
@blackZone.ButtonText
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="zone-foot" id="blackHintChrono">@blackZone.Hint</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="phase-spine">
|
||||||
|
<div class="spine-card">
|
||||||
|
<p class="micro-label" id="spineLabel">@summary.SpineLabel</p>
|
||||||
|
<strong id="spineHeadline">@summary.SpineHeadline</strong>
|
||||||
|
<p id="spineText">@summary.SpineText</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="button primary spine-button" id="primaryChronoButton" type="button" @onclick="HandlePrimaryActionAsync">
|
||||||
|
@summary.PrimaryButtonText
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="player-zone @(whiteZone.ActiveZone ? "active-zone" : string.Empty) @(whiteZone.HasPlayerClock ? "has-player-clock" : string.Empty)" id="whiteZone">
|
||||||
|
<div class="zone-inner">
|
||||||
|
<div class="zone-head">
|
||||||
|
<div>
|
||||||
|
<span class="seat-tag light-seat">Blanc</span>
|
||||||
|
<h2 id="whiteNameChrono">@whiteZone.Name</h2>
|
||||||
|
</div>
|
||||||
|
<div class="zone-stats">
|
||||||
|
<strong id="whiteMovesChrono">@whiteZone.Moves</strong>
|
||||||
|
<span class="player-clock @(whiteZone.NegativeClock ? "negative-clock" : string.Empty) @(whiteZone.ActiveClock ? "active-clock" : string.Empty)" id="whiteClockChrono">@whiteZone.Clock</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="zone-button light-button @(whiteZone.ActiveTurn ? "active-turn" : string.Empty)" id="whiteMoveButton" type="button" disabled="@whiteZone.Disabled" @onclick="() => HandleChronoTapAsync(MatchEngine.ColorWhite)">
|
||||||
|
@whiteZone.ButtonText
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="zone-foot" id="whiteHintChrono">@whiteZone.Hint</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section class="modal @(ShowArbiterModal ? string.Empty : "hidden")" id="arbiterModal" aria-hidden="@BoolString(!ShowArbiterModal)">
|
||||||
|
<div class="modal-backdrop" @onclick="CloseArbiterModal"></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Outils arbitre</p>
|
||||||
|
<h2>Controles avances</h2>
|
||||||
|
</div>
|
||||||
|
<button class="button ghost small" id="closeArbiterButton" type="button" @onclick="CloseArbiterModal">Fermer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-copy" id="arbiterStatus">@summary.ArbiterStatus</p>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="button secondary" id="arbiterPauseButton" type="button" @onclick="TogglePauseAsync">
|
||||||
|
Pause / reprise
|
||||||
|
</button>
|
||||||
|
<button class="button secondary" id="arbiterCloseBlockButton" type="button" @onclick="CloseBlockAsync">
|
||||||
|
Passer au cube
|
||||||
|
</button>
|
||||||
|
<button class="button secondary" id="arbiterTimeoutButton" type="button" hidden="@summary.HideMoveTimer" disabled="@summary.HideMoveTimer" @onclick="TimeoutMoveAsync">
|
||||||
|
@summary.TimeoutButtonText
|
||||||
|
</button>
|
||||||
|
<button class="button secondary" id="arbiterSwitchTurnButton" type="button" @onclick="SwitchTurnAsync">
|
||||||
|
Corriger le trait
|
||||||
|
</button>
|
||||||
|
<button class="button ghost" id="arbiterWhiteWinButton" type="button" @onclick="() => SetResultAsync(MatchEngine.ColorWhite)">
|
||||||
|
Blanc gagne
|
||||||
|
</button>
|
||||||
|
<button class="button ghost" id="arbiterBlackWinButton" type="button" @onclick="() => SetResultAsync(MatchEngine.ColorBlack)">
|
||||||
|
Noir gagne
|
||||||
|
</button>
|
||||||
|
<button class="button ghost danger" id="arbiterStopButton" type="button" @onclick='() => SetResultAsync("stopped")'>
|
||||||
|
Abandon / arret
|
||||||
|
</button>
|
||||||
|
<button class="button ghost" id="arbiterResetButton" type="button" @onclick="ResetMatchAsync">
|
||||||
|
Reinitialiser le match
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private CancellationTokenSource? _tickerCancellation;
|
||||||
|
private bool _ready;
|
||||||
|
private bool ShowArbiterModal;
|
||||||
|
private long _knownCollaborativeRevision;
|
||||||
|
|
||||||
|
private MatchState? Match => Store.Current;
|
||||||
|
|
||||||
|
private string ChronoBodyClass =>
|
||||||
|
Match is not null && MatchEngine.IsTimeMode(Match)
|
||||||
|
? "phase-body time-mode"
|
||||||
|
: "phase-body";
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Realtime.Changed += HandleRealtimeChanged;
|
||||||
|
await Realtime.EnsureStartedAsync();
|
||||||
|
await Store.EnsureLoadedAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId))
|
||||||
|
{
|
||||||
|
await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId);
|
||||||
|
await ApplyCollaborativeSyncAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ready = true;
|
||||||
|
|
||||||
|
if (Match is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/application.html", replace: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube)
|
||||||
|
{
|
||||||
|
await Store.SaveAsync();
|
||||||
|
Navigation.NavigateTo("/cube.html", replace: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnsureResultReportedAsync();
|
||||||
|
|
||||||
|
_tickerCancellation = new CancellationTokenSource();
|
||||||
|
_ = RunTickerAsync(_tickerCancellation.Token);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_tickerCancellation is not null)
|
||||||
|
{
|
||||||
|
_tickerCancellation.Cancel();
|
||||||
|
_tickerCancellation.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Realtime.Changed -= HandleRealtimeChanged;
|
||||||
|
await Store.FlushIfDueAsync(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleRealtimeChanged()
|
||||||
|
=> _ = InvokeAsync(HandleRealtimeChangedAsync);
|
||||||
|
|
||||||
|
private async Task HandleRealtimeChangedAsync()
|
||||||
|
{
|
||||||
|
await ApplyCollaborativeSyncAsync();
|
||||||
|
await EnsureResultReportedAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunTickerAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
|
||||||
|
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MatchEngine.SyncRunningState(match))
|
||||||
|
{
|
||||||
|
Store.MarkDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Store.FlushIfDueAsync();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(match.Result) && match.Phase == MatchEngine.PhaseCube)
|
||||||
|
{
|
||||||
|
await Store.SaveAsync();
|
||||||
|
await InvokeAsync(() => Navigation.NavigateTo("/cube.html", replace: true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenArbiterModal()
|
||||||
|
=> ShowArbiterModal = true;
|
||||||
|
|
||||||
|
private void CloseArbiterModal()
|
||||||
|
=> ShowArbiterModal = false;
|
||||||
|
|
||||||
|
private async Task HandleChronoTapAsync(string color)
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchEngine.SyncRunningState(match);
|
||||||
|
if (!match.Running || match.CurrentTurn != color)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.DoubleCoup.Step == 1)
|
||||||
|
{
|
||||||
|
MatchEngine.RegisterFreeDoubleMove(match);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MatchEngine.RegisterCountedMove(match, match.DoubleCoup.Step == 2 ? "double" : "standard");
|
||||||
|
}
|
||||||
|
|
||||||
|
await PersistAndRouteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandlePrimaryActionAsync()
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchEngine.SyncRunningState(match);
|
||||||
|
if (!string.IsNullOrEmpty(match.Result))
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/application.html");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Phase != MatchEngine.PhaseBlock)
|
||||||
|
{
|
||||||
|
await Store.SaveAsync();
|
||||||
|
Navigation.NavigateTo("/cube.html", replace: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Running)
|
||||||
|
{
|
||||||
|
MatchEngine.PauseBlock(match);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MatchEngine.StartBlock(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
await PersistAndRouteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TogglePauseAsync()
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchEngine.SyncRunningState(match);
|
||||||
|
if (match.Running)
|
||||||
|
{
|
||||||
|
MatchEngine.PauseBlock(match);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MatchEngine.StartBlock(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
await PersistAndRouteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseBlockAsync()
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchEngine.SyncRunningState(match);
|
||||||
|
MatchEngine.RequestBlockClosure(match, $"Cloture manuelle {MatchEngine.GetBlockGenitivePhrase(match)} demandee par l'arbitre.");
|
||||||
|
await PersistAndRouteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TimeoutMoveAsync()
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock || !MatchEngine.UsesMoveLimit(match))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchEngine.SyncRunningState(match);
|
||||||
|
MatchEngine.RegisterMoveTimeout(match, false);
|
||||||
|
await PersistAndRouteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SwitchTurnAsync()
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchEngine.SyncRunningState(match);
|
||||||
|
match.CurrentTurn = MatchEngine.OpponentOf(match.CurrentTurn);
|
||||||
|
if (MatchEngine.UsesMoveLimit(match))
|
||||||
|
{
|
||||||
|
match.MoveRemainingMs = MatchEngine.GetMoveLimitMs(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchEngine.LogEvent(match, "Trait corrige manuellement par l'arbitre.");
|
||||||
|
await PersistAndRouteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetResultAsync(string result)
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchEngine.SyncRunningState(match);
|
||||||
|
MatchEngine.SetResult(match, result);
|
||||||
|
await PersistAndRouteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetMatchAsync()
|
||||||
|
{
|
||||||
|
var sessionId = Match?.CollaborationSessionId;
|
||||||
|
await Store.ClearAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
await Realtime.PublishMatchStateAsync(null, "/application.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigation.NavigateTo("/application.html", replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistAndRouteAsync()
|
||||||
|
{
|
||||||
|
await EnsureResultReportedAsync();
|
||||||
|
Store.MarkDirty();
|
||||||
|
await Store.SaveAsync();
|
||||||
|
|
||||||
|
var route = Match is not null && string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube
|
||||||
|
? "/cube.html"
|
||||||
|
: "/chrono.html";
|
||||||
|
|
||||||
|
await Realtime.PublishMatchStateAsync(Match, route);
|
||||||
|
|
||||||
|
if (Match is not null && string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/cube.html", replace: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureResultReportedAsync()
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null || string.IsNullOrWhiteSpace(match.Result) || match.ResultRecordedUtc is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await MatchStats.TryReportCompletedMatchAsync(match))
|
||||||
|
{
|
||||||
|
Store.MarkDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChronoSummaryView BuildSummary()
|
||||||
|
{
|
||||||
|
var match = Match!;
|
||||||
|
var blockHeading = MatchEngine.FormatBlockHeading(match, match.BlockNumber);
|
||||||
|
var subtitle = $"{blockHeading} - {MatchEngine.Modes[match.Config.Mode].Label} - {MatchEngine.RenderModeContext(match)}";
|
||||||
|
var hideMoveTimer = MatchEngine.IsTimeMode(match);
|
||||||
|
var timeoutButtonText = $"Depassement {MatchEngine.FormatClock(MatchEngine.GetMoveLimitMs(match))}";
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(match.Result))
|
||||||
|
{
|
||||||
|
return new ChronoSummaryView(
|
||||||
|
match.Config.MatchLabel,
|
||||||
|
subtitle,
|
||||||
|
MatchEngine.FormatClock(match.BlockRemainingMs),
|
||||||
|
hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs),
|
||||||
|
"Resultat",
|
||||||
|
MatchEngine.ResultText(match),
|
||||||
|
"Termine",
|
||||||
|
MatchEngine.ResultText(match),
|
||||||
|
"Retournez a la configuration pour lancer une nouvelle rencontre.",
|
||||||
|
"Retour a l'accueil",
|
||||||
|
"Le match est termine. Vous pouvez revenir a l'accueil ou reinitialiser.",
|
||||||
|
hideMoveTimer,
|
||||||
|
timeoutButtonText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Running)
|
||||||
|
{
|
||||||
|
return new ChronoSummaryView(
|
||||||
|
match.Config.MatchLabel,
|
||||||
|
subtitle,
|
||||||
|
MatchEngine.FormatClock(match.BlockRemainingMs),
|
||||||
|
hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs),
|
||||||
|
"Trait",
|
||||||
|
MatchEngine.PlayerName(match, match.CurrentTurn),
|
||||||
|
"Chrono en cours",
|
||||||
|
$"{blockHeading} actif",
|
||||||
|
"Chaque joueur tape sur sa grande zone quand son coup est termine. La page cube s'ouvrira automatiquement a la fin de la phase chess.",
|
||||||
|
"Pause arbitre",
|
||||||
|
$"{blockHeading} en cours. Joueur au trait : {MatchEngine.PlayerName(match, match.CurrentTurn)}.",
|
||||||
|
hideMoveTimer,
|
||||||
|
timeoutButtonText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ChronoSummaryView(
|
||||||
|
match.Config.MatchLabel,
|
||||||
|
subtitle,
|
||||||
|
MatchEngine.FormatClock(match.BlockRemainingMs),
|
||||||
|
hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs),
|
||||||
|
"Trait",
|
||||||
|
MatchEngine.PlayerName(match, match.CurrentTurn),
|
||||||
|
MatchEngine.IsTimeMode(match) ? "Etat du Block" : "Pret",
|
||||||
|
blockHeading,
|
||||||
|
"Demarrez le Block, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.",
|
||||||
|
"Demarrer le Block",
|
||||||
|
$"{blockHeading} pret. {MatchEngine.PlayerName(match, match.CurrentTurn)} commencera.",
|
||||||
|
hideMoveTimer,
|
||||||
|
timeoutButtonText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChronoZoneView BuildZone(string color)
|
||||||
|
{
|
||||||
|
var match = Match!;
|
||||||
|
var active = match.CurrentTurn == color;
|
||||||
|
var clockValue = match.Clocks is not null
|
||||||
|
? color == MatchEngine.ColorWhite ? match.Clocks.White : match.Clocks.Black
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var clockText = match.Clocks is not null
|
||||||
|
? MatchEngine.FormatSignedClock(clockValue)
|
||||||
|
: $"Dernier cube {MatchEngine.RenderLastCube(match, color)}";
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(match.Result))
|
||||||
|
{
|
||||||
|
return new ChronoZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
|
||||||
|
clockText,
|
||||||
|
MatchEngine.ResultText(match),
|
||||||
|
"Le match est termine.",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
match.Clocks is not null,
|
||||||
|
match.Clocks is not null && clockValue < 0,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match.Running)
|
||||||
|
{
|
||||||
|
return new ChronoZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
|
||||||
|
clockText,
|
||||||
|
"Block en pause",
|
||||||
|
active
|
||||||
|
? $"{MatchEngine.GetBlockPhrase(match)} n'a pas encore demarre ou a ete mis en pause."
|
||||||
|
: $"{MatchEngine.PlayerName(match, match.CurrentTurn)} reprendra au demarrage.",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
active,
|
||||||
|
match.Clocks is not null,
|
||||||
|
match.Clocks is not null && clockValue < 0,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!active)
|
||||||
|
{
|
||||||
|
return new ChronoZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
|
||||||
|
clockText,
|
||||||
|
"Attends",
|
||||||
|
$"{MatchEngine.PlayerName(match, match.CurrentTurn)} est en train de jouer.",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
match.Clocks is not null,
|
||||||
|
match.Clocks is not null && clockValue < 0,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.DoubleCoup.Step == 1)
|
||||||
|
{
|
||||||
|
return new ChronoZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
|
||||||
|
clockText,
|
||||||
|
"1er coup gratuit",
|
||||||
|
"Ce coup ne compte pas et ne doit pas donner echec.",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
match.Clocks is not null,
|
||||||
|
match.Clocks is not null && clockValue < 0,
|
||||||
|
match.Clocks is not null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.DoubleCoup.Step == 2)
|
||||||
|
{
|
||||||
|
return new ChronoZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
|
||||||
|
clockText,
|
||||||
|
"2e coup du double",
|
||||||
|
"Ce coup compte dans le quota et l'echec redevient autorise.",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
match.Clocks is not null,
|
||||||
|
match.Clocks is not null && clockValue < 0,
|
||||||
|
match.Clocks is not null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ChronoZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
|
||||||
|
clockText,
|
||||||
|
"J'ai fini mon coup",
|
||||||
|
"Tape des que ton coup est joue sur l'echiquier.",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
match.Clocks is not null,
|
||||||
|
match.Clocks is not null && clockValue < 0,
|
||||||
|
match.Clocks is not null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BoolString(bool value)
|
||||||
|
=> value ? "true" : "false";
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
var route = NormalizeRoute(snapshot.Route);
|
||||||
|
if (!string.Equals(route, "/chrono.html", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo(route, replace: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRoute(string? route)
|
||||||
|
{
|
||||||
|
var normalized = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim();
|
||||||
|
return normalized.StartsWith('/') ? normalized : $"/{normalized}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ChronoSummaryView(
|
||||||
|
string Title,
|
||||||
|
string Subtitle,
|
||||||
|
string BlockTimer,
|
||||||
|
string MoveTimer,
|
||||||
|
string CenterLabel,
|
||||||
|
string CenterValue,
|
||||||
|
string SpineLabel,
|
||||||
|
string SpineHeadline,
|
||||||
|
string SpineText,
|
||||||
|
string PrimaryButtonText,
|
||||||
|
string ArbiterStatus,
|
||||||
|
bool HideMoveTimer,
|
||||||
|
string TimeoutButtonText);
|
||||||
|
|
||||||
|
private sealed record ChronoZoneView(
|
||||||
|
string Name,
|
||||||
|
string Moves,
|
||||||
|
string Clock,
|
||||||
|
string ButtonText,
|
||||||
|
string Hint,
|
||||||
|
bool Disabled,
|
||||||
|
bool ActiveTurn,
|
||||||
|
bool ActiveZone,
|
||||||
|
bool HasPlayerClock,
|
||||||
|
bool NegativeClock,
|
||||||
|
bool ActiveClock);
|
||||||
|
}
|
||||||
877
ChessCubing.App/Pages/CubePage.razor
Normal file
877
ChessCubing.App/Pages/CubePage.razor
Normal file
@@ -0,0 +1,877 @@
|
|||||||
|
@page "/cube"
|
||||||
|
@page "/cube.html"
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
@inject BrowserBridge Browser
|
||||||
|
@inject MatchStore Store
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject SocialRealtimeService Realtime
|
||||||
|
|
||||||
|
<PageTitle>ChessCubing Arena | Phase Cube</PageTitle>
|
||||||
|
<PageBody Page="cube" BodyClass="phase-body" />
|
||||||
|
|
||||||
|
@{
|
||||||
|
var summary = Match is null ? null : BuildSummary();
|
||||||
|
var blackZone = Match is null ? null : BuildZone(MatchEngine.ColorBlack);
|
||||||
|
var whiteZone = Match is null ? null : BuildZone(MatchEngine.ColorWhite);
|
||||||
|
var resultView = Match is null ? null : BuildResultView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!_ready)
|
||||||
|
{
|
||||||
|
<main class="phase-shell cube-shell">
|
||||||
|
<div class="panel" style="padding: 1.5rem;">Chargement de la phase cube...</div>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
else if (Match is not null && summary is not null && blackZone is not null && whiteZone is not null)
|
||||||
|
{
|
||||||
|
<main class="phase-shell cube-shell">
|
||||||
|
<header class="phase-header">
|
||||||
|
<a class="brand-link brand-link-logo" href="application.html" aria-label="Retour a l'application">
|
||||||
|
<img class="brand-link-icon" src="logo.png" alt="Icone ChessCubing" />
|
||||||
|
<img class="brand-link-mark" src="transparent.png" alt="Logo ChessCubing" />
|
||||||
|
</a>
|
||||||
|
<div class="phase-title">
|
||||||
|
<p class="eyebrow">Phase cube</p>
|
||||||
|
<h1 id="cubeTitle">@summary.Title</h1>
|
||||||
|
<p id="cubeSubtitle" class="phase-subtitle">@summary.Subtitle</p>
|
||||||
|
</div>
|
||||||
|
<button class="button ghost small utility-button" id="openCubeHelpButton" type="button" @onclick="OpenHelpModal">
|
||||||
|
Arbitre
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="status-strip">
|
||||||
|
<article class="status-card">
|
||||||
|
<span id="cubeBlockLabelText">Block</span>
|
||||||
|
<strong id="cubeBlockLabel">@Match.BlockNumber</strong>
|
||||||
|
</article>
|
||||||
|
<article class="status-card">
|
||||||
|
<span>Temps max</span>
|
||||||
|
<strong id="cubeElapsed">@MatchEngine.RenderCubeElapsed(Match)</strong>
|
||||||
|
</article>
|
||||||
|
<article class="status-card wide">
|
||||||
|
<span id="cubeCenterLabel">@summary.CenterLabel</span>
|
||||||
|
<strong id="cubeCenterValue">@summary.CenterValue</strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="faceoff-board">
|
||||||
|
<article class="player-zone opponent-zone" id="blackCubeZone">
|
||||||
|
<div class="zone-inner mirrored-mobile">
|
||||||
|
<div class="zone-head">
|
||||||
|
<div>
|
||||||
|
<span class="seat-tag dark-seat">Noir</span>
|
||||||
|
<h2 id="blackNameCube">@blackZone.Name</h2>
|
||||||
|
</div>
|
||||||
|
<div class="zone-stats">
|
||||||
|
<strong id="blackCubeResult">@blackZone.ResultText</strong>
|
||||||
|
<span id="blackCubeCap">@blackZone.MetaText</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="zone-button dark-button @(blackZone.HoldArmed && !blackZone.HoldReady ? "cube-hold-arming" : string.Empty) @(blackZone.HoldReady ? "cube-hold-ready" : string.Empty)" id="blackCubeButton" type="button" disabled="@blackZone.Disabled" style="@blackZone.ProgressStyle" @onpointerdown="() => HandleCubePointerDownAsync(MatchEngine.ColorBlack)" @onpointerup="() => HandleCubePointerUpAsync(MatchEngine.ColorBlack)" @onpointercancel="() => CancelCubeHold(MatchEngine.ColorBlack)" @onpointerleave="() => CancelCubeHold(MatchEngine.ColorBlack)">
|
||||||
|
@blackZone.ButtonText
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="zone-foot" id="blackHintCube">@blackZone.Hint</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="phase-spine">
|
||||||
|
<div class="spine-card">
|
||||||
|
<p class="micro-label" id="cubeSpineLabel">@summary.SpineLabel</p>
|
||||||
|
<strong id="cubeSpineHeadline">@summary.SpineHeadline</strong>
|
||||||
|
<p id="cubeSpineText">@summary.SpineText</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="button primary spine-button" id="primaryCubeButton" type="button" disabled="@summary.PrimaryDisabled" @onclick="HandlePrimaryActionAsync">
|
||||||
|
@summary.PrimaryButtonText
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="player-zone" id="whiteCubeZone">
|
||||||
|
<div class="zone-inner">
|
||||||
|
<div class="zone-head">
|
||||||
|
<div>
|
||||||
|
<span class="seat-tag light-seat">Blanc</span>
|
||||||
|
<h2 id="whiteNameCube">@whiteZone.Name</h2>
|
||||||
|
</div>
|
||||||
|
<div class="zone-stats">
|
||||||
|
<strong id="whiteCubeResult">@whiteZone.ResultText</strong>
|
||||||
|
<span id="whiteCubeCap">@whiteZone.MetaText</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="zone-button light-button @(whiteZone.HoldArmed && !whiteZone.HoldReady ? "cube-hold-arming" : string.Empty) @(whiteZone.HoldReady ? "cube-hold-ready" : string.Empty)" id="whiteCubeButton" type="button" disabled="@whiteZone.Disabled" style="@whiteZone.ProgressStyle" @onpointerdown="() => HandleCubePointerDownAsync(MatchEngine.ColorWhite)" @onpointerup="() => HandleCubePointerUpAsync(MatchEngine.ColorWhite)" @onpointercancel="() => CancelCubeHold(MatchEngine.ColorWhite)" @onpointerleave="() => CancelCubeHold(MatchEngine.ColorWhite)">
|
||||||
|
@whiteZone.ButtonText
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="zone-foot" id="whiteHintCube">@whiteZone.Hint</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section class="modal @(ShowHelpModal ? string.Empty : "hidden")" id="cubeHelpModal" aria-hidden="@BoolString(!ShowHelpModal)">
|
||||||
|
<div class="modal-backdrop" @onclick="CloseHelpModal"></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Outils arbitre</p>
|
||||||
|
<h2>Phase cube</h2>
|
||||||
|
</div>
|
||||||
|
<button class="button ghost small" id="closeCubeHelpButton" type="button" @onclick="CloseHelpModal">
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-copy" id="cubeHelpStatus">@summary.HelpStatus</p>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="button secondary" id="replayCubeButton" type="button" @onclick="ReplayCubeAsync">
|
||||||
|
Rejouer la phase cube
|
||||||
|
</button>
|
||||||
|
<button class="button ghost danger" id="cubeResetButton" type="button" @onclick="ResetMatchAsync">
|
||||||
|
Reinitialiser le match
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="modal @(ShowResultModal && resultView is not null ? string.Empty : "hidden")" id="cubeResultModal" aria-hidden="@BoolString(!(ShowResultModal && resultView is not null))">
|
||||||
|
<div class="modal-backdrop" @onclick="CloseResultModal"></div>
|
||||||
|
@if (resultView is not null)
|
||||||
|
{
|
||||||
|
<div class="modal-card result-modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Fin de phase cube</p>
|
||||||
|
<h2 id="cubeResultModalTitle">@resultView.Title</h2>
|
||||||
|
</div>
|
||||||
|
<button class="button ghost small" id="closeCubeResultButton" type="button" @onclick="CloseResultModal">
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-copy" id="cubeResultSummary">@resultView.Summary</p>
|
||||||
|
|
||||||
|
<div class="cube-result-overview">
|
||||||
|
<article class="result-pill-card">
|
||||||
|
<span>Vainqueur cube</span>
|
||||||
|
<strong id="cubeResultWinner">@resultView.Winner</strong>
|
||||||
|
</article>
|
||||||
|
<article class="result-pill-card">
|
||||||
|
<span>Suite</span>
|
||||||
|
<strong id="cubeResultOutcome">@resultView.Outcome</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cube-result-player-grid">
|
||||||
|
<article class="cube-result-player-card">
|
||||||
|
<span class="seat-tag light-seat">Blanc</span>
|
||||||
|
<strong id="cubeResultWhiteName">@resultView.WhiteName</strong>
|
||||||
|
<span id="cubeResultWhiteTime">@resultView.WhiteTime</span>
|
||||||
|
<span id="cubeResultWhiteDetail">@resultView.WhiteDetail</span>
|
||||||
|
<span id="cubeResultWhiteClock">@resultView.WhiteClock</span>
|
||||||
|
</article>
|
||||||
|
<article class="cube-result-player-card">
|
||||||
|
<span class="seat-tag dark-seat">Noir</span>
|
||||||
|
<strong id="cubeResultBlackName">@resultView.BlackName</strong>
|
||||||
|
<span id="cubeResultBlackTime">@resultView.BlackTime</span>
|
||||||
|
<span id="cubeResultBlackDetail">@resultView.BlackDetail</span>
|
||||||
|
<span id="cubeResultBlackClock">@resultView.BlackClock</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="button primary" id="cubeResultActionButton" type="button" @onclick="ApplyResultAsync">
|
||||||
|
@resultView.ActionLabel
|
||||||
|
</button>
|
||||||
|
<button class="button ghost" id="cubeResultDismissButton" type="button" @onclick="CloseResultModal">
|
||||||
|
Revenir a la phase cube
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly Dictionary<string, CubeHoldState> _holdStates = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[MatchEngine.ColorWhite] = new(),
|
||||||
|
[MatchEngine.ColorBlack] = new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private CancellationTokenSource? _tickerCancellation;
|
||||||
|
private bool _ready;
|
||||||
|
private bool ShowHelpModal;
|
||||||
|
private bool ShowResultModal;
|
||||||
|
private string? _resultModalKey;
|
||||||
|
private long _knownCollaborativeRevision;
|
||||||
|
|
||||||
|
private MatchState? Match => Store.Current;
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Realtime.Changed += HandleRealtimeChanged;
|
||||||
|
await Realtime.EnsureStartedAsync();
|
||||||
|
await Store.EnsureLoadedAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Store.Current?.CollaborationSessionId))
|
||||||
|
{
|
||||||
|
await Realtime.EnsureJoinedPlaySessionAsync(Store.Current.CollaborationSessionId);
|
||||||
|
await ApplyCollaborativeSyncAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ready = true;
|
||||||
|
|
||||||
|
if (Match is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/application.html", replace: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(Match.Result) && Match.Phase != MatchEngine.PhaseCube)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/chrono.html", replace: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickerCancellation = new CancellationTokenSource();
|
||||||
|
_ = RunTickerAsync(_tickerCancellation.Token);
|
||||||
|
await TryPlayPendingAlertAsync();
|
||||||
|
UpdateResultModalState();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_tickerCancellation is not null)
|
||||||
|
{
|
||||||
|
_tickerCancellation.Cancel();
|
||||||
|
_tickerCancellation.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Realtime.Changed -= HandleRealtimeChanged;
|
||||||
|
await Store.FlushIfDueAsync(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleRealtimeChanged()
|
||||||
|
=> _ = InvokeAsync(HandleRealtimeChangedAsync);
|
||||||
|
|
||||||
|
private async Task HandleRealtimeChangedAsync()
|
||||||
|
{
|
||||||
|
await ApplyCollaborativeSyncAsync();
|
||||||
|
UpdateResultModalState();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunTickerAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
|
||||||
|
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(match.Result) && match.Phase != MatchEngine.PhaseCube)
|
||||||
|
{
|
||||||
|
await Store.SaveAsync();
|
||||||
|
await InvokeAsync(() => Navigation.NavigateTo("/chrono.html", replace: true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateResultModalState();
|
||||||
|
await Store.FlushIfDueAsync();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenHelpModal()
|
||||||
|
=> ShowHelpModal = true;
|
||||||
|
|
||||||
|
private void CloseHelpModal()
|
||||||
|
=> ShowHelpModal = false;
|
||||||
|
|
||||||
|
private void CloseResultModal()
|
||||||
|
=> ShowResultModal = false;
|
||||||
|
|
||||||
|
private async Task HandlePrimaryActionAsync()
|
||||||
|
{
|
||||||
|
if (Match is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(Match.Result))
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/application.html");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Match.Phase != MatchEngine.PhaseCube)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/chrono.html", replace: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildResultView() is not null)
|
||||||
|
{
|
||||||
|
ShowResultModal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCubePointerDownAsync(string color)
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Browser.PrimeAudioAsync();
|
||||||
|
await TryPlayPendingAlertAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseCube)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black) is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black;
|
||||||
|
if (playerState.Running)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_holdStates[color].Armed = true;
|
||||||
|
_holdStates[color].StartedAt = MatchEngine.NowUnixMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCubePointerUpAsync(string color)
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Browser.PrimeAudioAsync();
|
||||||
|
await TryPlayPendingAlertAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseCube)
|
||||||
|
{
|
||||||
|
CancelCubeHold(color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black) is not null)
|
||||||
|
{
|
||||||
|
CancelCubeHold(color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black;
|
||||||
|
if (playerState.Running)
|
||||||
|
{
|
||||||
|
MatchEngine.CaptureCubeTime(match, color);
|
||||||
|
CancelCubeHold(color);
|
||||||
|
await PersistCubeAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ready = IsHoldReady(color);
|
||||||
|
CancelCubeHold(color);
|
||||||
|
if (!ready)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchEngine.StartCubeTimer(match, color);
|
||||||
|
await PersistCubeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelCubeHold(string color)
|
||||||
|
{
|
||||||
|
_holdStates[color].Armed = false;
|
||||||
|
_holdStates[color].StartedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyResultAsync()
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
var resultView = BuildResultView();
|
||||||
|
if (match is null || resultView is null)
|
||||||
|
{
|
||||||
|
ShowResultModal = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultView.ReplayRequired)
|
||||||
|
{
|
||||||
|
ShowResultModal = false;
|
||||||
|
_resultModalKey = null;
|
||||||
|
MatchEngine.ReplayCubePhase(match);
|
||||||
|
await PersistCubeAsync();
|
||||||
|
await TryPlayPendingAlertAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShowResultModal = false;
|
||||||
|
MatchEngine.ApplyCubeOutcome(match);
|
||||||
|
Store.MarkDirty();
|
||||||
|
await Store.SaveAsync();
|
||||||
|
await Realtime.PublishMatchStateAsync(match, "/chrono.html");
|
||||||
|
Navigation.NavigateTo("/chrono.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReplayCubeAsync()
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null || match.Phase != MatchEngine.PhaseCube)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShowResultModal = false;
|
||||||
|
_resultModalKey = null;
|
||||||
|
MatchEngine.ReplayCubePhase(match);
|
||||||
|
await PersistCubeAsync();
|
||||||
|
await TryPlayPendingAlertAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetMatchAsync()
|
||||||
|
{
|
||||||
|
var sessionId = Match?.CollaborationSessionId;
|
||||||
|
await Store.ClearAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
await Realtime.PublishMatchStateAsync(null, "/application.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigation.NavigateTo("/application.html", replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistCubeAsync()
|
||||||
|
{
|
||||||
|
Store.MarkDirty();
|
||||||
|
await Store.SaveAsync();
|
||||||
|
await Realtime.PublishMatchStateAsync(Match, "/cube.html");
|
||||||
|
UpdateResultModalState();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryPlayPendingAlertAsync()
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null || !match.Cube.PhaseAlertPending)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await Browser.PlayCubePhaseAlertAsync())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match.Cube.PhaseAlertPending = false;
|
||||||
|
Store.MarkDirty();
|
||||||
|
await Store.SaveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CubeSummaryView BuildSummary()
|
||||||
|
{
|
||||||
|
var match = Match!;
|
||||||
|
var blockHeading = MatchEngine.FormatBlockHeading(match, match.BlockNumber);
|
||||||
|
var subtitle = $"{blockHeading} - {MatchEngine.Modes[match.Config.Mode].Label} - {MatchEngine.RenderModeContext(match)}";
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(match.Result))
|
||||||
|
{
|
||||||
|
return new CubeSummaryView(
|
||||||
|
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
|
||||||
|
subtitle,
|
||||||
|
"Resultat",
|
||||||
|
MatchEngine.ResultText(match),
|
||||||
|
"Termine",
|
||||||
|
MatchEngine.ResultText(match),
|
||||||
|
"Retournez a la configuration pour relancer une rencontre.",
|
||||||
|
"Retour a l'accueil",
|
||||||
|
"Le match est termine.",
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Cube.Times.White is not null && match.Cube.Times.Black is not null && match.Config.Mode == MatchEngine.ModeTwice && match.Cube.Times.White == match.Cube.Times.Black)
|
||||||
|
{
|
||||||
|
return new CubeSummaryView(
|
||||||
|
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
|
||||||
|
subtitle,
|
||||||
|
"Decision",
|
||||||
|
"Egalite parfaite",
|
||||||
|
"Reglement",
|
||||||
|
"Rejouer la phase cube",
|
||||||
|
"Le mode Twice impose de relancer immediatement la phase cube en cas d'egalite parfaite.",
|
||||||
|
"Voir le resume du cube",
|
||||||
|
"Le mode Twice impose de relancer immediatement la phase cube en cas d'egalite parfaite.",
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Cube.Times.White is not null && match.Cube.Times.Black is not null)
|
||||||
|
{
|
||||||
|
var preview = match.Config.Mode == MatchEngine.ModeTime
|
||||||
|
? MatchEngine.GetTimeAdjustmentPreview(match, match.Cube.Times.White.Value, match.Cube.Times.Black.Value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return preview is null
|
||||||
|
? new CubeSummaryView(
|
||||||
|
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
|
||||||
|
subtitle,
|
||||||
|
"Decision",
|
||||||
|
"Phase cube complete",
|
||||||
|
"Suite",
|
||||||
|
"Ouvrir la page chrono",
|
||||||
|
"Appliquer le resultat du cube pour preparer le Block suivant.",
|
||||||
|
"Voir le resume du cube",
|
||||||
|
"Appliquer le resultat du cube pour preparer le Block suivant.",
|
||||||
|
false)
|
||||||
|
: new CubeSummaryView(
|
||||||
|
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
|
||||||
|
subtitle,
|
||||||
|
"Vainqueur cube",
|
||||||
|
preview.Winner is not null ? MatchEngine.PlayerName(match, preview.Winner) : "Egalite",
|
||||||
|
"Impact chrono",
|
||||||
|
preview.BlockType == "minus" ? "Bloc - a appliquer" : "Bloc + a appliquer",
|
||||||
|
$"Blanc {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)} -> {MatchEngine.FormatSignedClock(preview.WhiteAfter)}. Noir {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)} -> {MatchEngine.FormatSignedClock(preview.BlackAfter)}.",
|
||||||
|
"Voir le resume du cube",
|
||||||
|
$"Blanc {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)} -> {MatchEngine.FormatSignedClock(preview.WhiteAfter)}. Noir {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)} -> {MatchEngine.FormatSignedClock(preview.BlackAfter)}.",
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Cube.Running)
|
||||||
|
{
|
||||||
|
return new CubeSummaryView(
|
||||||
|
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
|
||||||
|
subtitle,
|
||||||
|
"Etat",
|
||||||
|
"Chronos lances",
|
||||||
|
"Arrets",
|
||||||
|
"Chaque joueur se chronometre",
|
||||||
|
"Chaque joueur demarre en relachant sa zone, puis retape sa zone une fois le cube termine.",
|
||||||
|
"Attendre les deux temps",
|
||||||
|
"Chaque joueur demarre en relachant sa zone, puis retape sa zone une fois le cube termine.",
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Cube.Times.White is not null || match.Cube.Times.Black is not null)
|
||||||
|
{
|
||||||
|
return new CubeSummaryView(
|
||||||
|
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
|
||||||
|
subtitle,
|
||||||
|
"Etat",
|
||||||
|
"Un temps saisi",
|
||||||
|
"Suite",
|
||||||
|
"Attendre l'autre joueur",
|
||||||
|
"Le deuxieme joueur peut encore maintenir puis relacher sa zone pour demarrer son propre chrono.",
|
||||||
|
"Attendre le deuxieme temps",
|
||||||
|
"Le deuxieme joueur peut encore maintenir puis relacher sa zone pour demarrer son propre chrono.",
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CubeSummaryView(
|
||||||
|
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
|
||||||
|
subtitle,
|
||||||
|
"Etat",
|
||||||
|
"Pret",
|
||||||
|
"Depart libre",
|
||||||
|
"Chaque joueur lance son chrono",
|
||||||
|
"Au debut de sa resolution, chaque joueur maintient sa grande zone puis la relache pour demarrer son propre chrono.",
|
||||||
|
"En attente des joueurs",
|
||||||
|
"Au debut de sa resolution, chaque joueur maintient sa grande zone puis la relache pour demarrer son propre chrono.",
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CubeZoneView BuildZone(string color)
|
||||||
|
{
|
||||||
|
var match = Match!;
|
||||||
|
var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black;
|
||||||
|
var time = color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black;
|
||||||
|
var hold = _holdStates[color];
|
||||||
|
var holdReady = IsHoldReady(color);
|
||||||
|
var holdArmed = hold.Armed && !holdReady && !playerState.Running && time is null && match.Phase == MatchEngine.PhaseCube && string.IsNullOrEmpty(match.Result);
|
||||||
|
var holdProgress = hold.Armed && time is null && !playerState.Running
|
||||||
|
? Math.Min((MatchEngine.NowUnixMs() - hold.StartedAt) / (double)MatchEngine.CubeStartHoldMs, 1d)
|
||||||
|
: 0d;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(match.Result))
|
||||||
|
{
|
||||||
|
return new CubeZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
MatchEngine.FormatCubePlayerTime(match, color),
|
||||||
|
MatchEngine.RenderCubeMeta(match, color),
|
||||||
|
MatchEngine.ResultText(match),
|
||||||
|
"Le match est termine.",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"--cube-hold-progress: 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Phase != MatchEngine.PhaseCube)
|
||||||
|
{
|
||||||
|
return new CubeZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
MatchEngine.FormatCubePlayerTime(match, color),
|
||||||
|
MatchEngine.RenderCubeMeta(match, color),
|
||||||
|
"Retour chrono",
|
||||||
|
"La page cube est terminee.",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"--cube-hold-progress: 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time is not null)
|
||||||
|
{
|
||||||
|
return new CubeZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
MatchEngine.FormatCubePlayerTime(match, color),
|
||||||
|
MatchEngine.RenderCubeMeta(match, color),
|
||||||
|
"Temps enregistre",
|
||||||
|
"Ce joueur a deja termine son cube.",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"--cube-hold-progress: 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerState.Running)
|
||||||
|
{
|
||||||
|
return new CubeZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
MatchEngine.FormatCubePlayerTime(match, color),
|
||||||
|
MatchEngine.RenderCubeMeta(match, color),
|
||||||
|
"J'ai fini le cube",
|
||||||
|
"Tape au moment exact ou le cube est resolu.",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"--cube-hold-progress: 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (holdReady)
|
||||||
|
{
|
||||||
|
return new CubeZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
MatchEngine.FormatCubePlayerTime(match, color),
|
||||||
|
MatchEngine.RenderCubeMeta(match, color),
|
||||||
|
"Relachez pour demarrer",
|
||||||
|
"Le chrono partira des que vous levez le doigt.",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
"--cube-hold-progress: 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (holdArmed)
|
||||||
|
{
|
||||||
|
return new CubeZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
MatchEngine.FormatCubePlayerTime(match, color),
|
||||||
|
MatchEngine.RenderCubeMeta(match, color),
|
||||||
|
"Maintenez 2 s...",
|
||||||
|
"Gardez le doigt pose 2 secondes, jusqu'a la fin de la barre.",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
$"--cube-hold-progress: {holdProgress.ToString("0.###", CultureInfo.InvariantCulture)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CubeZoneView(
|
||||||
|
MatchEngine.PlayerName(match, color),
|
||||||
|
MatchEngine.FormatCubePlayerTime(match, color),
|
||||||
|
MatchEngine.RenderCubeMeta(match, color),
|
||||||
|
"Maintenir 2 s pour demarrer",
|
||||||
|
"Maintenez la grande zone 2 secondes, puis relachez pour lancer votre chrono.",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"--cube-hold-progress: 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
private CubeResultView? BuildResultView()
|
||||||
|
{
|
||||||
|
var match = Match;
|
||||||
|
if (match is null || match.Phase != MatchEngine.PhaseCube)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var white = match.Cube.Times.White;
|
||||||
|
var black = match.Cube.Times.Black;
|
||||||
|
if (white is null || black is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var whiteName = MatchEngine.PlayerName(match, MatchEngine.ColorWhite);
|
||||||
|
var blackName = MatchEngine.PlayerName(match, MatchEngine.ColorBlack);
|
||||||
|
|
||||||
|
if (match.Config.Mode == MatchEngine.ModeTime)
|
||||||
|
{
|
||||||
|
var preview = MatchEngine.GetTimeAdjustmentPreview(match, white.Value, black.Value);
|
||||||
|
if (preview is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CubeResultView(
|
||||||
|
$"time:{match.BlockNumber}:{match.Cube.Round}:{white}:{black}",
|
||||||
|
"Resume du cube",
|
||||||
|
preview.Winner is not null ? MatchEngine.PlayerName(match, preview.Winner) : "Egalite",
|
||||||
|
preview.BlockType == "minus" ? "Bloc - a appliquer" : "Bloc + a appliquer",
|
||||||
|
"Validez ce resume pour appliquer les impacts chrono puis revenir a la page chrono.",
|
||||||
|
"Appliquer et ouvrir la page chrono",
|
||||||
|
whiteName,
|
||||||
|
blackName,
|
||||||
|
$"Temps cube {MatchEngine.FormatStopwatch(white.Value)}",
|
||||||
|
$"Temps cube {MatchEngine.FormatStopwatch(black.Value)}",
|
||||||
|
$"Impact chrono {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)}",
|
||||||
|
$"Impact chrono {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)}",
|
||||||
|
$"Chrono apres {MatchEngine.FormatSignedClock(preview.WhiteAfter)}",
|
||||||
|
$"Chrono apres {MatchEngine.FormatSignedClock(preview.BlackAfter)}",
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var winner = white < black ? MatchEngine.ColorWhite : black < white ? MatchEngine.ColorBlack : null;
|
||||||
|
var tie = winner is null;
|
||||||
|
return new CubeResultView(
|
||||||
|
$"twice:{match.BlockNumber}:{match.Cube.Round}:{white}:{black}",
|
||||||
|
tie ? "Egalite parfaite" : "Resume du cube",
|
||||||
|
winner is not null ? MatchEngine.PlayerName(match, winner) : "Egalite parfaite",
|
||||||
|
tie ? "Rejouer la phase cube" : $"{MatchEngine.PlayerName(match, winner!)} ouvrira le Block suivant",
|
||||||
|
tie ? "Le reglement Twice impose de rejouer immediatement la phase cube." : "Validez ce resultat pour preparer le Block suivant.",
|
||||||
|
tie ? "Rejouer la phase cube" : "Appliquer et ouvrir la page chrono",
|
||||||
|
whiteName,
|
||||||
|
blackName,
|
||||||
|
$"Temps cube {MatchEngine.FormatStopwatch(white.Value)}",
|
||||||
|
$"Temps cube {MatchEngine.FormatStopwatch(black.Value)}",
|
||||||
|
winner == MatchEngine.ColorWhite ? "Gagne la phase cube" : tie ? "Egalite parfaite" : "Ne gagne pas la phase cube",
|
||||||
|
winner == MatchEngine.ColorBlack ? "Gagne la phase cube" : tie ? "Egalite parfaite" : "Ne gagne pas la phase cube",
|
||||||
|
"Aucun impact chrono en mode Twice",
|
||||||
|
"Aucun impact chrono en mode Twice",
|
||||||
|
tie);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateResultModalState()
|
||||||
|
{
|
||||||
|
var resultView = BuildResultView();
|
||||||
|
if (resultView is null)
|
||||||
|
{
|
||||||
|
ShowResultModal = false;
|
||||||
|
_resultModalKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_resultModalKey != resultView.Key)
|
||||||
|
{
|
||||||
|
_resultModalKey = resultView.Key;
|
||||||
|
ShowResultModal = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsHoldReady(string color)
|
||||||
|
=> _holdStates[color].Armed && MatchEngine.NowUnixMs() - _holdStates[color].StartedAt >= MatchEngine.CubeStartHoldMs;
|
||||||
|
|
||||||
|
private static string BoolString(bool value)
|
||||||
|
=> value ? "true" : "false";
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
var route = NormalizeRoute(snapshot.Route);
|
||||||
|
if (!string.Equals(route, "/cube.html", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo(route, replace: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRoute(string? route)
|
||||||
|
{
|
||||||
|
var normalized = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim();
|
||||||
|
return normalized.StartsWith('/') ? normalized : $"/{normalized}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CubeHoldState
|
||||||
|
{
|
||||||
|
public bool Armed { get; set; }
|
||||||
|
public long StartedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CubeSummaryView(
|
||||||
|
string Title,
|
||||||
|
string Subtitle,
|
||||||
|
string CenterLabel,
|
||||||
|
string CenterValue,
|
||||||
|
string SpineLabel,
|
||||||
|
string SpineHeadline,
|
||||||
|
string SpineText,
|
||||||
|
string PrimaryButtonText,
|
||||||
|
string HelpStatus,
|
||||||
|
bool PrimaryDisabled);
|
||||||
|
|
||||||
|
private sealed record CubeZoneView(
|
||||||
|
string Name,
|
||||||
|
string ResultText,
|
||||||
|
string MetaText,
|
||||||
|
string ButtonText,
|
||||||
|
string Hint,
|
||||||
|
bool Disabled,
|
||||||
|
bool HoldArmed,
|
||||||
|
bool HoldReady,
|
||||||
|
string ProgressStyle);
|
||||||
|
|
||||||
|
private sealed record CubeResultView(
|
||||||
|
string Key,
|
||||||
|
string Title,
|
||||||
|
string Winner,
|
||||||
|
string Outcome,
|
||||||
|
string Summary,
|
||||||
|
string ActionLabel,
|
||||||
|
string WhiteName,
|
||||||
|
string BlackName,
|
||||||
|
string WhiteTime,
|
||||||
|
string BlackTime,
|
||||||
|
string WhiteDetail,
|
||||||
|
string BlackDetail,
|
||||||
|
string WhiteClock,
|
||||||
|
string BlackClock,
|
||||||
|
bool ReplayRequired);
|
||||||
|
}
|
||||||
239
ChessCubing.App/Pages/Home.razor
Normal file
239
ChessCubing.App/Pages/Home.razor
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
@page "/"
|
||||||
|
@page "/index.html"
|
||||||
|
|
||||||
|
<PageTitle>ChessCubing Arena | Accueil</PageTitle>
|
||||||
|
<PageBody BodyClass="home-body" />
|
||||||
|
|
||||||
|
<div class="ambient ambient-left"></div>
|
||||||
|
<div class="ambient ambient-right"></div>
|
||||||
|
|
||||||
|
<div class="rules-shell">
|
||||||
|
<header class="hero hero-home">
|
||||||
|
<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">Association ChessCubing</p>
|
||||||
|
<h1>Les echecs rencontrent le Rubik's Cube</h1>
|
||||||
|
<p class="lead">
|
||||||
|
ChessCubing propose un jeu hybride simple a comprendre, intense a
|
||||||
|
vivre et tres plaisant a regarder. On joue une partie d'echecs, on
|
||||||
|
passe par une phase cube obligatoire, puis la partie repart avec un
|
||||||
|
nouveau rythme.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="button primary" href="application.html">Ouvrir l'application</a>
|
||||||
|
<a class="button secondary" href="reglement.html">Lire le reglement</a>
|
||||||
|
<a class="button ghost" href="/ethan/">Ouvrir l'appli d'Ethan</a>
|
||||||
|
<a class="button ghost" href="/brice/">Ouvrir l'appli de Brice</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="hero-preview">
|
||||||
|
<div class="preview-card">
|
||||||
|
<p class="micro-label">Le principe</p>
|
||||||
|
<ol class="phase-list">
|
||||||
|
<li>Deux joueurs disputent une partie d'echecs courte et nerveuse.</li>
|
||||||
|
<li>Une phase cube coupe le rythme et relance la tension.</li>
|
||||||
|
<li>Le resultat du cube influence immediatement la suite du match.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div class="preview-banner">
|
||||||
|
<span class="mini-chip">Decouverte rapide</span>
|
||||||
|
<strong>On comprend en quelques minutes</strong>
|
||||||
|
<p>
|
||||||
|
Le format donne envie de jouer meme quand on decouvre seulement
|
||||||
|
l'un des deux univers. On peut venir pour les echecs, pour le
|
||||||
|
cube, ou simplement pour l'energie du duel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="rules-grid">
|
||||||
|
<section class="panel panel-wide">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">L'association</p>
|
||||||
|
<h2>Une passerelle entre deux passions</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-copy">
|
||||||
|
L'association ChessCubing veut creer des moments de rencontre, de
|
||||||
|
decouverte et de jeu partage autour d'un format hybride.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-story-grid">
|
||||||
|
<article class="stage-card">
|
||||||
|
<span class="micro-label">Partager</span>
|
||||||
|
<strong>Faire decouvrir un format original</strong>
|
||||||
|
<p>
|
||||||
|
L'idee est simple : reunir des profils differents autour d'un
|
||||||
|
jeu lisible, vivant et immediatement intriguant.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="stage-card">
|
||||||
|
<span class="micro-label">Rassembler</span>
|
||||||
|
<strong>Creer un terrain commun</strong>
|
||||||
|
<p>
|
||||||
|
Joueurs d'echecs, cubers, curieux, familles ou clubs peuvent se
|
||||||
|
retrouver dans une experience facile a lancer et a commenter.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="stage-card">
|
||||||
|
<span class="micro-label">Animer</span>
|
||||||
|
<strong>Donner envie de revenir jouer</strong>
|
||||||
|
<p>
|
||||||
|
Le melange de reflexion, de vitesse et de rebondissements donne
|
||||||
|
au format un cote spectaculaire qui marche tres bien en
|
||||||
|
initiation comme en evenement.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel-half">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Le jeu en simple</p>
|
||||||
|
<h2>Comment se passe une partie</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-mini-grid">
|
||||||
|
<article class="mini-panel">
|
||||||
|
<span class="micro-label">1</span>
|
||||||
|
<strong>On joue une partie d'echecs</strong>
|
||||||
|
<p>
|
||||||
|
La partie avance par sequences courtes, ce qui garde un tres bon
|
||||||
|
rythme et rend l'action facile a suivre.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="mini-panel">
|
||||||
|
<span class="micro-label">2</span>
|
||||||
|
<strong>On resout un cube</strong>
|
||||||
|
<p>
|
||||||
|
Les deux joueurs enchainent avec une phase cube obligatoire qui
|
||||||
|
sert de relance, de pression et de bascule.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="mini-panel">
|
||||||
|
<span class="micro-label">3</span>
|
||||||
|
<strong>Le match repart</strong>
|
||||||
|
<p>
|
||||||
|
Selon le mode choisi, la phase cube donne l'initiative ou agit
|
||||||
|
sur les chronos. Le duel ne retombe jamais.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel-half">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Pourquoi ca plait</p>
|
||||||
|
<h2>Un format qui donne envie d'essayer</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-mini-grid two-columns">
|
||||||
|
<article class="mini-panel">
|
||||||
|
<strong>Tres lisible</strong>
|
||||||
|
<p>On comprend vite quand la pression monte et pourquoi le cube change tout.</p>
|
||||||
|
</article>
|
||||||
|
<article class="mini-panel">
|
||||||
|
<strong>Toujours relance</strong>
|
||||||
|
<p>Chaque phase cube remet du suspense dans la partie et ouvre de nouvelles options.</p>
|
||||||
|
</article>
|
||||||
|
<article class="mini-panel">
|
||||||
|
<strong>Convivial</strong>
|
||||||
|
<p>Le format marche bien en initiation, en animation de club ou en demonstration publique.</p>
|
||||||
|
</article>
|
||||||
|
<article class="mini-panel">
|
||||||
|
<strong>Memorable</strong>
|
||||||
|
<p>On ressort d'une partie avec des coups, des temps et des retournements dont on se souvient.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel-wide">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Deux formats</p>
|
||||||
|
<h2>Deux manieres de vivre le duel</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-copy">
|
||||||
|
Le reglement officiel propose deux lectures du meme concept, l'une
|
||||||
|
plus orientee initiative, l'autre plus orientee gestion du temps.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rules-compare">
|
||||||
|
<article class="format-card twice-card">
|
||||||
|
<div class="format-head">
|
||||||
|
<span class="mini-chip">ChessCubing Twice</span>
|
||||||
|
<h3>Le cube donne l'elan</h3>
|
||||||
|
<p>
|
||||||
|
Le joueur le plus rapide sur la phase cube prend le depart de
|
||||||
|
la partie suivante et peut meme obtenir un double coup dans
|
||||||
|
certaines situations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="format-badges">
|
||||||
|
<span>Mode nerveux</span>
|
||||||
|
<span>Initiative forte</span>
|
||||||
|
<span>Effet immediat</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="format-card time-card">
|
||||||
|
<div class="format-head">
|
||||||
|
<span class="mini-chip">ChessCubing Time</span>
|
||||||
|
<h3>Le cube agit sur les chronos</h3>
|
||||||
|
<p>
|
||||||
|
Ici, on garde le trait mais la performance sur le cube retire
|
||||||
|
ou ajoute du temps selon l'alternance des blocs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="format-badges">
|
||||||
|
<span>Gestion de temps</span>
|
||||||
|
<span>Blocs - et +</span>
|
||||||
|
<span>Suspense permanent</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel-wide cta-panel">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Envie de tester</p>
|
||||||
|
<h2>Choisis ton entree</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-copy">
|
||||||
|
Tu peux decouvrir le jeu tranquillement avec la page reglement ou
|
||||||
|
aller directement vers l'application officielle de match.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="source-grid">
|
||||||
|
<a class="source-card" href="application.html">
|
||||||
|
<span class="micro-label">Application</span>
|
||||||
|
<strong>Ouvrir la partie arbitrage</strong>
|
||||||
|
<p>Configurer une rencontre, lancer le chrono et enchainer avec la phase cube.</p>
|
||||||
|
</a>
|
||||||
|
<a class="source-card" href="reglement.html">
|
||||||
|
<span class="micro-label">Reglement</span>
|
||||||
|
<strong>Comprendre les formats officiels</strong>
|
||||||
|
<p>Retrouver une presentation claire du Twice, du Time et des regles de match.</p>
|
||||||
|
</a>
|
||||||
|
<a class="source-card" href="ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" target="_blank">
|
||||||
|
<span class="micro-label">PDF officiel</span>
|
||||||
|
<strong>Lire le texte source</strong>
|
||||||
|
<p>Consulter directement le document officiel si tu veux tous les details.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
39
ChessCubing.App/Pages/NotFound.razor
Normal file
39
ChessCubing.App/Pages/NotFound.razor
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
@page "/not-found"
|
||||||
|
|
||||||
|
<PageTitle>ChessCubing Arena | Page introuvable</PageTitle>
|
||||||
|
<PageBody BodyClass="home-body" />
|
||||||
|
|
||||||
|
<div class="ambient ambient-left"></div>
|
||||||
|
<div class="ambient ambient-right"></div>
|
||||||
|
|
||||||
|
<div class="rules-shell">
|
||||||
|
<section class="panel panel-wide cta-panel" style="margin-top: 2rem;">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Navigation</p>
|
||||||
|
<h1>Page introuvable</h1>
|
||||||
|
</div>
|
||||||
|
<p class="section-copy">
|
||||||
|
Le lien demande n'existe pas ou n'est plus expose par l'application Blazor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="source-grid">
|
||||||
|
<a class="source-card" href="index.html">
|
||||||
|
<span class="micro-label">Accueil</span>
|
||||||
|
<strong>Retourner au site</strong>
|
||||||
|
<p>Revenir a la page d'accueil de ChessCubing Arena.</p>
|
||||||
|
</a>
|
||||||
|
<a class="source-card" href="application.html">
|
||||||
|
<span class="micro-label">Application</span>
|
||||||
|
<strong>Ouvrir l'arbitrage</strong>
|
||||||
|
<p>Configurer un match et basculer vers les phases chrono et cube.</p>
|
||||||
|
</a>
|
||||||
|
<a class="source-card" href="reglement.html">
|
||||||
|
<span class="micro-label">Reglement</span>
|
||||||
|
<strong>Lire les formats officiels</strong>
|
||||||
|
<p>Retrouver la synthese des reglements Twice et Time.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
344
ChessCubing.App/Pages/RulesPage.razor
Normal file
344
ChessCubing.App/Pages/RulesPage.razor
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
@page "/reglement"
|
||||||
|
@page "/reglement.html"
|
||||||
|
|
||||||
|
<PageTitle>ChessCubing Arena | Reglement officiel</PageTitle>
|
||||||
|
<PageBody BodyClass="rules-body" />
|
||||||
|
|
||||||
|
<div class="ambient ambient-left"></div>
|
||||||
|
<div class="ambient ambient-right"></div>
|
||||||
|
|
||||||
|
<div class="rules-shell">
|
||||||
|
<header class="hero hero-rules">
|
||||||
|
<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">Referentiel officiel</p>
|
||||||
|
<h1>Reglement ChessCubing</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Cette page reprend les regles officielles en vigueur du
|
||||||
|
ChessCubing Twice et du ChessCubing Time pour offrir une lecture
|
||||||
|
rapide, claire et directement exploitable en club, en demonstration
|
||||||
|
ou en arbitrage.
|
||||||
|
</p>
|
||||||
|
<div class="hero-pills">
|
||||||
|
<span>Twice V2</span>
|
||||||
|
<span>Time V1</span>
|
||||||
|
<span>Entree en vigueur le 4 fevrier 2026</span>
|
||||||
|
<span>Fin uniquement par mat ou abandon</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="button primary" href="application.html">Ouvrir l'application</a>
|
||||||
|
<a class="button secondary" href="index.html">Retour a l'accueil</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="hero-preview">
|
||||||
|
<div class="preview-card">
|
||||||
|
<p class="micro-label">Reperes rapides</p>
|
||||||
|
<div class="rule-metrics">
|
||||||
|
<article class="metric-chip">
|
||||||
|
<span>Partie</span>
|
||||||
|
<strong>180 s</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-chip">
|
||||||
|
<span>Cube</span>
|
||||||
|
<strong>Obligatoire</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-chip">
|
||||||
|
<span>Quotas</span>
|
||||||
|
<strong>6 / 8 / 10</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-chip">
|
||||||
|
<span>Chrono cube</span>
|
||||||
|
<strong>Cap 120 s en Time</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-banner">
|
||||||
|
<span class="mini-chip">Deux formats</span>
|
||||||
|
<strong>Meme structure, effets differents</strong>
|
||||||
|
<p>
|
||||||
|
Le mode Twice donne l'initiative au gagnant du cube et peut
|
||||||
|
declencher un double coup. Le mode Time ajuste les chronos sur
|
||||||
|
des blocs - et des blocs +, sans priorite ni double coup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="rules-grid">
|
||||||
|
<section class="panel panel-wide">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Vue d'ensemble</p>
|
||||||
|
<h2>Le match en 4 temps</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-copy">
|
||||||
|
Les deux reglements partagent la meme colonne vertebrale : un
|
||||||
|
match en parties successives, interrompues par une phase cube
|
||||||
|
obligatoire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rules-stage-grid">
|
||||||
|
<article class="stage-card">
|
||||||
|
<span class="micro-label">1. Avant le match</span>
|
||||||
|
<strong>Installation et tirage</strong>
|
||||||
|
<p>
|
||||||
|
L'arbitre controle le materiel, les cubes, les caches, les
|
||||||
|
melanges, puis le tirage au sort qui attribue Blancs ou Noirs.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="stage-card">
|
||||||
|
<span class="micro-label">2. Partie d'echecs</span>
|
||||||
|
<strong>180 secondes de jeu</strong>
|
||||||
|
<p>
|
||||||
|
Chaque partie comporte une phase d'echecs limitee par une duree
|
||||||
|
fixe et par un quota de coups selon le format FAST, FREEZE ou
|
||||||
|
MASTERS.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="stage-card">
|
||||||
|
<span class="micro-label">3. Phase cube</span>
|
||||||
|
<strong>Un cube identique par joueur</strong>
|
||||||
|
<p>
|
||||||
|
L'application designe le numero du cube, les deux joueurs
|
||||||
|
resolvent le meme melange, et le resultat influe ensuite selon
|
||||||
|
le mode choisi.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="stage-card">
|
||||||
|
<span class="micro-label">4. Fin de partie</span>
|
||||||
|
<strong>Jamais au temps</strong>
|
||||||
|
<p>
|
||||||
|
La partie se termine uniquement par echec et mat ou abandon,
|
||||||
|
jamais par simple chute au temps ou depassement d'une partie.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel-half">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Materiel commun</p>
|
||||||
|
<h2>Base officielle</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="rule-list">
|
||||||
|
<li>Un echiquier et un jeu de pieces reglementaires.</li>
|
||||||
|
<li>Huit Rubik's Cubes 3x3, soit quatre par joueur.</li>
|
||||||
|
<li>Des caches opaques numerotes de 1 a 4.</li>
|
||||||
|
<li>Des melanges strictement identiques pour chaque numero.</li>
|
||||||
|
<li>L'application officielle ChessCubing.</li>
|
||||||
|
<li>Un arbitre pour piloter le match et les transitions.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel-half">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Arbitrage</p>
|
||||||
|
<h2>Check-list terrain</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="rule-list">
|
||||||
|
<li>Verifier la presence des huit cubes et des caches numerotes.</li>
|
||||||
|
<li>Confirmer des melanges identiques sous chaque numero.</li>
|
||||||
|
<li>Preparer l'echiquier et la variante dans l'application.</li>
|
||||||
|
<li>Controler le tirage au sort avant la premiere partie.</li>
|
||||||
|
<li>Declencher chaque phase cube au bon moment.</li>
|
||||||
|
<li>Surveiller le respect du plafond de 120 s en mode Time.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel-wide" id="formats">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Formats officiels</p>
|
||||||
|
<h2>Twice et Time, cote a cote</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-copy">
|
||||||
|
Les deux formats partagent les parties et la phase cube, mais leur
|
||||||
|
logique d'avantage differe completement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rules-compare">
|
||||||
|
<article class="format-card twice-card">
|
||||||
|
<div class="format-head">
|
||||||
|
<span class="mini-chip">Version V2</span>
|
||||||
|
<h3>ChessCubing Twice</h3>
|
||||||
|
<p>
|
||||||
|
Le gagnant du cube obtient l'initiative sur la partie suivante,
|
||||||
|
avec une regle de double coup encadree.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="format-badges">
|
||||||
|
<span>Partie : 180 s</span>
|
||||||
|
<span>Temps par coup : 20 s max</span>
|
||||||
|
<span>FAST / FREEZE / MASTERS : 6 / 8 / 10</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="format-section">
|
||||||
|
<h4>Debut et fin des parties</h4>
|
||||||
|
<ul class="rule-list compact">
|
||||||
|
<li>Les Blancs commencent la partie 1.</li>
|
||||||
|
<li>Aucun double coup n'est possible a la partie 1.</li>
|
||||||
|
<li>Une partie s'arrete a 180 s ou quand les deux quotas sont atteints.</li>
|
||||||
|
<li>Il est interdit de finir une partie avec un roi en echec.</li>
|
||||||
|
<li>Si le dernier coup donne echec, les coups necessaires pour parer sont joues hors quota.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="format-section">
|
||||||
|
<h4>Phase cube</h4>
|
||||||
|
<ul class="rule-list compact">
|
||||||
|
<li>Le numero du cube est designe par l'application.</li>
|
||||||
|
<li>Les deux joueurs recoivent un melange identique.</li>
|
||||||
|
<li>Le joueur le plus rapide gagne la phase cube.</li>
|
||||||
|
<li>En cas d'egalite parfaite, la phase cube est rejouee.</li>
|
||||||
|
<li>Le gagnant du cube commence la partie suivante.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<span class="micro-label">Double coup V2</span>
|
||||||
|
<strong>Condition stricte</strong>
|
||||||
|
<p>
|
||||||
|
Le gagnant du cube ne doit pas avoir joue le dernier coup de
|
||||||
|
la partie precedente. Le premier coup est gratuit, non compte,
|
||||||
|
peut capturer mais ne peut pas donner echec. Le second compte
|
||||||
|
comme premier coup de la partie, peut donner echec, mais ne peut
|
||||||
|
capturer qu'un pion ou une piece mineure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="format-section">
|
||||||
|
<h4>Temps par coup et fin de partie</h4>
|
||||||
|
<ul class="rule-list compact">
|
||||||
|
<li>Chaque coup doit etre joue en 20 secondes maximum.</li>
|
||||||
|
<li>En depassement, le coup est perdu et compte dans le quota.</li>
|
||||||
|
<li>Sur le premier coup d'un double coup, le depassement annule l'avantage.</li>
|
||||||
|
<li>Sur le second coup d'un double coup, le coup est perdu et comptabilise.</li>
|
||||||
|
<li>La partie se termine uniquement par mat ou abandon.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="format-card time-card">
|
||||||
|
<div class="format-head">
|
||||||
|
<span class="mini-chip">Version V1</span>
|
||||||
|
<h3>ChessCubing Time</h3>
|
||||||
|
<p>
|
||||||
|
Ici, la phase cube n'offre pas l'initiative mais modifie les
|
||||||
|
chronos selon une alternance bloc - puis bloc +.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="format-badges">
|
||||||
|
<span>Temps initial : 10 min / joueur</span>
|
||||||
|
<span>Block : 180 s</span>
|
||||||
|
<span>Cap cube pris en compte : 120 s</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="format-section">
|
||||||
|
<h4>Structure temporelle</h4>
|
||||||
|
<ul class="rule-list compact">
|
||||||
|
<li>La structure des Blocks est identique a celle du Twice.</li>
|
||||||
|
<li>Les quotas de coups restent les memes : 6, 8 ou 10.</li>
|
||||||
|
<li>Chaque Block est suivi d'une phase cube obligatoire.</li>
|
||||||
|
<li>Le trait est conserve apres la phase cube.</li>
|
||||||
|
<li>Aucun systeme de priorite ou de double coup n'existe.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split-callouts">
|
||||||
|
<article class="split-card minus-card">
|
||||||
|
<span class="micro-label">Block impair</span>
|
||||||
|
<strong>Bloc -</strong>
|
||||||
|
<p>
|
||||||
|
Le temps de resolution du cube est retire du chrono du
|
||||||
|
joueur concerne, avec un plafond de 120 secondes.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="split-card plus-card">
|
||||||
|
<span class="micro-label">Block pair</span>
|
||||||
|
<strong>Bloc +</strong>
|
||||||
|
<p>
|
||||||
|
Le temps de resolution du cube est ajoute au chrono adverse,
|
||||||
|
lui aussi plafonne a 120 secondes.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="format-section">
|
||||||
|
<h4>Exemples officiels</h4>
|
||||||
|
<ul class="rule-list compact">
|
||||||
|
<li>Bloc - : 35 s retire 35 s a son propre chrono.</li>
|
||||||
|
<li>Bloc - : 110 s retire 110 s a son propre chrono.</li>
|
||||||
|
<li>Bloc + : 25 s ajoute 25 s au chrono adverse.</li>
|
||||||
|
<li>Bloc + : 150 s ajoute 120 s au chrono adverse.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<span class="micro-label">Reprise du jeu</span>
|
||||||
|
<strong>Pas de coup pendant le cube</strong>
|
||||||
|
<p>
|
||||||
|
Aucun coup ne peut etre joue pendant la phase cube. Des que
|
||||||
|
les deux resolutions sont terminees, les chronos sont ajustes
|
||||||
|
et la partie reprend immediatement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="format-section">
|
||||||
|
<h4>Fin de partie et vigilance</h4>
|
||||||
|
<ul class="rule-list compact">
|
||||||
|
<li>La partie s'arrete uniquement par mat ou abandon volontaire.</li>
|
||||||
|
<li>L'arbitre surveille le chronometrage exact et le plafond de 120 s.</li>
|
||||||
|
<li>L'absence de priorite et de double coup fait partie des points cles du mode.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel-wide">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Sources</p>
|
||||||
|
<h2>Documents officiels</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-copy">
|
||||||
|
Cette synthese reprend les versions officielles actuellement
|
||||||
|
embarquees dans l'application.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="source-grid">
|
||||||
|
<a class="source-card" href="ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" target="_blank">
|
||||||
|
<span class="micro-label">PDF officiel</span>
|
||||||
|
<strong>ChessCubing Twice V2</strong>
|
||||||
|
<p>Version entree en vigueur le 4 fevrier 2026.</p>
|
||||||
|
</a>
|
||||||
|
<a class="source-card" href="ChessCubing_Time_Reglement_Officiel_V1-1.pdf" target="_blank">
|
||||||
|
<span class="micro-label">PDF officiel</span>
|
||||||
|
<strong>ChessCubing Time V1</strong>
|
||||||
|
<p>Version entree en vigueur le 4 fevrier 2026.</p>
|
||||||
|
</a>
|
||||||
|
<a class="source-card" href="application.html">
|
||||||
|
<span class="micro-label">Application</span>
|
||||||
|
<strong>Retour a ChessCubing Arena</strong>
|
||||||
|
<p>Revenir a la configuration du match et a l'arbitrage mobile.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
1210
ChessCubing.App/Pages/UserPage.razor
Normal file
1210
ChessCubing.App/Pages/UserPage.razor
Normal file
File diff suppressed because it is too large
Load Diff
21
ChessCubing.App/Program.cs
Normal file
21
ChessCubing.App/Program.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using ChessCubing.App;
|
||||||
|
using ChessCubing.App.Services;
|
||||||
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
|
||||||
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
builder.RootComponents.Add<App>("#app");
|
||||||
|
builder.RootComponents.Add<HeadOutlet>("head::after");
|
||||||
|
|
||||||
|
builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||||
|
builder.Services.AddAuthorizationCore();
|
||||||
|
builder.Services.AddScoped<AppAuthenticationStateProvider>();
|
||||||
|
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AppAuthenticationStateProvider>());
|
||||||
|
builder.Services.AddScoped<BrowserBridge>();
|
||||||
|
builder.Services.AddScoped<UserSession>();
|
||||||
|
builder.Services.AddScoped<MatchStore>();
|
||||||
|
builder.Services.AddScoped<SocialRealtimeService>();
|
||||||
|
builder.Services.AddScoped<MatchStatsService>();
|
||||||
|
|
||||||
|
await builder.Build().RunAsync();
|
||||||
15
ChessCubing.App/Properties/launchSettings.json
Normal file
15
ChessCubing.App/Properties/launchSettings.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||||
|
"applicationUrl": "http://localhost:5263",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
ChessCubing.App/Services/AppAuthenticationStateProvider.cs
Normal file
101
ChessCubing.App/Services/AppAuthenticationStateProvider.cs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using ChessCubing.App.Models.Auth;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
|
||||||
|
namespace ChessCubing.App.Services;
|
||||||
|
|
||||||
|
public sealed class AppAuthenticationStateProvider(HttpClient httpClient) : AuthenticationStateProvider
|
||||||
|
{
|
||||||
|
private static readonly AuthenticationState AnonymousState = new(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||||
|
|
||||||
|
private AuthenticationState? _cachedState;
|
||||||
|
|
||||||
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||||
|
{
|
||||||
|
if (_cachedState is not null)
|
||||||
|
{
|
||||||
|
return _cachedState;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cachedState = await LoadStateAsync();
|
||||||
|
return _cachedState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
_cachedState = await LoadStateAsync();
|
||||||
|
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetAuthenticated(AuthSessionResponse session)
|
||||||
|
{
|
||||||
|
_cachedState = new AuthenticationState(BuildPrincipal(session));
|
||||||
|
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetAnonymous()
|
||||||
|
{
|
||||||
|
_cachedState = AnonymousState;
|
||||||
|
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AuthenticationState> LoadStateAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var session = await httpClient.GetFromJsonAsync<AuthSessionResponse>("api/auth/session");
|
||||||
|
if (session is null || !session.IsAuthenticated)
|
||||||
|
{
|
||||||
|
return AnonymousState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AuthenticationState(BuildPrincipal(session));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return AnonymousState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal BuildPrincipal(AuthSessionResponse session)
|
||||||
|
{
|
||||||
|
if (!session.IsAuthenticated)
|
||||||
|
{
|
||||||
|
return AnonymousState.User;
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims = new List<Claim>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.Subject))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("sub", session.Subject));
|
||||||
|
claims.Add(new Claim(ClaimTypes.NameIdentifier, session.Subject));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.Username))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("preferred_username", session.Username));
|
||||||
|
claims.Add(new Claim(ClaimTypes.Name, session.Username));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.Name))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("name", session.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(session.Email))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("email", session.Email));
|
||||||
|
claims.Add(new Claim(ClaimTypes.Email, session.Email));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var role in session.Roles.Where(role => !string.IsNullOrWhiteSpace(role)))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("role", role));
|
||||||
|
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "ChessCubingCookie"));
|
||||||
|
}
|
||||||
|
}
|
||||||
33
ChessCubing.App/Services/BrowserBridge.cs
Normal file
33
ChessCubing.App/Services/BrowserBridge.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace ChessCubing.App.Services;
|
||||||
|
|
||||||
|
public sealed class BrowserBridge(IJSRuntime jsRuntime)
|
||||||
|
{
|
||||||
|
public ValueTask StartViewportAsync()
|
||||||
|
=> jsRuntime.InvokeVoidAsync("chesscubingViewport.start");
|
||||||
|
|
||||||
|
public ValueTask SetBodyStateAsync(string? page, string? bodyClass)
|
||||||
|
=> jsRuntime.InvokeVoidAsync("chesscubingPage.setBodyState", page, bodyClass ?? string.Empty);
|
||||||
|
|
||||||
|
public ValueTask SyncMenuAsync()
|
||||||
|
=> jsRuntime.InvokeVoidAsync("chesscubingMenu.sync");
|
||||||
|
|
||||||
|
public ValueTask<string?> ReadMatchJsonAsync(string storageKey, string windowNameKey)
|
||||||
|
=> jsRuntime.InvokeAsync<string?>("chesscubingStorage.getMatchState", storageKey, windowNameKey);
|
||||||
|
|
||||||
|
public ValueTask WriteMatchJsonAsync(string storageKey, string windowNameKey, string json)
|
||||||
|
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.setMatchState", storageKey, windowNameKey, json);
|
||||||
|
|
||||||
|
public ValueTask ClearMatchAsync(string storageKey, string windowNameKey)
|
||||||
|
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.clearMatchState", storageKey, windowNameKey);
|
||||||
|
|
||||||
|
public ValueTask<bool> PlayCubePhaseAlertAsync()
|
||||||
|
=> jsRuntime.InvokeAsync<bool>("chesscubingAudio.playCubePhaseAlert");
|
||||||
|
|
||||||
|
public ValueTask PrimeAudioAsync()
|
||||||
|
=> jsRuntime.InvokeVoidAsync("chesscubingAudio.prime");
|
||||||
|
|
||||||
|
public ValueTask ForceRefreshAsync(string path)
|
||||||
|
=> jsRuntime.InvokeVoidAsync("chesscubingBrowser.forceRefresh", path);
|
||||||
|
}
|
||||||
53
ChessCubing.App/Services/KeycloakAccountFactory.cs
Normal file
53
ChessCubing.App/Services/KeycloakAccountFactory.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
|
||||||
|
|
||||||
|
namespace ChessCubing.App.Services;
|
||||||
|
|
||||||
|
public sealed class KeycloakAccountFactory(IAccessTokenProviderAccessor accessor)
|
||||||
|
: AccountClaimsPrincipalFactory<RemoteUserAccount>(accessor)
|
||||||
|
{
|
||||||
|
public override async ValueTask<ClaimsPrincipal> CreateUserAsync(
|
||||||
|
RemoteUserAccount account,
|
||||||
|
RemoteAuthenticationUserOptions options)
|
||||||
|
{
|
||||||
|
var user = await base.CreateUserAsync(account, options);
|
||||||
|
if (user.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated)
|
||||||
|
{
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
var roleClaimType = options.RoleClaim ?? "role";
|
||||||
|
var existingRoles = identity.FindAll(roleClaimType)
|
||||||
|
.Select(claim => claim.Value)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var role in ReadRealmRoles(account))
|
||||||
|
{
|
||||||
|
if (existingRoles.Add(role))
|
||||||
|
{
|
||||||
|
identity.AddClaim(new Claim(roleClaimType, role));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> ReadRealmRoles(RemoteUserAccount account)
|
||||||
|
{
|
||||||
|
if (!account.AdditionalProperties.TryGetValue("realm_access", out var realmAccessValue) ||
|
||||||
|
realmAccessValue is not JsonElement realmAccessElement ||
|
||||||
|
realmAccessElement.ValueKind != JsonValueKind.Object ||
|
||||||
|
!realmAccessElement.TryGetProperty("roles", out var rolesElement) ||
|
||||||
|
rolesElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rolesElement.EnumerateArray()
|
||||||
|
.Where(item => item.ValueKind == JsonValueKind.String)
|
||||||
|
.Select(item => item.GetString())
|
||||||
|
.OfType<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
1030
ChessCubing.App/Services/MatchEngine.cs
Normal file
1030
ChessCubing.App/Services/MatchEngine.cs
Normal file
File diff suppressed because it is too large
Load Diff
77
ChessCubing.App/Services/MatchStatsService.cs
Normal file
77
ChessCubing.App/Services/MatchStatsService.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using ChessCubing.App.Models;
|
||||||
|
using ChessCubing.App.Models.Stats;
|
||||||
|
|
||||||
|
namespace ChessCubing.App.Services;
|
||||||
|
|
||||||
|
public sealed class MatchStatsService(HttpClient httpClient)
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient = httpClient;
|
||||||
|
|
||||||
|
public async Task<bool> TryReportCompletedMatchAsync(MatchState? match, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (match is null || string.IsNullOrWhiteSpace(match.Result) || match.ResultRecordedUtc is not null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(match.WhiteSubject) && string.IsNullOrWhiteSpace(match.BlackSubject))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = BuildReport(match);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsJsonAsync("api/users/me/stats/matches", request, cancellationToken);
|
||||||
|
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match.ResultRecordedUtc = DateTime.UtcNow;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReportCompletedMatchRequest BuildReport(MatchState match)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
MatchId = string.IsNullOrWhiteSpace(match.MatchId) ? Guid.NewGuid().ToString("N") : match.MatchId,
|
||||||
|
CollaborationSessionId = match.CollaborationSessionId,
|
||||||
|
WhiteSubject = NormalizeOptional(match.WhiteSubject),
|
||||||
|
WhiteName = MatchEngine.PlayerName(match, MatchEngine.ColorWhite),
|
||||||
|
BlackSubject = NormalizeOptional(match.BlackSubject),
|
||||||
|
BlackName = MatchEngine.PlayerName(match, MatchEngine.ColorBlack),
|
||||||
|
Result = match.Result ?? string.Empty,
|
||||||
|
Mode = match.Config.Mode,
|
||||||
|
Preset = match.Config.Preset,
|
||||||
|
MatchLabel = MatchEngine.SanitizeText(match.Config.MatchLabel),
|
||||||
|
BlockNumber = Math.Max(1, match.BlockNumber),
|
||||||
|
WhiteMoves = Math.Max(0, match.Moves.White),
|
||||||
|
BlackMoves = Math.Max(0, match.Moves.Black),
|
||||||
|
CubeRounds = match.Cube.History
|
||||||
|
.Select(entry => new ReportCompletedCubeRound
|
||||||
|
{
|
||||||
|
BlockNumber = Math.Max(1, entry.BlockNumber),
|
||||||
|
Number = entry.Number,
|
||||||
|
White = entry.White,
|
||||||
|
Black = entry.Black,
|
||||||
|
})
|
||||||
|
.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? NormalizeOptional(string? value)
|
||||||
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
}
|
||||||
134
ChessCubing.App/Services/MatchStore.cs
Normal file
134
ChessCubing.App/Services/MatchStore.cs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ChessCubing.App.Models;
|
||||||
|
|
||||||
|
namespace ChessCubing.App.Services;
|
||||||
|
|
||||||
|
public sealed class MatchStore(BrowserBridge browser, UserSession userSession)
|
||||||
|
{
|
||||||
|
public const string StorageKeyPrefix = "chesscubing-arena-state-v3";
|
||||||
|
public const string WindowNameKeyPrefix = "chesscubing-arena-state-v3";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
private bool _dirty;
|
||||||
|
private long _lastPersistedAt;
|
||||||
|
private string? _activeStorageKey;
|
||||||
|
private string? _activeWindowNameKey;
|
||||||
|
|
||||||
|
public MatchState? Current { get; private set; }
|
||||||
|
|
||||||
|
public bool IsLoaded { get; private set; }
|
||||||
|
|
||||||
|
public async Task EnsureLoadedAsync()
|
||||||
|
{
|
||||||
|
var storageScope = await SyncStorageScopeAsync();
|
||||||
|
if (IsLoaded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var raw = await browser.ReadMatchJsonAsync(storageScope.StorageKey, storageScope.WindowNameKey);
|
||||||
|
if (!string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
var parsed = JsonSerializer.Deserialize<MatchState>(raw, JsonOptions);
|
||||||
|
if (parsed is not null && MatchEngine.IsSupportedSchemaVersion(parsed.SchemaVersion))
|
||||||
|
{
|
||||||
|
MatchEngine.NormalizeRecoveredMatch(parsed);
|
||||||
|
Current = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsLoaded = true;
|
||||||
|
_lastPersistedAt = MatchEngine.NowUnixMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCurrent(MatchState? match)
|
||||||
|
{
|
||||||
|
Current = match;
|
||||||
|
MarkDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReplaceCurrent(MatchState? match)
|
||||||
|
{
|
||||||
|
Current = match;
|
||||||
|
IsLoaded = true;
|
||||||
|
_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkDirty()
|
||||||
|
=> _dirty = true;
|
||||||
|
|
||||||
|
public async Task SaveAsync()
|
||||||
|
{
|
||||||
|
var storageScope = await SyncStorageScopeAsync();
|
||||||
|
if (!IsLoaded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Current is null)
|
||||||
|
{
|
||||||
|
await browser.ClearMatchAsync(storageScope.StorageKey, storageScope.WindowNameKey);
|
||||||
|
_dirty = false;
|
||||||
|
_lastPersistedAt = MatchEngine.NowUnixMs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(Current, JsonOptions);
|
||||||
|
await browser.WriteMatchJsonAsync(storageScope.StorageKey, storageScope.WindowNameKey, json);
|
||||||
|
_dirty = false;
|
||||||
|
_lastPersistedAt = MatchEngine.NowUnixMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task FlushIfDueAsync(long minimumIntervalMs = 1_000)
|
||||||
|
{
|
||||||
|
if (!_dirty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MatchEngine.NowUnixMs() - _lastPersistedAt < minimumIntervalMs)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SaveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClearAsync()
|
||||||
|
{
|
||||||
|
var storageScope = await SyncStorageScopeAsync();
|
||||||
|
Current = null;
|
||||||
|
IsLoaded = true;
|
||||||
|
_dirty = false;
|
||||||
|
_lastPersistedAt = MatchEngine.NowUnixMs();
|
||||||
|
await browser.ClearMatchAsync(storageScope.StorageKey, storageScope.WindowNameKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<UserStorageScope> SyncStorageScopeAsync()
|
||||||
|
{
|
||||||
|
var storageScope = await userSession.GetStorageScopeAsync();
|
||||||
|
if (_activeStorageKey is not null &&
|
||||||
|
(_activeStorageKey != storageScope.StorageKey || _activeWindowNameKey != storageScope.WindowNameKey))
|
||||||
|
{
|
||||||
|
Current = null;
|
||||||
|
IsLoaded = false;
|
||||||
|
_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeStorageKey = storageScope.StorageKey;
|
||||||
|
_activeWindowNameKey = storageScope.WindowNameKey;
|
||||||
|
return storageScope;
|
||||||
|
}
|
||||||
|
}
|
||||||
469
ChessCubing.App/Services/SocialRealtimeService.cs
Normal file
469
ChessCubing.App/Services/SocialRealtimeService.cs
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ChessCubing.App.Models;
|
||||||
|
using ChessCubing.App.Models.Social;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
|
||||||
|
namespace ChessCubing.App.Services;
|
||||||
|
|
||||||
|
public sealed class SocialRealtimeService(
|
||||||
|
NavigationManager navigation,
|
||||||
|
AuthenticationStateProvider authenticationStateProvider) : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly NavigationManager _navigation = navigation;
|
||||||
|
private readonly AuthenticationStateProvider _authenticationStateProvider = authenticationStateProvider;
|
||||||
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
|
private HubConnection? _hubConnection;
|
||||||
|
private string? _currentSubject;
|
||||||
|
private string? _joinedPlaySessionId;
|
||||||
|
private HashSet<string> _knownPresenceSubjects = new(StringComparer.Ordinal);
|
||||||
|
private HashSet<string> _onlineSubjects = new(StringComparer.Ordinal);
|
||||||
|
private PlayInviteMessage? _incomingPlayInvite;
|
||||||
|
private PlayInviteMessage? _outgoingPlayInvite;
|
||||||
|
private PlaySessionResponse? _activePlaySession;
|
||||||
|
private CollaborativeMatchSnapshot? _collaborativeSnapshot;
|
||||||
|
private string? _lastInviteNotice;
|
||||||
|
private int _socialVersion;
|
||||||
|
private bool _started;
|
||||||
|
|
||||||
|
public event Action? Changed;
|
||||||
|
|
||||||
|
public PlayInviteMessage? IncomingPlayInvite => _incomingPlayInvite;
|
||||||
|
|
||||||
|
public PlayInviteMessage? OutgoingPlayInvite => _outgoingPlayInvite;
|
||||||
|
|
||||||
|
public PlaySessionResponse? ActivePlaySession => _activePlaySession;
|
||||||
|
|
||||||
|
public CollaborativeMatchSnapshot? CollaborativeSnapshot => _collaborativeSnapshot;
|
||||||
|
|
||||||
|
public string? LastInviteNotice => _lastInviteNotice;
|
||||||
|
|
||||||
|
public int SocialVersion => _socialVersion;
|
||||||
|
|
||||||
|
public async Task EnsureStartedAsync()
|
||||||
|
{
|
||||||
|
if (_started)
|
||||||
|
{
|
||||||
|
await SyncConnectionAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_started = true;
|
||||||
|
_authenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
||||||
|
await SyncConnectionAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsUserOnline(string subject)
|
||||||
|
=> _onlineSubjects.Contains(subject);
|
||||||
|
|
||||||
|
public bool? GetKnownOnlineStatus(string subject)
|
||||||
|
{
|
||||||
|
if (_onlineSubjects.Contains(subject))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _knownPresenceSubjects.Contains(subject)
|
||||||
|
? false
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnsureJoinedPlaySessionAsync(string? sessionId)
|
||||||
|
{
|
||||||
|
var normalizedSessionId = sessionId?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedSessionId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnsureStartedAsync();
|
||||||
|
await JoinPlaySessionCoreAsync(normalizedSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PublishMatchStateAsync(MatchState? match, string route)
|
||||||
|
{
|
||||||
|
var sessionId = match?.CollaborationSessionId
|
||||||
|
?? _activePlaySession?.SessionId
|
||||||
|
?? _joinedPlaySessionId;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnsureJoinedPlaySessionAsync(sessionId);
|
||||||
|
|
||||||
|
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("La connexion temps reel n'est pas prete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = match is null
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Serialize(match, JsonOptions);
|
||||||
|
|
||||||
|
await _hubConnection.InvokeAsync("PublishMatchState", sessionId, payload, route);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendPlayInviteAsync(string recipientSubject, string recipientColor)
|
||||||
|
{
|
||||||
|
_lastInviteNotice = null;
|
||||||
|
|
||||||
|
if (_hubConnection is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("La connexion temps reel n'est pas prete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var invite = await _hubConnection.InvokeAsync<PlayInviteMessage>("SendPlayInvite", recipientSubject, recipientColor);
|
||||||
|
ApplyInvite(invite);
|
||||||
|
RaiseChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RespondToIncomingPlayInviteAsync(bool accept)
|
||||||
|
{
|
||||||
|
if (_hubConnection is null || _incomingPlayInvite is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var inviteId = _incomingPlayInvite.InviteId;
|
||||||
|
await _hubConnection.InvokeAsync("RespondToPlayInvite", inviteId, accept);
|
||||||
|
|
||||||
|
if (!accept)
|
||||||
|
{
|
||||||
|
_incomingPlayInvite = null;
|
||||||
|
RaiseChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CancelOutgoingPlayInviteAsync()
|
||||||
|
{
|
||||||
|
if (_hubConnection is null || _outgoingPlayInvite is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var inviteId = _outgoingPlayInvite.InviteId;
|
||||||
|
await _hubConnection.InvokeAsync("CancelPlayInvite", inviteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearInviteNotice()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_lastInviteNotice))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastInviteNotice = null;
|
||||||
|
RaiseChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_authenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
||||||
|
|
||||||
|
await _gate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_hubConnection is not null)
|
||||||
|
{
|
||||||
|
await _hubConnection.DisposeAsync();
|
||||||
|
_hubConnection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
_gate.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
||||||
|
=> _ = SyncConnectionAsync();
|
||||||
|
|
||||||
|
private async Task SyncConnectionAsync()
|
||||||
|
{
|
||||||
|
await _gate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var user = authState.User;
|
||||||
|
var subject = ResolveSubject(user);
|
||||||
|
var preserveSessionState = string.Equals(subject, _currentSubject, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(subject))
|
||||||
|
{
|
||||||
|
await DisconnectUnsafeAsync(clearSessionState: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hubConnection is not null &&
|
||||||
|
string.Equals(subject, _currentSubject, StringComparison.Ordinal) &&
|
||||||
|
_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DisconnectUnsafeAsync(clearSessionState: !preserveSessionState);
|
||||||
|
|
||||||
|
_currentSubject = subject;
|
||||||
|
_hubConnection = BuildHubConnection();
|
||||||
|
RegisterHandlers(_hubConnection);
|
||||||
|
await _hubConnection.StartAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId))
|
||||||
|
{
|
||||||
|
await JoinPlaySessionCoreAsync(_joinedPlaySessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HubConnection BuildHubConnection()
|
||||||
|
=> new HubConnectionBuilder()
|
||||||
|
.WithUrl(_navigation.ToAbsoluteUri("/hubs/social"))
|
||||||
|
.WithAutomaticReconnect()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private void RegisterHandlers(HubConnection connection)
|
||||||
|
{
|
||||||
|
connection.On<PresenceSnapshotMessage>("PresenceSnapshot", message =>
|
||||||
|
{
|
||||||
|
_knownPresenceSubjects = message.OnlineSubjects.ToHashSet(StringComparer.Ordinal);
|
||||||
|
_onlineSubjects = message.OnlineSubjects.ToHashSet(StringComparer.Ordinal);
|
||||||
|
RaiseChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.On<PresenceChangedMessage>("PresenceChanged", message =>
|
||||||
|
{
|
||||||
|
_knownPresenceSubjects.Add(message.Subject);
|
||||||
|
|
||||||
|
if (message.IsOnline)
|
||||||
|
{
|
||||||
|
_onlineSubjects.Add(message.Subject);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_onlineSubjects.Remove(message.Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
RaiseChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.On("SocialChanged", async () =>
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _socialVersion);
|
||||||
|
await RequestPresenceSnapshotAsync();
|
||||||
|
RaiseChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.On<PlayInviteMessage>("PlayInviteUpdated", message =>
|
||||||
|
{
|
||||||
|
ApplyInvite(message);
|
||||||
|
RaiseChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.On<PlayInviteClosedMessage>("PlayInviteClosed", message =>
|
||||||
|
{
|
||||||
|
if (_incomingPlayInvite?.InviteId == message.InviteId)
|
||||||
|
{
|
||||||
|
_incomingPlayInvite = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_outgoingPlayInvite?.InviteId == message.InviteId)
|
||||||
|
{
|
||||||
|
_outgoingPlayInvite = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastInviteNotice = message.Message;
|
||||||
|
RaiseChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.On<CollaborativeMatchStateMessage>("CollaborativeMatchStateUpdated", message =>
|
||||||
|
{
|
||||||
|
ApplyCollaborativeState(message);
|
||||||
|
RaiseChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.On<PlaySessionResponse>("PlayInviteAccepted", async session =>
|
||||||
|
{
|
||||||
|
_incomingPlayInvite = null;
|
||||||
|
_outgoingPlayInvite = null;
|
||||||
|
_activePlaySession = session;
|
||||||
|
_lastInviteNotice = "La partie est confirmee. Les deux ecrans resteront synchronises pendant le match.";
|
||||||
|
await JoinPlaySessionCoreAsync(session.SessionId);
|
||||||
|
RaiseChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.Reconnected += async _ =>
|
||||||
|
{
|
||||||
|
await RequestPresenceSnapshotAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId))
|
||||||
|
{
|
||||||
|
await JoinPlaySessionCoreAsync(_joinedPlaySessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
RaiseChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.Closed += async _ =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_currentSubject))
|
||||||
|
{
|
||||||
|
await SyncConnectionAsync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RequestPresenceSnapshotAsync()
|
||||||
|
{
|
||||||
|
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _hubConnection.InvokeAsync("RequestPresenceSnapshot");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// La vue peut continuer avec le dernier etat connu si le snapshot echoue ponctuellement.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task JoinPlaySessionCoreAsync(string sessionId)
|
||||||
|
{
|
||||||
|
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId) &&
|
||||||
|
!string.Equals(_joinedPlaySessionId, sessionId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _hubConnection.InvokeAsync("LeavePlaySession", _joinedPlaySessionId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// La prochaine connexion recreera les groupes si besoin.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestState = await _hubConnection.InvokeAsync<CollaborativeMatchStateMessage?>("JoinPlaySession", sessionId);
|
||||||
|
_joinedPlaySessionId = sessionId;
|
||||||
|
|
||||||
|
if (latestState is not null)
|
||||||
|
{
|
||||||
|
ApplyCollaborativeState(latestState);
|
||||||
|
}
|
||||||
|
else if (_collaborativeSnapshot?.SessionId != sessionId)
|
||||||
|
{
|
||||||
|
_collaborativeSnapshot = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyInvite(PlayInviteMessage invite)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_currentSubject))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(invite.RecipientSubject, _currentSubject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_incomingPlayInvite = invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(invite.SenderSubject, _currentSubject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_outgoingPlayInvite = invite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCollaborativeState(CollaborativeMatchStateMessage message)
|
||||||
|
{
|
||||||
|
if (_collaborativeSnapshot is not null &&
|
||||||
|
string.Equals(_collaborativeSnapshot.SessionId, message.SessionId, StringComparison.Ordinal) &&
|
||||||
|
message.Revision <= _collaborativeSnapshot.Revision)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchState? match = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.MatchJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
match = JsonSerializer.Deserialize<MatchState>(message.MatchJson, JsonOptions);
|
||||||
|
if (match is not null)
|
||||||
|
{
|
||||||
|
match.CollaborationSessionId = message.SessionId;
|
||||||
|
MatchEngine.NormalizeRecoveredMatch(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
match = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_collaborativeSnapshot = new CollaborativeMatchSnapshot
|
||||||
|
{
|
||||||
|
SessionId = message.SessionId,
|
||||||
|
Match = match,
|
||||||
|
Route = string.IsNullOrWhiteSpace(message.Route) ? "/application.html" : message.Route,
|
||||||
|
SenderSubject = message.SenderSubject,
|
||||||
|
Revision = message.Revision,
|
||||||
|
UpdatedUtc = message.UpdatedUtc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DisconnectUnsafeAsync(bool clearSessionState)
|
||||||
|
{
|
||||||
|
_currentSubject = clearSessionState ? null : _currentSubject;
|
||||||
|
_knownPresenceSubjects.Clear();
|
||||||
|
_onlineSubjects.Clear();
|
||||||
|
_incomingPlayInvite = null;
|
||||||
|
_outgoingPlayInvite = null;
|
||||||
|
|
||||||
|
if (clearSessionState)
|
||||||
|
{
|
||||||
|
_activePlaySession = null;
|
||||||
|
_joinedPlaySessionId = null;
|
||||||
|
_collaborativeSnapshot = null;
|
||||||
|
_lastInviteNotice = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hubConnection is not null)
|
||||||
|
{
|
||||||
|
await _hubConnection.DisposeAsync();
|
||||||
|
_hubConnection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
RaiseChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaiseChanged()
|
||||||
|
=> Changed?.Invoke();
|
||||||
|
|
||||||
|
private static string? ResolveSubject(ClaimsPrincipal user)
|
||||||
|
=> user.Identity?.IsAuthenticated == true
|
||||||
|
? user.FindFirst("sub")?.Value ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
49
ChessCubing.App/Services/UserSession.cs
Normal file
49
ChessCubing.App/Services/UserSession.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
|
||||||
|
namespace ChessCubing.App.Services;
|
||||||
|
|
||||||
|
public sealed class UserSession(AuthenticationStateProvider authenticationStateProvider)
|
||||||
|
{
|
||||||
|
public async ValueTask<UserStorageScope> GetStorageScopeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var authState = await authenticationStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var userId = ResolveUserId(authState.User);
|
||||||
|
|
||||||
|
return CreateStorageScope(userId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return CreateStorageScope("anonymous");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveUserId(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
if (user.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return "anonymous";
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawIdentifier = user.FindFirst("sub")?.Value
|
||||||
|
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||||
|
?? user.FindFirst("preferred_username")?.Value
|
||||||
|
?? user.Identity?.Name;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(rawIdentifier))
|
||||||
|
{
|
||||||
|
return "authenticated";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uri.EscapeDataString(rawIdentifier.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserStorageScope CreateStorageScope(string userId)
|
||||||
|
=> new(
|
||||||
|
$"{MatchStore.StorageKeyPrefix}:{userId}",
|
||||||
|
$"{MatchStore.WindowNameKeyPrefix}:{userId}:");
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct UserStorageScope(string StorageKey, string WindowNameKey);
|
||||||
15
ChessCubing.App/_Imports.razor
Normal file
15
ChessCubing.App/_Imports.razor
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@using System.Globalization
|
||||||
|
@using System.Net.Http
|
||||||
|
@using ChessCubing.App
|
||||||
|
@using ChessCubing.App.Components
|
||||||
|
@using ChessCubing.App.Models
|
||||||
|
@using ChessCubing.App.Models.Social
|
||||||
|
@using ChessCubing.App.Services
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||||
|
@using Microsoft.JSInterop
|
||||||
13
ChessCubing.App/wwwroot/appsettings.json
Normal file
13
ChessCubing.App/wwwroot/appsettings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"Keycloak": {
|
||||||
|
"Authority": "/auth/realms/chesscubing",
|
||||||
|
"ClientId": "chesscubing-web",
|
||||||
|
"ResponseType": "code",
|
||||||
|
"PostLogoutRedirectUri": "/",
|
||||||
|
"DefaultScopes": [
|
||||||
|
"openid",
|
||||||
|
"profile",
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ChessCubing.App/wwwroot/icon-192.png
Normal file
BIN
ChessCubing.App/wwwroot/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
46
ChessCubing.App/wwwroot/index.html
Normal file
46
ChessCubing.App/wwwroot/index.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#140700" />
|
||||||
|
<meta name="application-name" content="ChessCubing Arena" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="ChessCubing" />
|
||||||
|
<meta name="description" content="Application officielle ChessCubing Arena en Blazor .NET 10." />
|
||||||
|
<title>ChessCubing Arena</title>
|
||||||
|
<base href="/" />
|
||||||
|
<link rel="preload" id="webassembly" />
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
|
<link rel="shortcut icon" href="favicon.png" />
|
||||||
|
<link rel="apple-touch-icon" href="logo.png" />
|
||||||
|
<link rel="manifest" href="site.webmanifest" />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<link href="ChessCubing.App.styles.css" rel="stylesheet" />
|
||||||
|
<script src="js/chesscubing-interop.js"></script>
|
||||||
|
<script type="importmap"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div style="min-height: 100dvh; display: grid; place-items: center; padding: 2rem; color: #f5f7fb;">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<strong style="display: block; margin-bottom: 0.75rem;">ChessCubing Arena</strong>
|
||||||
|
<span>Chargement de l'application Blazor...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="blazor-error-ui">
|
||||||
|
Une erreur inattendue est survenue.
|
||||||
|
<a href="." class="reload">Recharger</a>
|
||||||
|
<span class="dismiss">x</span>
|
||||||
|
</div>
|
||||||
|
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
347
ChessCubing.App/wwwroot/js/chesscubing-interop.js
Normal file
347
ChessCubing.App/wwwroot/js/chesscubing-interop.js
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
(() => {
|
||||||
|
const assetTokenStorageKey = "chesscubing-arena-asset-token";
|
||||||
|
let viewportStarted = false;
|
||||||
|
let audioContext = null;
|
||||||
|
let authModalMessageHandler = null;
|
||||||
|
let menuScrollStarted = false;
|
||||||
|
let menuLastScrollY = 0;
|
||||||
|
let menuAnimationFrame = 0;
|
||||||
|
let appliedBodyStateClasses = [];
|
||||||
|
|
||||||
|
function syncViewportHeight() {
|
||||||
|
const visibleHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
const viewportTopOffset = window.visualViewport?.offsetTop ?? 0;
|
||||||
|
const viewportHeight = Math.max(
|
||||||
|
visibleHeight + viewportTopOffset,
|
||||||
|
window.innerHeight,
|
||||||
|
document.documentElement.clientHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty("--app-visible-height", `${Math.round(visibleHeight)}px`);
|
||||||
|
document.documentElement.style.setProperty("--app-viewport-top", `${Math.round(viewportTopOffset)}px`);
|
||||||
|
document.documentElement.style.setProperty("--app-viewport-height", `${Math.round(viewportHeight)}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startViewport() {
|
||||||
|
if (viewportStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewportStarted = true;
|
||||||
|
syncViewportHeight();
|
||||||
|
window.addEventListener("load", syncViewportHeight);
|
||||||
|
window.addEventListener("resize", syncViewportHeight);
|
||||||
|
window.addEventListener("scroll", syncViewportHeight, { passive: true });
|
||||||
|
window.addEventListener("pageshow", syncViewportHeight);
|
||||||
|
window.addEventListener("orientationchange", syncViewportHeight);
|
||||||
|
window.visualViewport?.addEventListener("resize", syncViewportHeight);
|
||||||
|
window.visualViewport?.addEventListener("scroll", syncViewportHeight);
|
||||||
|
window.setTimeout(syncViewportHeight, 0);
|
||||||
|
window.setTimeout(syncViewportHeight, 150);
|
||||||
|
window.setTimeout(syncViewportHeight, 400);
|
||||||
|
window.requestAnimationFrame(() => window.requestAnimationFrame(syncViewportHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMenuHidden(hidden) {
|
||||||
|
document.body.classList.toggle("site-menu-hidden", hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHideMenuForModal(modal) {
|
||||||
|
if (!modal) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia("(max-width: 900px)").matches && modal.querySelector(".auth-modal-card") !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMenuVisibility() {
|
||||||
|
menuAnimationFrame = 0;
|
||||||
|
|
||||||
|
const menu = document.querySelector(".site-menu-shell");
|
||||||
|
if (!menu) {
|
||||||
|
setMenuHidden(false);
|
||||||
|
menuLastScrollY = window.scrollY || window.pageYOffset || 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalIsOpen = document.querySelector(".modal:not(.hidden)");
|
||||||
|
if (modalIsOpen) {
|
||||||
|
setMenuHidden(shouldHideMenuForModal(modalIsOpen));
|
||||||
|
menuLastScrollY = window.scrollY || window.pageYOffset || 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileMenuIsOpen =
|
||||||
|
window.matchMedia("(max-width: 900px)").matches &&
|
||||||
|
document.querySelector(".site-menu-main.is-mobile-open") !== null;
|
||||||
|
|
||||||
|
if (mobileMenuIsOpen) {
|
||||||
|
setMenuHidden(false);
|
||||||
|
menuLastScrollY = window.scrollY || window.pageYOffset || 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentScrollY = Math.max(window.scrollY || window.pageYOffset || 0, 0);
|
||||||
|
const delta = currentScrollY - menuLastScrollY;
|
||||||
|
|
||||||
|
if (currentScrollY <= 24 || delta < -10) {
|
||||||
|
setMenuHidden(false);
|
||||||
|
} else if (delta > 10 && currentScrollY > 120) {
|
||||||
|
setMenuHidden(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
menuLastScrollY = currentScrollY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueMenuVisibilitySync() {
|
||||||
|
if (menuAnimationFrame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
menuAnimationFrame = window.requestAnimationFrame(syncMenuVisibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMenuScrollTracking() {
|
||||||
|
if (menuScrollStarted) {
|
||||||
|
queueMenuVisibilitySync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
menuScrollStarted = true;
|
||||||
|
menuLastScrollY = window.scrollY || window.pageYOffset || 0;
|
||||||
|
window.addEventListener("scroll", queueMenuVisibilitySync, { passive: true });
|
||||||
|
window.addEventListener("pageshow", queueMenuVisibilitySync);
|
||||||
|
window.addEventListener("resize", queueMenuVisibilitySync);
|
||||||
|
window.addEventListener("hashchange", queueMenuVisibilitySync);
|
||||||
|
window.setTimeout(queueMenuVisibilitySync, 0);
|
||||||
|
window.setTimeout(queueMenuVisibilitySync, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAudioContext() {
|
||||||
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioContextClass) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioContext) {
|
||||||
|
try {
|
||||||
|
audioContext = new AudioContextClass();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function primeAudio() {
|
||||||
|
const context = getAudioContext();
|
||||||
|
if (!context || context.state === "running") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.resume().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playCubePhaseAlert() {
|
||||||
|
primeAudio();
|
||||||
|
const context = getAudioContext();
|
||||||
|
if (!context || context.state !== "running") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = [
|
||||||
|
{ frequency: 740, offset: 0, duration: 0.12, gain: 0.035 },
|
||||||
|
{ frequency: 988, offset: 0.18, duration: 0.12, gain: 0.04 },
|
||||||
|
{ frequency: 1318, offset: 0.36, duration: 0.2, gain: 0.05 },
|
||||||
|
];
|
||||||
|
const startAt = context.currentTime + 0.02;
|
||||||
|
|
||||||
|
pattern.forEach(({ frequency, offset, duration, gain }) => {
|
||||||
|
const oscillator = context.createOscillator();
|
||||||
|
const envelope = context.createGain();
|
||||||
|
const toneStartAt = startAt + offset;
|
||||||
|
|
||||||
|
oscillator.type = "triangle";
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, toneStartAt);
|
||||||
|
envelope.gain.setValueAtTime(0.0001, toneStartAt);
|
||||||
|
envelope.gain.exponentialRampToValueAtTime(gain, toneStartAt + 0.02);
|
||||||
|
envelope.gain.exponentialRampToValueAtTime(0.0001, toneStartAt + duration);
|
||||||
|
|
||||||
|
oscillator.connect(envelope);
|
||||||
|
envelope.connect(context.destination);
|
||||||
|
oscillator.start(toneStartAt);
|
||||||
|
oscillator.stop(toneStartAt + duration + 0.03);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.chesscubingPage = {
|
||||||
|
setBodyState(page, bodyClass) {
|
||||||
|
if (page) {
|
||||||
|
document.body.dataset.page = page;
|
||||||
|
} else {
|
||||||
|
delete document.body.dataset.page;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const className of appliedBodyStateClasses) {
|
||||||
|
document.body.classList.remove(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
appliedBodyStateClasses = (bodyClass || "")
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value.length > 0);
|
||||||
|
|
||||||
|
if (appliedBodyStateClasses.length > 0) {
|
||||||
|
document.body.classList.add(...appliedBodyStateClasses);
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMenuVisibilitySync();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chesscubingViewport = {
|
||||||
|
start: startViewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chesscubingMenu = {
|
||||||
|
start: startMenuScrollTracking,
|
||||||
|
sync: queueMenuVisibilitySync,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chesscubingStorage = {
|
||||||
|
getMatchState(storageKey, windowNameKey) {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey);
|
||||||
|
if (raw) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!window.name || !window.name.startsWith(windowNameKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.name.slice(windowNameKey.length);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setMatchState(storageKey, windowNameKey, value) {
|
||||||
|
try {
|
||||||
|
window.name = `${windowNameKey}${value}`;
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, value);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearMatchState(storageKey, windowNameKey) {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(storageKey);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (window.name && window.name.startsWith(windowNameKey)) {
|
||||||
|
window.name = "";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chesscubingAudio = {
|
||||||
|
prime: primeAudio,
|
||||||
|
playCubePhaseAlert,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chesscubingBrowser = {
|
||||||
|
async forceRefresh(path) {
|
||||||
|
const refreshToken = `${Date.now()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(assetTokenStorageKey, refreshToken);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("caches" in window) {
|
||||||
|
try {
|
||||||
|
const cacheKeys = await window.caches.keys();
|
||||||
|
await Promise.all(cacheKeys.map((cacheKey) => window.caches.delete(cacheKey)));
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
try {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
await Promise.all(registrations.map((registration) => registration.update().catch(() => undefined)));
|
||||||
|
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => undefined)));
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = new URL(path, window.location.href);
|
||||||
|
targetUrl.searchParams.set("refresh", refreshToken);
|
||||||
|
window.location.replace(targetUrl.toString());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.chesscubingAuthModal = {
|
||||||
|
registerListener(dotNetReference) {
|
||||||
|
if (authModalMessageHandler) {
|
||||||
|
window.removeEventListener("message", authModalMessageHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
authModalMessageHandler = (event) => {
|
||||||
|
if (event.origin !== window.location.origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || data.source !== "chesscubing-auth-modal" || typeof data.status !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dotNetReference.invokeMethodAsync("HandleAuthModalMessage", data.status).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", authModalMessageHandler);
|
||||||
|
},
|
||||||
|
|
||||||
|
unregisterListener() {
|
||||||
|
if (!authModalMessageHandler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener("message", authModalMessageHandler);
|
||||||
|
authModalMessageHandler = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
notifyParent(status) {
|
||||||
|
if (!window.parent || window.parent === window) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
source: "chesscubing-auth-modal",
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
startViewport();
|
||||||
|
startMenuScrollTracking();
|
||||||
|
})();
|
||||||
154
ChessCubing.Server/Admin/AdminUserContracts.cs
Normal file
154
ChessCubing.Server/Admin/AdminUserContracts.cs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
namespace ChessCubing.Server.Admin;
|
||||||
|
|
||||||
|
public sealed class AdminUserSummaryResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string IdentityDisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? SiteDisplayName { get; init; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; init; }
|
||||||
|
|
||||||
|
public bool IsEmailVerified { get; init; }
|
||||||
|
|
||||||
|
public bool HasSiteProfile { get; init; }
|
||||||
|
|
||||||
|
public string? Club { get; init; }
|
||||||
|
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; init; }
|
||||||
|
|
||||||
|
public DateTime? AccountCreatedUtc { get; init; }
|
||||||
|
|
||||||
|
public DateTime? SiteProfileUpdatedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminUserDetailResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string? FirstName { get; init; }
|
||||||
|
|
||||||
|
public string? LastName { get; init; }
|
||||||
|
|
||||||
|
public string IdentityDisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsEnabled { get; init; }
|
||||||
|
|
||||||
|
public bool IsEmailVerified { get; init; }
|
||||||
|
|
||||||
|
public DateTime? AccountCreatedUtc { get; init; }
|
||||||
|
|
||||||
|
public bool HasSiteProfile { get; init; }
|
||||||
|
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Club { get; init; }
|
||||||
|
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; init; }
|
||||||
|
|
||||||
|
public string? FavoriteCube { get; init; }
|
||||||
|
|
||||||
|
public string? Bio { get; init; }
|
||||||
|
|
||||||
|
public DateTime? SiteProfileCreatedUtc { get; init; }
|
||||||
|
|
||||||
|
public DateTime? SiteProfileUpdatedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminUpdateUserRequest
|
||||||
|
{
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string? FirstName { get; init; }
|
||||||
|
|
||||||
|
public string? LastName { get; init; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; init; }
|
||||||
|
|
||||||
|
public bool IsEmailVerified { get; init; }
|
||||||
|
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
|
||||||
|
public string? Club { get; init; }
|
||||||
|
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; init; }
|
||||||
|
|
||||||
|
public string? FavoriteCube { get; init; }
|
||||||
|
|
||||||
|
public string? Bio { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminCreateUserRequest
|
||||||
|
{
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string Password { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ConfirmPassword { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? FirstName { get; init; }
|
||||||
|
|
||||||
|
public string? LastName { get; init; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; init; } = true;
|
||||||
|
|
||||||
|
public bool IsEmailVerified { get; init; }
|
||||||
|
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
|
||||||
|
public string? Club { get; init; }
|
||||||
|
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; init; }
|
||||||
|
|
||||||
|
public string? FavoriteCube { get; init; }
|
||||||
|
|
||||||
|
public string? Bio { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AdminIdentityUser(
|
||||||
|
string Subject,
|
||||||
|
string Username,
|
||||||
|
string? Email,
|
||||||
|
string? FirstName,
|
||||||
|
string? LastName,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsEmailVerified,
|
||||||
|
DateTime? CreatedUtc);
|
||||||
|
|
||||||
|
public sealed record AdminIdentityUserUpdateRequest(
|
||||||
|
string Username,
|
||||||
|
string? Email,
|
||||||
|
string? FirstName,
|
||||||
|
string? LastName,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsEmailVerified);
|
||||||
|
|
||||||
|
public sealed record AdminIdentityUserCreateRequest(
|
||||||
|
string Username,
|
||||||
|
string? Email,
|
||||||
|
string Password,
|
||||||
|
string? FirstName,
|
||||||
|
string? LastName,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsEmailVerified);
|
||||||
|
|
||||||
|
public sealed class AdminUserValidationException(string message) : Exception(message);
|
||||||
59
ChessCubing.Server/Auth/AuthContracts.cs
Normal file
59
ChessCubing.Server/Auth/AuthContracts.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ChessCubing.Server.Auth;
|
||||||
|
|
||||||
|
public sealed record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
|
public sealed record RegisterRequest(
|
||||||
|
string Username,
|
||||||
|
string Email,
|
||||||
|
string Password,
|
||||||
|
string ConfirmPassword,
|
||||||
|
string? FirstName,
|
||||||
|
string? LastName);
|
||||||
|
|
||||||
|
public sealed record ApiErrorResponse(string Message);
|
||||||
|
|
||||||
|
public sealed class AuthSessionResponse
|
||||||
|
{
|
||||||
|
public bool IsAuthenticated { get; init; }
|
||||||
|
|
||||||
|
public string? Subject { get; init; }
|
||||||
|
|
||||||
|
public string? Username { get; init; }
|
||||||
|
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string[] Roles { get; init; } = [];
|
||||||
|
|
||||||
|
public static AuthSessionResponse FromUser(ClaimsPrincipal user)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
IsAuthenticated = user.Identity?.IsAuthenticated == true,
|
||||||
|
Subject = user.FindFirst("sub")?.Value ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value,
|
||||||
|
Username = user.FindFirst("preferred_username")?.Value ?? user.Identity?.Name,
|
||||||
|
Name = user.FindFirst("name")?.Value ?? user.Identity?.Name,
|
||||||
|
Email = user.FindFirst("email")?.Value,
|
||||||
|
Roles = user.FindAll("role").Select(claim => claim.Value).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class KeycloakUserInfo
|
||||||
|
{
|
||||||
|
[JsonPropertyName("sub")]
|
||||||
|
public string? Subject { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("preferred_username")]
|
||||||
|
public string? PreferredUsername { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string[] Roles { get; set; } = [];
|
||||||
|
}
|
||||||
18
ChessCubing.Server/Auth/KeycloakAuthOptions.cs
Normal file
18
ChessCubing.Server/Auth/KeycloakAuthOptions.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ChessCubing.Server.Auth;
|
||||||
|
|
||||||
|
public sealed class KeycloakAuthOptions
|
||||||
|
{
|
||||||
|
public string BaseUrl { get; set; } = "http://keycloak:8080/auth";
|
||||||
|
|
||||||
|
public string Realm { get; set; } = "chesscubing";
|
||||||
|
|
||||||
|
public string ClientId { get; set; } = "chesscubing-web";
|
||||||
|
|
||||||
|
public string AdminRealm { get; set; } = "master";
|
||||||
|
|
||||||
|
public string AdminClientId { get; set; } = "admin-cli";
|
||||||
|
|
||||||
|
public string AdminUsername { get; set; } = "admin";
|
||||||
|
|
||||||
|
public string AdminPassword { get; set; } = "admin";
|
||||||
|
}
|
||||||
542
ChessCubing.Server/Auth/KeycloakAuthService.cs
Normal file
542
ChessCubing.Server/Auth/KeycloakAuthService.cs
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ChessCubing.Server.Admin;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ChessCubing.Server.Auth;
|
||||||
|
|
||||||
|
public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<KeycloakAuthOptions> options)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions UpdateJsonOptions = new(JsonOptions)
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly HttpClient _httpClient = httpClient;
|
||||||
|
private readonly KeycloakAuthOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async Task<KeycloakUserInfo> LoginAsync(string username, string password, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tokenResponse = await RequestPasswordTokenAsync(username, password, cancellationToken);
|
||||||
|
return await GetUserInfoAsync(tokenResponse.AccessToken!, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<KeycloakUserInfo> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||||
|
var userId = await CreateUserAsync(
|
||||||
|
adminToken,
|
||||||
|
request.Username,
|
||||||
|
request.Email,
|
||||||
|
request.FirstName,
|
||||||
|
request.LastName,
|
||||||
|
isEnabled: true,
|
||||||
|
isEmailVerified: false,
|
||||||
|
cancellationToken);
|
||||||
|
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
|
||||||
|
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
|
||||||
|
return await LoginAsync(request.Username, request.Password, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminIdentityUser> CreateAdminUserAsync(
|
||||||
|
AdminIdentityUserCreateRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||||
|
var userId = await CreateUserAsync(
|
||||||
|
adminToken,
|
||||||
|
request.Username,
|
||||||
|
request.Email,
|
||||||
|
request.FirstName,
|
||||||
|
request.LastName,
|
||||||
|
request.IsEnabled,
|
||||||
|
request.IsEmailVerified,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
|
||||||
|
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
|
||||||
|
|
||||||
|
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AdminIdentityUser>> GetAdminUsersAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||||
|
var users = new List<AdminIdentityUser>();
|
||||||
|
const int pageSize = 100;
|
||||||
|
|
||||||
|
for (var first = 0; ; first += pageSize)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"{GetAdminBaseUrl()}/users?first={first}&max={pageSize}&briefRepresentation=false");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Impossible de recuperer la liste des utilisateurs Keycloak.", (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = await ReadJsonAsync<List<AdminUserRepresentation>>(response, cancellationToken) ?? [];
|
||||||
|
users.AddRange(page
|
||||||
|
.Where(user => !string.IsNullOrWhiteSpace(user.Id) && !string.IsNullOrWhiteSpace(user.Username))
|
||||||
|
.Select(MapAdminIdentityUser));
|
||||||
|
|
||||||
|
if (page.Count < pageSize)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminIdentityUser> GetAdminUserAsync(string userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||||
|
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminIdentityUser> UpdateAdminUserAsync(
|
||||||
|
string userId,
|
||||||
|
AdminIdentityUserUpdateRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||||
|
|
||||||
|
using (var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
username = request.Username,
|
||||||
|
email = request.Email,
|
||||||
|
enabled = request.IsEnabled,
|
||||||
|
emailVerified = request.IsEmailVerified,
|
||||||
|
firstName = request.FirstName,
|
||||||
|
lastName = request.LastName,
|
||||||
|
}, options: UpdateJsonOptions)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Ce nom d'utilisateur ou cet email existe deja.", StatusCodes.Status409Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("La mise a jour du compte Keycloak a echoue.", (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAdminUserAsync(string userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("La suppression du compte Keycloak a echoue.", (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var formData = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["client_id"] = _options.ClientId,
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["scope"] = "openid profile email",
|
||||||
|
["username"] = username,
|
||||||
|
["password"] = password,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var response = await _httpClient.PostAsync(
|
||||||
|
$"{GetRealmBaseUrl()}/protocol/openid-connect/token",
|
||||||
|
new FormUrlEncodedContent(formData),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var error = await ReadJsonAsync<TokenErrorResponse>(response, cancellationToken);
|
||||||
|
var message = error?.ErrorDescription switch
|
||||||
|
{
|
||||||
|
not null when error.ErrorDescription.Contains("Account is not fully set up", StringComparison.OrdinalIgnoreCase)
|
||||||
|
=> "Le compte existe mais n'est pas encore actif dans Keycloak.",
|
||||||
|
not null => "Identifiants invalides ou connexion Keycloak indisponible.",
|
||||||
|
_ => "Connexion Keycloak impossible.",
|
||||||
|
};
|
||||||
|
|
||||||
|
throw new KeycloakAuthException(message, (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = await ReadJsonAsync<TokenSuccessResponse>(response, cancellationToken);
|
||||||
|
if (token?.AccessToken is null)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Keycloak n'a pas retourne de jeton utilisable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> RequestAdminTokenAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var formData = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["client_id"] = _options.AdminClientId,
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["username"] = _options.AdminUsername,
|
||||||
|
["password"] = _options.AdminPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var response = await _httpClient.PostAsync(
|
||||||
|
$"{GetBaseUrl()}/realms/{Uri.EscapeDataString(_options.AdminRealm)}/protocol/openid-connect/token",
|
||||||
|
new FormUrlEncodedContent(formData),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Impossible d'obtenir un acces admin Keycloak.", (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = await ReadJsonAsync<TokenSuccessResponse>(response, cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(token?.AccessToken))
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Keycloak n'a pas retourne de jeton admin.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return token.AccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<KeycloakUserInfo> GetUserInfoAsync(string accessToken, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, $"{GetRealmBaseUrl()}/protocol/openid-connect/userinfo");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Impossible de recuperer le profil utilisateur Keycloak.", (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo = await ReadJsonAsync<KeycloakUserInfo>(response, cancellationToken);
|
||||||
|
if (userInfo is null)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Le profil Keycloak est invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo.Roles = ExtractRealmRoles(accessToken);
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateUserAsync(
|
||||||
|
string adminToken,
|
||||||
|
string username,
|
||||||
|
string? email,
|
||||||
|
string? firstName,
|
||||||
|
string? lastName,
|
||||||
|
bool isEnabled,
|
||||||
|
bool isEmailVerified,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
username = username.Trim(),
|
||||||
|
email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||||
|
enabled = isEnabled,
|
||||||
|
emailVerified = isEmailVerified,
|
||||||
|
firstName = string.IsNullOrWhiteSpace(firstName) ? null : firstName.Trim(),
|
||||||
|
lastName = string.IsNullOrWhiteSpace(lastName) ? null : lastName.Trim(),
|
||||||
|
}, options: JsonOptions)
|
||||||
|
};
|
||||||
|
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Ce nom d'utilisateur ou cet email existe deja.", StatusCodes.Status409Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Creation du compte impossible dans Keycloak.", (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = response.Headers.Location?.Segments.LastOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(userId))
|
||||||
|
{
|
||||||
|
return userId.Trim('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallbackUserId = await FindUserIdByUsernameAsync(adminToken, username, cancellationToken);
|
||||||
|
if (!string.IsNullOrWhiteSpace(fallbackUserId))
|
||||||
|
{
|
||||||
|
return fallbackUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new KeycloakAuthException("Le compte a ete cree mais l'identifiant Keycloak est introuvable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetPasswordAsync(string adminToken, string userId, string password, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}/reset-password")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
type = "password",
|
||||||
|
value = password,
|
||||||
|
temporary = false,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Le compte a ete cree mais le mot de passe n'a pas pu etre defini.", (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryAssignPlayerRoleAsync(string adminToken, string userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var roleRequest = new HttpRequestMessage(HttpMethod.Get, $"{GetAdminBaseUrl()}/roles/player");
|
||||||
|
roleRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
using var roleResponse = await _httpClient.SendAsync(roleRequest, cancellationToken);
|
||||||
|
if (!roleResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var role = await ReadJsonAsync<RealmRoleRepresentation>(roleResponse, cancellationToken);
|
||||||
|
if (role?.Name is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var assignRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}/role-mappings/realm")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new[]
|
||||||
|
{
|
||||||
|
new RealmRoleRepresentation
|
||||||
|
{
|
||||||
|
Id = role.Id,
|
||||||
|
Name = role.Name,
|
||||||
|
Description = role.Description,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
assignRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
using var assignResponse = await _httpClient.SendAsync(assignRequest, cancellationToken);
|
||||||
|
_ = assignResponse.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> FindUserIdByUsernameAsync(string adminToken, string username, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var httpRequest = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"{GetAdminBaseUrl()}/users?username={Uri.EscapeDataString(username)}&exact=true");
|
||||||
|
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var users = await ReadJsonAsync<List<UserRepresentation>>(response, cancellationToken);
|
||||||
|
return users?.FirstOrDefault()?.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AdminIdentityUser> GetAdminUserAsync(string adminToken, string userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Impossible de recuperer le compte Keycloak.", (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await ReadJsonAsync<AdminUserRepresentation>(response, cancellationToken);
|
||||||
|
if (user is null || string.IsNullOrWhiteSpace(user.Id) || string.IsNullOrWhiteSpace(user.Username))
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Le compte Keycloak est invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return MapAdminIdentityUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminIdentityUser MapAdminIdentityUser(AdminUserRepresentation user)
|
||||||
|
=> new(
|
||||||
|
user.Id!,
|
||||||
|
user.Username!,
|
||||||
|
user.Email,
|
||||||
|
user.FirstName,
|
||||||
|
user.LastName,
|
||||||
|
user.Enabled ?? true,
|
||||||
|
user.EmailVerified ?? false,
|
||||||
|
user.CreatedTimestamp is > 0
|
||||||
|
? DateTimeOffset.FromUnixTimeMilliseconds(user.CreatedTimestamp.Value).UtcDateTime
|
||||||
|
: null);
|
||||||
|
|
||||||
|
private string[] ExtractRealmRoles(string accessToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tokenParts = accessToken.Split('.');
|
||||||
|
if (tokenParts.Length < 2)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var payloadBytes = DecodeBase64Url(tokenParts[1]);
|
||||||
|
using var document = JsonDocument.Parse(payloadBytes);
|
||||||
|
if (!document.RootElement.TryGetProperty("realm_access", out var realmAccess) ||
|
||||||
|
!realmAccess.TryGetProperty("roles", out var rolesElement) ||
|
||||||
|
rolesElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rolesElement
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(role => role.GetString())
|
||||||
|
.Where(role => !string.IsNullOrWhiteSpace(role))
|
||||||
|
.Cast<string>()
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] DecodeBase64Url(string input)
|
||||||
|
{
|
||||||
|
var normalized = input.Replace('-', '+').Replace('_', '/');
|
||||||
|
normalized = normalized.PadRight(normalized.Length + (4 - normalized.Length % 4) % 4, '=');
|
||||||
|
return Convert.FromBase64String(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetBaseUrl()
|
||||||
|
=> _options.BaseUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
private string GetRealmBaseUrl()
|
||||||
|
=> $"{GetBaseUrl()}/realms/{Uri.EscapeDataString(_options.Realm)}";
|
||||||
|
|
||||||
|
private string GetAdminBaseUrl()
|
||||||
|
=> $"{GetBaseUrl()}/admin/realms/{Uri.EscapeDataString(_options.Realm)}";
|
||||||
|
|
||||||
|
private static async Task<T?> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<T>(content, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TokenSuccessResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string? AccessToken { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TokenErrorResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public string? Error { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("error_description")]
|
||||||
|
public string? ErrorDescription { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class UserRepresentation
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RealmRoleRepresentation
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("description")]
|
||||||
|
public string? Description { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AdminUserRepresentation
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("username")]
|
||||||
|
public string? Username { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("email")]
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("firstName")]
|
||||||
|
public string? FirstName { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("lastName")]
|
||||||
|
public string? LastName { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("enabled")]
|
||||||
|
public bool? Enabled { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("emailVerified")]
|
||||||
|
public bool? EmailVerified { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("createdTimestamp")]
|
||||||
|
public long? CreatedTimestamp { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class KeycloakAuthException(string message, int statusCode = StatusCodes.Status400BadRequest) : Exception(message)
|
||||||
|
{
|
||||||
|
public int StatusCode { get; } = statusCode;
|
||||||
|
}
|
||||||
15
ChessCubing.Server/ChessCubing.Server.csproj
Normal file
15
ChessCubing.Server/ChessCubing.Server.csproj
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>ChessCubing.Server</RootNamespace>
|
||||||
|
<AssemblyName>ChessCubing.Server</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MySqlConnector" Version="2.4.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
33
ChessCubing.Server/Data/SiteDataOptions.cs
Normal file
33
ChessCubing.Server/Data/SiteDataOptions.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using MySqlConnector;
|
||||||
|
|
||||||
|
namespace ChessCubing.Server.Data;
|
||||||
|
|
||||||
|
public sealed class SiteDataOptions
|
||||||
|
{
|
||||||
|
public string Host { get; set; } = "mysql";
|
||||||
|
|
||||||
|
public int Port { get; set; } = 3306;
|
||||||
|
|
||||||
|
public string Database { get; set; } = "chesscubing_site";
|
||||||
|
|
||||||
|
public string Username { get; set; } = "chesscubing";
|
||||||
|
|
||||||
|
public string Password { get; set; } = "chesscubing";
|
||||||
|
|
||||||
|
public int InitializationRetries { get; set; } = 12;
|
||||||
|
|
||||||
|
public int InitializationDelaySeconds { get; set; } = 3;
|
||||||
|
|
||||||
|
public string BuildConnectionString()
|
||||||
|
=> new MySqlConnectionStringBuilder
|
||||||
|
{
|
||||||
|
Server = Host,
|
||||||
|
Port = checked((uint)Port),
|
||||||
|
Database = Database,
|
||||||
|
UserID = Username,
|
||||||
|
Password = Password,
|
||||||
|
CharacterSet = "utf8mb4",
|
||||||
|
Pooling = true,
|
||||||
|
ConnectionTimeout = 15,
|
||||||
|
}.ConnectionString;
|
||||||
|
}
|
||||||
843
ChessCubing.Server/Program.cs
Normal file
843
ChessCubing.Server/Program.cs
Normal file
@@ -0,0 +1,843 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Net.Mail;
|
||||||
|
using ChessCubing.Server.Admin;
|
||||||
|
using ChessCubing.Server.Auth;
|
||||||
|
using ChessCubing.Server.Data;
|
||||||
|
using ChessCubing.Server.Social;
|
||||||
|
using ChessCubing.Server.Stats;
|
||||||
|
using ChessCubing.Server.Users;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddOptions<KeycloakAuthOptions>()
|
||||||
|
.Configure<IConfiguration>((options, configuration) =>
|
||||||
|
{
|
||||||
|
options.BaseUrl = configuration["KEYCLOAK_BASE_URL"] ?? options.BaseUrl;
|
||||||
|
options.Realm = configuration["KEYCLOAK_REALM"] ?? options.Realm;
|
||||||
|
options.ClientId = configuration["KEYCLOAK_CLIENT_ID"] ?? options.ClientId;
|
||||||
|
options.AdminRealm = configuration["KEYCLOAK_ADMIN_REALM"] ?? options.AdminRealm;
|
||||||
|
options.AdminClientId = configuration["KEYCLOAK_ADMIN_CLIENT_ID"] ?? options.AdminClientId;
|
||||||
|
options.AdminUsername = configuration["KEYCLOAK_ADMIN_USERNAME"] ?? options.AdminUsername;
|
||||||
|
options.AdminPassword = configuration["KEYCLOAK_ADMIN_PASSWORD"] ?? options.AdminPassword;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddOptions<SiteDataOptions>()
|
||||||
|
.Configure<IConfiguration>((options, configuration) =>
|
||||||
|
{
|
||||||
|
options.Host = configuration["SITE_DB_HOST"] ?? options.Host;
|
||||||
|
options.Database = configuration["SITE_DB_NAME"] ?? options.Database;
|
||||||
|
options.Username = configuration["SITE_DB_USER"] ?? options.Username;
|
||||||
|
options.Password = configuration["SITE_DB_PASSWORD"] ?? options.Password;
|
||||||
|
|
||||||
|
if (int.TryParse(configuration["SITE_DB_PORT"], out var port) && port > 0)
|
||||||
|
{
|
||||||
|
options.Port = port;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie(options =>
|
||||||
|
{
|
||||||
|
options.Cookie.Name = "chesscubing.auth";
|
||||||
|
options.Cookie.HttpOnly = true;
|
||||||
|
options.Cookie.IsEssential = true;
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||||
|
options.Cookie.MaxAge = TimeSpan.FromDays(30);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||||
|
options.Events = new CookieAuthenticationEvents
|
||||||
|
{
|
||||||
|
OnRedirectToLogin = context =>
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnRedirectToAccessDenied = context =>
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
|
||||||
|
});
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
builder.Services.AddHttpClient<KeycloakAuthService>();
|
||||||
|
builder.Services.AddSingleton<MySqlUserProfileStore>();
|
||||||
|
builder.Services.AddSingleton<MySqlSocialStore>();
|
||||||
|
builder.Services.AddSingleton<MySqlPlayerStatsStore>();
|
||||||
|
builder.Services.AddSingleton<ConnectedUserTracker>();
|
||||||
|
builder.Services.AddSingleton<PlayInviteCoordinator>();
|
||||||
|
builder.Services.AddSingleton<CollaborativeMatchCoordinator>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
await using (var scope = app.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var profileStore = scope.ServiceProvider.GetRequiredService<MySqlUserProfileStore>();
|
||||||
|
var socialStore = scope.ServiceProvider.GetRequiredService<MySqlSocialStore>();
|
||||||
|
var statsStore = scope.ServiceProvider.GetRequiredService<MySqlPlayerStatsStore>();
|
||||||
|
await profileStore.InitializeAsync(CancellationToken.None);
|
||||||
|
await socialStore.InitializeAsync(CancellationToken.None);
|
||||||
|
await statsStore.InitializeAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/health", () => TypedResults.Ok(new { status = "ok" }));
|
||||||
|
|
||||||
|
app.MapGet("/api/auth/session", (ClaimsPrincipal user) =>
|
||||||
|
TypedResults.Ok(AuthSessionResponse.FromUser(user)));
|
||||||
|
|
||||||
|
app.MapGet("/api/users/me", async Task<IResult> (
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = await profileStore.GetOrCreateAsync(siteUser, cancellationToken);
|
||||||
|
return TypedResults.Ok(profile);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapPut("/api/users/me", async Task<IResult> (
|
||||||
|
UpdateUserProfileRequest request,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var profile = await profileStore.UpdateAsync(siteUser, request, cancellationToken);
|
||||||
|
return TypedResults.Ok(profile);
|
||||||
|
}
|
||||||
|
catch (UserProfileValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/users/me/stats", async Task<IResult> (
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlPlayerStatsStore statsStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats = await statsStore.GetUserStatsAsync(siteUser.Subject, cancellationToken);
|
||||||
|
return TypedResults.Ok(stats);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapPost("/api/users/me/stats/matches", async Task<IResult> (
|
||||||
|
ReportCompletedMatchRequest request,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlPlayerStatsStore statsStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await statsStore.RecordCompletedMatchAsync(siteUser, request, cancellationToken);
|
||||||
|
return TypedResults.Ok(result);
|
||||||
|
}
|
||||||
|
catch (PlayerStatsValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
var socialGroup = app.MapGroup("/api/social")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
socialGroup.MapGet("/overview", async Task<IResult> (
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlSocialStore socialStore,
|
||||||
|
ConnectedUserTracker tracker,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var overview = await socialStore.GetOverviewAsync(siteUser.Subject, tracker.IsOnline, cancellationToken);
|
||||||
|
return TypedResults.Ok(overview);
|
||||||
|
});
|
||||||
|
|
||||||
|
socialGroup.MapGet("/search", async Task<IResult> (
|
||||||
|
string? query,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlSocialStore socialStore,
|
||||||
|
ConnectedUserTracker tracker,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var results = await socialStore.SearchUsersAsync(siteUser.Subject, query, tracker.IsOnline, cancellationToken);
|
||||||
|
return TypedResults.Ok(results);
|
||||||
|
}
|
||||||
|
catch (SocialValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socialGroup.MapPost("/invitations", async Task<IResult> (
|
||||||
|
SendFriendInvitationRequest request,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlSocialStore socialStore,
|
||||||
|
IHubContext<SocialHub> hubContext,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targetSubject = request.TargetSubject?.Trim() ?? string.Empty;
|
||||||
|
await socialStore.SendInvitationAsync(siteUser.Subject, targetSubject, cancellationToken);
|
||||||
|
await NotifySocialChangedAsync(hubContext, siteUser.Subject, targetSubject);
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
}
|
||||||
|
catch (SocialValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socialGroup.MapPost("/invitations/{invitationId:long}/accept", async Task<IResult> (
|
||||||
|
long invitationId,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlSocialStore socialStore,
|
||||||
|
IHubContext<SocialHub> hubContext,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var senderSubject = await socialStore.AcceptInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
|
||||||
|
await NotifySocialChangedAsync(hubContext, siteUser.Subject, senderSubject);
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
}
|
||||||
|
catch (SocialValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socialGroup.MapPost("/invitations/{invitationId:long}/decline", async Task<IResult> (
|
||||||
|
long invitationId,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlSocialStore socialStore,
|
||||||
|
IHubContext<SocialHub> hubContext,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var senderSubject = await socialStore.DeclineInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
|
||||||
|
await NotifySocialChangedAsync(hubContext, siteUser.Subject, senderSubject);
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
}
|
||||||
|
catch (SocialValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socialGroup.MapDelete("/invitations/{invitationId:long}", async Task<IResult> (
|
||||||
|
long invitationId,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlSocialStore socialStore,
|
||||||
|
IHubContext<SocialHub> hubContext,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var recipientSubject = await socialStore.CancelInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
|
||||||
|
await NotifySocialChangedAsync(hubContext, siteUser.Subject, recipientSubject);
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
}
|
||||||
|
catch (SocialValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socialGroup.MapDelete("/friends/{friendSubject}", async Task<IResult> (
|
||||||
|
string friendSubject,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlSocialStore socialStore,
|
||||||
|
IHubContext<SocialHub> hubContext,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedFriendSubject = friendSubject.Trim();
|
||||||
|
await socialStore.RemoveFriendAsync(siteUser.Subject, normalizedFriendSubject, cancellationToken);
|
||||||
|
await NotifySocialChangedAsync(hubContext, siteUser.Subject, normalizedFriendSubject);
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
var adminGroup = app.MapGroup("/api/admin")
|
||||||
|
.RequireAuthorization("AdminOnly");
|
||||||
|
|
||||||
|
adminGroup.MapGet("/users", async Task<IResult> (
|
||||||
|
KeycloakAuthService keycloak,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var identityUsersTask = keycloak.GetAdminUsersAsync(cancellationToken);
|
||||||
|
var siteProfilesTask = profileStore.ListAsync(cancellationToken);
|
||||||
|
|
||||||
|
await Task.WhenAll(identityUsersTask, siteProfilesTask);
|
||||||
|
|
||||||
|
var siteProfilesBySubject = (await siteProfilesTask)
|
||||||
|
.ToDictionary(profile => profile.Subject, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var users = (await identityUsersTask)
|
||||||
|
.Select(identity => MapAdminSummary(identity, siteProfilesBySubject.GetValueOrDefault(identity.Subject)))
|
||||||
|
.OrderByDescending(user => user.SiteProfileUpdatedUtc ?? user.AccountCreatedUtc ?? DateTime.MinValue)
|
||||||
|
.ThenBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return TypedResults.Ok(users);
|
||||||
|
});
|
||||||
|
|
||||||
|
adminGroup.MapPost("/users", async Task<IResult> (
|
||||||
|
AdminCreateUserRequest request,
|
||||||
|
KeycloakAuthService keycloak,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var normalized = NormalizeAdminCreate(request);
|
||||||
|
var fallbackDisplayName = BuildIdentityDisplayNameFromParts(normalized.FirstName, normalized.LastName, normalized.Username);
|
||||||
|
var siteProfileRequest = new UpdateUserProfileRequest
|
||||||
|
{
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Club = request.Club,
|
||||||
|
City = request.City,
|
||||||
|
PreferredFormat = request.PreferredFormat,
|
||||||
|
FavoriteCube = request.FavoriteCube,
|
||||||
|
Bio = request.Bio,
|
||||||
|
};
|
||||||
|
|
||||||
|
profileStore.ValidateAdminUpdate(fallbackDisplayName, siteProfileRequest);
|
||||||
|
|
||||||
|
var createdIdentity = await keycloak.CreateAdminUserAsync(
|
||||||
|
new AdminIdentityUserCreateRequest(
|
||||||
|
normalized.Username,
|
||||||
|
normalized.Email,
|
||||||
|
normalized.Password,
|
||||||
|
normalized.FirstName,
|
||||||
|
normalized.LastName,
|
||||||
|
normalized.IsEnabled,
|
||||||
|
normalized.IsEmailVerified),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var createdProfile = await profileStore.AdminUpsertAsync(
|
||||||
|
createdIdentity.Subject,
|
||||||
|
createdIdentity.Username,
|
||||||
|
createdIdentity.Email,
|
||||||
|
BuildIdentityDisplayName(createdIdentity),
|
||||||
|
siteProfileRequest,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return TypedResults.Created($"/api/admin/users/{Uri.EscapeDataString(createdIdentity.Subject)}", MapAdminDetail(createdIdentity, createdProfile));
|
||||||
|
}
|
||||||
|
catch (AdminUserValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
catch (UserProfileValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
catch (KeycloakAuthException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminGroup.MapGet("/users/{subject}", async Task<IResult> (
|
||||||
|
string subject,
|
||||||
|
KeycloakAuthService keycloak,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var identityTask = keycloak.GetAdminUserAsync(subject, cancellationToken);
|
||||||
|
var profileTask = profileStore.FindBySubjectAsync(subject, cancellationToken);
|
||||||
|
|
||||||
|
await Task.WhenAll(identityTask, profileTask);
|
||||||
|
return TypedResults.Ok(MapAdminDetail(await identityTask, await profileTask));
|
||||||
|
}
|
||||||
|
catch (KeycloakAuthException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminGroup.MapDelete("/users/{subject}", async Task<IResult> (
|
||||||
|
string subject,
|
||||||
|
KeycloakAuthService keycloak,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
MySqlSocialStore socialStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await keycloak.DeleteAdminUserAsync(subject, cancellationToken);
|
||||||
|
await socialStore.DeleteUserAsync(subject, cancellationToken);
|
||||||
|
await profileStore.DeleteAsync(subject, cancellationToken);
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
}
|
||||||
|
catch (KeycloakAuthException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminGroup.MapPut("/users/{subject}", async Task<IResult> (
|
||||||
|
string subject,
|
||||||
|
AdminUpdateUserRequest request,
|
||||||
|
KeycloakAuthService keycloak,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingIdentity = await keycloak.GetAdminUserAsync(subject, cancellationToken);
|
||||||
|
var normalized = NormalizeAdminUpdate(request);
|
||||||
|
var fallbackDisplayName = BuildIdentityDisplayNameFromParts(normalized.FirstName, normalized.LastName, existingIdentity.Username);
|
||||||
|
var siteProfileRequest = new UpdateUserProfileRequest
|
||||||
|
{
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Club = request.Club,
|
||||||
|
City = request.City,
|
||||||
|
PreferredFormat = request.PreferredFormat,
|
||||||
|
FavoriteCube = request.FavoriteCube,
|
||||||
|
Bio = request.Bio,
|
||||||
|
};
|
||||||
|
|
||||||
|
profileStore.ValidateAdminUpdate(fallbackDisplayName, siteProfileRequest);
|
||||||
|
|
||||||
|
var updatedIdentity = await keycloak.UpdateAdminUserAsync(
|
||||||
|
subject,
|
||||||
|
new AdminIdentityUserUpdateRequest(
|
||||||
|
existingIdentity.Username,
|
||||||
|
normalized.Email,
|
||||||
|
normalized.FirstName,
|
||||||
|
normalized.LastName,
|
||||||
|
normalized.IsEnabled,
|
||||||
|
normalized.IsEmailVerified),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var updatedProfile = await profileStore.AdminUpsertAsync(
|
||||||
|
updatedIdentity.Subject,
|
||||||
|
updatedIdentity.Username,
|
||||||
|
updatedIdentity.Email,
|
||||||
|
BuildIdentityDisplayName(updatedIdentity),
|
||||||
|
siteProfileRequest,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return TypedResults.Ok(MapAdminDetail(updatedIdentity, updatedProfile));
|
||||||
|
}
|
||||||
|
catch (AdminUserValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
catch (UserProfileValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
catch (KeycloakAuthException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/auth/login", async Task<IResult> (
|
||||||
|
LoginRequest request,
|
||||||
|
HttpContext httpContext,
|
||||||
|
KeycloakAuthService keycloak,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("Nom d'utilisateur et mot de passe obligatoires."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userInfo = await keycloak.LoginAsync(request.Username.Trim(), request.Password, cancellationToken);
|
||||||
|
await SignInAsync(httpContext, userInfo);
|
||||||
|
await EnsureSiteUserAsync(profileStore, userInfo, cancellationToken);
|
||||||
|
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
|
||||||
|
}
|
||||||
|
catch (KeycloakAuthException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/auth/register", async Task<IResult> (
|
||||||
|
RegisterRequest request,
|
||||||
|
HttpContext httpContext,
|
||||||
|
KeycloakAuthService keycloak,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Username) ||
|
||||||
|
string.IsNullOrWhiteSpace(request.Email) ||
|
||||||
|
string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("Nom d'utilisateur, email et mot de passe obligatoires."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(request.Password, request.ConfirmPassword, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("Les mots de passe ne correspondent pas."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userInfo = await keycloak.RegisterAsync(request with
|
||||||
|
{
|
||||||
|
Username = request.Username.Trim(),
|
||||||
|
Email = request.Email.Trim(),
|
||||||
|
FirstName = request.FirstName?.Trim(),
|
||||||
|
LastName = request.LastName?.Trim(),
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
await SignInAsync(httpContext, userInfo);
|
||||||
|
await EnsureSiteUserAsync(profileStore, userInfo, cancellationToken);
|
||||||
|
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
|
||||||
|
}
|
||||||
|
catch (KeycloakAuthException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/auth/logout", async Task<IResult> (HttpContext httpContext) =>
|
||||||
|
{
|
||||||
|
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return TypedResults.Ok(AuthSessionResponse.FromUser(new ClaimsPrincipal(new ClaimsIdentity())));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/auth/logout/browser", async Task<IResult> (HttpContext httpContext) =>
|
||||||
|
{
|
||||||
|
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return TypedResults.Redirect("/index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapHub<SocialHub>("/hubs/social")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
static AdminUserSummaryResponse MapAdminSummary(AdminIdentityUser identity, UserProfileResponse? profile)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Subject = identity.Subject,
|
||||||
|
Username = identity.Username,
|
||||||
|
Email = identity.Email,
|
||||||
|
IdentityDisplayName = BuildIdentityDisplayName(identity),
|
||||||
|
SiteDisplayName = profile?.DisplayName,
|
||||||
|
IsEnabled = identity.IsEnabled,
|
||||||
|
IsEmailVerified = identity.IsEmailVerified,
|
||||||
|
HasSiteProfile = profile is not null,
|
||||||
|
Club = profile?.Club,
|
||||||
|
City = profile?.City,
|
||||||
|
PreferredFormat = profile?.PreferredFormat,
|
||||||
|
AccountCreatedUtc = identity.CreatedUtc,
|
||||||
|
SiteProfileUpdatedUtc = profile?.UpdatedUtc,
|
||||||
|
};
|
||||||
|
|
||||||
|
static AdminUserDetailResponse MapAdminDetail(AdminIdentityUser identity, UserProfileResponse? profile)
|
||||||
|
{
|
||||||
|
var identityDisplayName = BuildIdentityDisplayName(identity);
|
||||||
|
|
||||||
|
return new AdminUserDetailResponse
|
||||||
|
{
|
||||||
|
Subject = identity.Subject,
|
||||||
|
Username = identity.Username,
|
||||||
|
Email = identity.Email,
|
||||||
|
FirstName = identity.FirstName,
|
||||||
|
LastName = identity.LastName,
|
||||||
|
IdentityDisplayName = identityDisplayName,
|
||||||
|
IsEnabled = identity.IsEnabled,
|
||||||
|
IsEmailVerified = identity.IsEmailVerified,
|
||||||
|
AccountCreatedUtc = identity.CreatedUtc,
|
||||||
|
HasSiteProfile = profile is not null,
|
||||||
|
DisplayName = profile?.DisplayName ?? identityDisplayName,
|
||||||
|
Club = profile?.Club,
|
||||||
|
City = profile?.City,
|
||||||
|
PreferredFormat = profile?.PreferredFormat,
|
||||||
|
FavoriteCube = profile?.FavoriteCube,
|
||||||
|
Bio = profile?.Bio,
|
||||||
|
SiteProfileCreatedUtc = profile?.CreatedUtc,
|
||||||
|
SiteProfileUpdatedUtc = profile?.UpdatedUtc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static NormalizedAdminUserUpdate NormalizeAdminUpdate(AdminUpdateUserRequest request)
|
||||||
|
{
|
||||||
|
var email = NormalizeEmail(request.Email);
|
||||||
|
var firstName = NormalizeOptionalValue(request.FirstName, "prenom", 120);
|
||||||
|
var lastName = NormalizeOptionalValue(request.LastName, "nom", 120);
|
||||||
|
|
||||||
|
return new NormalizedAdminUserUpdate(
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
request.IsEnabled,
|
||||||
|
request.IsEmailVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
static NormalizedAdminCreateUser NormalizeAdminCreate(AdminCreateUserRequest request)
|
||||||
|
{
|
||||||
|
var username = NormalizeRequiredValue(request.Username, "nom d'utilisateur", 120);
|
||||||
|
var email = NormalizeEmail(request.Email);
|
||||||
|
var password = NormalizePassword(request.Password, request.ConfirmPassword);
|
||||||
|
var firstName = NormalizeOptionalValue(request.FirstName, "prenom", 120);
|
||||||
|
var lastName = NormalizeOptionalValue(request.LastName, "nom", 120);
|
||||||
|
|
||||||
|
return new NormalizedAdminCreateUser(
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
request.IsEnabled,
|
||||||
|
request.IsEmailVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string BuildIdentityDisplayName(AdminIdentityUser identity)
|
||||||
|
=> BuildIdentityDisplayNameFromParts(identity.FirstName, identity.LastName, identity.Username);
|
||||||
|
|
||||||
|
static string BuildIdentityDisplayNameFromParts(string? firstName, string? lastName, string username)
|
||||||
|
{
|
||||||
|
var fullName = string.Join(' ', new[] { firstName, lastName }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||||
|
return string.IsNullOrWhiteSpace(fullName)
|
||||||
|
? username
|
||||||
|
: fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string NormalizeRequiredValue(string? value, string fieldName, int maxLength)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeOptionalValue(value, fieldName, maxLength);
|
||||||
|
return normalized ?? throw new AdminUserValidationException($"Le champ {fieldName} est obligatoire.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static string? NormalizeEmail(string? value)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeOptionalValue(value, "email", 255);
|
||||||
|
if (normalized is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = new MailAddress(normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
throw new AdminUserValidationException("L'email n'est pas valide.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string NormalizePassword(string? password, string? confirmPassword)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
throw new AdminUserValidationException("Le mot de passe est obligatoire.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(password, confirmPassword, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new AdminUserValidationException("Les mots de passe ne correspondent pas.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.Length < 8)
|
||||||
|
{
|
||||||
|
throw new AdminUserValidationException("Le mot de passe doit contenir au moins 8 caracteres.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
|
||||||
|
{
|
||||||
|
var trimmed = value?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.Length > maxLength)
|
||||||
|
{
|
||||||
|
throw new AdminUserValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo)
|
||||||
|
{
|
||||||
|
var issuedAt = DateTimeOffset.UtcNow;
|
||||||
|
var claims = new List<Claim>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(userInfo.Subject))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("sub", userInfo.Subject));
|
||||||
|
claims.Add(new Claim(ClaimTypes.NameIdentifier, userInfo.Subject));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(userInfo.PreferredUsername))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("preferred_username", userInfo.PreferredUsername));
|
||||||
|
claims.Add(new Claim(ClaimTypes.Name, userInfo.PreferredUsername));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(userInfo.Name))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("name", userInfo.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(userInfo.Email))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("email", userInfo.Email));
|
||||||
|
claims.Add(new Claim(ClaimTypes.Email, userInfo.Email));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var role in userInfo.Roles.Where(role => !string.IsNullOrWhiteSpace(role)))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("role", role));
|
||||||
|
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
await httpContext.SignInAsync(
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
principal,
|
||||||
|
new AuthenticationProperties
|
||||||
|
{
|
||||||
|
IsPersistent = true,
|
||||||
|
AllowRefresh = true,
|
||||||
|
IssuedUtc = issuedAt,
|
||||||
|
ExpiresUtc = issuedAt.AddDays(30),
|
||||||
|
});
|
||||||
|
|
||||||
|
httpContext.User = principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task EnsureSiteUserAsync(
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
KeycloakUserInfo userInfo,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromKeycloakUserInfo(userInfo);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await profileStore.GetOrCreateAsync(siteUser, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Task NotifySocialChangedAsync(IHubContext<SocialHub> hubContext, params string[] subjects)
|
||||||
|
{
|
||||||
|
var distinctSubjects = subjects
|
||||||
|
.Where(subject => !string.IsNullOrWhiteSpace(subject))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return distinctSubjects.Length == 0
|
||||||
|
? Task.CompletedTask
|
||||||
|
: hubContext.Clients.Users(distinctSubjects).SendAsync("SocialChanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed record NormalizedAdminUserUpdate(
|
||||||
|
string? Email,
|
||||||
|
string? FirstName,
|
||||||
|
string? LastName,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsEmailVerified);
|
||||||
|
|
||||||
|
sealed record NormalizedAdminCreateUser(
|
||||||
|
string Username,
|
||||||
|
string? Email,
|
||||||
|
string Password,
|
||||||
|
string? FirstName,
|
||||||
|
string? LastName,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsEmailVerified);
|
||||||
89
ChessCubing.Server/Social/CollaborativeMatchCoordinator.cs
Normal file
89
ChessCubing.Server/Social/CollaborativeMatchCoordinator.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
namespace ChessCubing.Server.Social;
|
||||||
|
|
||||||
|
public sealed class CollaborativeMatchCoordinator
|
||||||
|
{
|
||||||
|
private readonly object _sync = new();
|
||||||
|
private readonly Dictionary<string, CollaborativeSessionState> _sessions = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public void RegisterSession(PlaySessionResponse session)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
_sessions[session.SessionId] = new CollaborativeSessionState(
|
||||||
|
session.SessionId,
|
||||||
|
new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
session.WhiteSubject,
|
||||||
|
session.BlackSubject,
|
||||||
|
session.InitiatorSubject,
|
||||||
|
session.RecipientSubject,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanAccessSession(string sessionId, string subject)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
return _sessions.TryGetValue(sessionId, out var session)
|
||||||
|
&& session.ParticipantSubjects.Contains(subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CollaborativeMatchStateMessage? GetLatestState(string sessionId, string subject)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (!_sessions.TryGetValue(sessionId, out var session) ||
|
||||||
|
!session.ParticipantSubjects.Contains(subject))
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Cette session de partie est introuvable ou n'est pas accessible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.LatestState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CollaborativeMatchStateMessage PublishState(
|
||||||
|
string sessionId,
|
||||||
|
string subject,
|
||||||
|
string? matchJson,
|
||||||
|
string route)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (!_sessions.TryGetValue(sessionId, out var session) ||
|
||||||
|
!session.ParticipantSubjects.Contains(subject))
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Cette session de partie est introuvable ou n'est pas accessible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var revision = session.Revision + 1;
|
||||||
|
var message = new CollaborativeMatchStateMessage
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
MatchJson = matchJson,
|
||||||
|
Route = string.IsNullOrWhiteSpace(route) ? "/application.html" : route.Trim(),
|
||||||
|
SenderSubject = subject,
|
||||||
|
Revision = revision,
|
||||||
|
UpdatedUtc = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
_sessions[sessionId] = session with
|
||||||
|
{
|
||||||
|
Revision = revision,
|
||||||
|
LatestState = message,
|
||||||
|
};
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CollaborativeSessionState(
|
||||||
|
string SessionId,
|
||||||
|
HashSet<string> ParticipantSubjects,
|
||||||
|
CollaborativeMatchStateMessage? LatestState,
|
||||||
|
long Revision);
|
||||||
|
}
|
||||||
60
ChessCubing.Server/Social/ConnectedUserTracker.cs
Normal file
60
ChessCubing.Server/Social/ConnectedUserTracker.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
namespace ChessCubing.Server.Social;
|
||||||
|
|
||||||
|
public sealed class ConnectedUserTracker
|
||||||
|
{
|
||||||
|
private readonly object _sync = new();
|
||||||
|
private readonly Dictionary<string, string> _subjectByConnection = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, HashSet<string>> _connectionsBySubject = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public bool TrackConnection(string connectionId, string subject)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
_subjectByConnection[connectionId] = subject;
|
||||||
|
|
||||||
|
if (!_connectionsBySubject.TryGetValue(subject, out var connections))
|
||||||
|
{
|
||||||
|
connections = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
_connectionsBySubject[subject] = connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wasOffline = connections.Count == 0;
|
||||||
|
connections.Add(connectionId);
|
||||||
|
return wasOffline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public (string? Subject, bool BecameOffline) RemoveConnection(string connectionId)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (!_subjectByConnection.Remove(connectionId, out var subject))
|
||||||
|
{
|
||||||
|
return (null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_connectionsBySubject.TryGetValue(subject, out var connections))
|
||||||
|
{
|
||||||
|
return (subject, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
connections.Remove(connectionId);
|
||||||
|
if (connections.Count > 0)
|
||||||
|
{
|
||||||
|
return (subject, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectionsBySubject.Remove(subject);
|
||||||
|
return (subject, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsOnline(string subject)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
return _connectionsBySubject.TryGetValue(subject, out var connections)
|
||||||
|
&& connections.Count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
787
ChessCubing.Server/Social/MySqlSocialStore.cs
Normal file
787
ChessCubing.Server/Social/MySqlSocialStore.cs
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
using ChessCubing.Server.Data;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MySqlConnector;
|
||||||
|
|
||||||
|
namespace ChessCubing.Server.Social;
|
||||||
|
|
||||||
|
public sealed class MySqlSocialStore(
|
||||||
|
IOptions<SiteDataOptions> options,
|
||||||
|
ILogger<MySqlSocialStore> logger)
|
||||||
|
{
|
||||||
|
private const string CreateFriendshipsTableSql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS social_friendships (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
subject_low VARCHAR(190) NOT NULL,
|
||||||
|
subject_high VARCHAR(190) NOT NULL,
|
||||||
|
created_utc DATETIME(6) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_social_friendships_pair (subject_low, subject_high),
|
||||||
|
KEY ix_social_friendships_low (subject_low),
|
||||||
|
KEY ix_social_friendships_high (subject_high)
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string CreateInvitationsTableSql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS social_friend_invitations (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
sender_subject VARCHAR(190) NOT NULL,
|
||||||
|
recipient_subject VARCHAR(190) NOT NULL,
|
||||||
|
created_utc DATETIME(6) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_social_friend_invitations_pair (sender_subject, recipient_subject),
|
||||||
|
KEY ix_social_friend_invitations_sender (sender_subject),
|
||||||
|
KEY ix_social_friend_invitations_recipient (recipient_subject)
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectOverviewFriendsSql = """
|
||||||
|
SELECT
|
||||||
|
u.subject,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.email,
|
||||||
|
u.club,
|
||||||
|
u.city
|
||||||
|
FROM social_friendships f
|
||||||
|
INNER JOIN site_users u
|
||||||
|
ON u.subject = CASE
|
||||||
|
WHEN f.subject_low = @subject THEN f.subject_high
|
||||||
|
ELSE f.subject_low
|
||||||
|
END
|
||||||
|
WHERE f.subject_low = @subject OR f.subject_high = @subject
|
||||||
|
ORDER BY u.display_name ASC, u.username ASC;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectReceivedInvitationsSql = """
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
u.subject,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.email,
|
||||||
|
i.created_utc
|
||||||
|
FROM social_friend_invitations i
|
||||||
|
INNER JOIN site_users u
|
||||||
|
ON u.subject = i.sender_subject
|
||||||
|
WHERE i.recipient_subject = @subject
|
||||||
|
ORDER BY i.created_utc DESC, u.display_name ASC, u.username ASC;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectSentInvitationsSql = """
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
u.subject,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.email,
|
||||||
|
i.created_utc
|
||||||
|
FROM social_friend_invitations i
|
||||||
|
INNER JOIN site_users u
|
||||||
|
ON u.subject = i.recipient_subject
|
||||||
|
WHERE i.sender_subject = @subject
|
||||||
|
ORDER BY i.created_utc DESC, u.display_name ASC, u.username ASC;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SearchUsersTemplateSql = """
|
||||||
|
SELECT
|
||||||
|
u.subject,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.email,
|
||||||
|
u.club,
|
||||||
|
u.city,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM social_friendships f
|
||||||
|
WHERE (f.subject_low = @subject AND f.subject_high = u.subject)
|
||||||
|
OR (f.subject_high = @subject AND f.subject_low = u.subject)
|
||||||
|
) AS is_friend,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM social_friend_invitations i
|
||||||
|
WHERE i.sender_subject = @subject
|
||||||
|
AND i.recipient_subject = u.subject
|
||||||
|
) AS has_sent_invitation,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM social_friend_invitations i
|
||||||
|
WHERE i.sender_subject = u.subject
|
||||||
|
AND i.recipient_subject = @subject
|
||||||
|
) AS has_received_invitation
|
||||||
|
FROM site_users u
|
||||||
|
WHERE u.subject <> @subject
|
||||||
|
AND (
|
||||||
|
u.username LIKE @pattern
|
||||||
|
OR u.display_name LIKE @pattern
|
||||||
|
OR COALESCE(u.email, '') LIKE @pattern
|
||||||
|
OR COALESCE(u.club, '') LIKE @pattern
|
||||||
|
OR COALESCE(u.city, '') LIKE @pattern
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
is_friend DESC,
|
||||||
|
has_received_invitation DESC,
|
||||||
|
has_sent_invitation DESC,
|
||||||
|
u.display_name ASC,
|
||||||
|
u.username ASC
|
||||||
|
LIMIT @limit;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectRelevantPresenceSubjectsSql = """
|
||||||
|
SELECT participant_subject
|
||||||
|
FROM (
|
||||||
|
SELECT CASE
|
||||||
|
WHEN f.subject_low = @subject THEN f.subject_high
|
||||||
|
ELSE f.subject_low
|
||||||
|
END AS participant_subject
|
||||||
|
FROM social_friendships f
|
||||||
|
WHERE f.subject_low = @subject OR f.subject_high = @subject
|
||||||
|
|
||||||
|
UNION
|
||||||
|
|
||||||
|
SELECT i.sender_subject AS participant_subject
|
||||||
|
FROM social_friend_invitations i
|
||||||
|
WHERE i.recipient_subject = @subject
|
||||||
|
|
||||||
|
UNION
|
||||||
|
|
||||||
|
SELECT i.recipient_subject AS participant_subject
|
||||||
|
FROM social_friend_invitations i
|
||||||
|
WHERE i.sender_subject = @subject
|
||||||
|
) participants
|
||||||
|
WHERE participant_subject <> @subject;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectFriendSubjectsSql = """
|
||||||
|
SELECT CASE
|
||||||
|
WHEN f.subject_low = @subject THEN f.subject_high
|
||||||
|
ELSE f.subject_low
|
||||||
|
END AS friend_subject
|
||||||
|
FROM social_friendships f
|
||||||
|
WHERE f.subject_low = @subject OR f.subject_high = @subject;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectKnownUserSubjectSql = """
|
||||||
|
SELECT subject
|
||||||
|
FROM site_users
|
||||||
|
WHERE subject = @subject
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectInvitationBetweenSql = """
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
sender_subject,
|
||||||
|
recipient_subject
|
||||||
|
FROM social_friend_invitations
|
||||||
|
WHERE (sender_subject = @subjectA AND recipient_subject = @subjectB)
|
||||||
|
OR (sender_subject = @subjectB AND recipient_subject = @subjectA)
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectInvitationForRecipientSql = """
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
sender_subject,
|
||||||
|
recipient_subject
|
||||||
|
FROM social_friend_invitations
|
||||||
|
WHERE id = @invitationId
|
||||||
|
AND recipient_subject = @recipientSubject
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectFriendshipExistsSql = """
|
||||||
|
SELECT 1
|
||||||
|
FROM social_friendships
|
||||||
|
WHERE subject_low = @subjectLow
|
||||||
|
AND subject_high = @subjectHigh
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string InsertInvitationSql = """
|
||||||
|
INSERT INTO social_friend_invitations (
|
||||||
|
sender_subject,
|
||||||
|
recipient_subject,
|
||||||
|
created_utc
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@senderSubject,
|
||||||
|
@recipientSubject,
|
||||||
|
@createdUtc
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string InsertFriendshipSql = """
|
||||||
|
INSERT IGNORE INTO social_friendships (
|
||||||
|
subject_low,
|
||||||
|
subject_high,
|
||||||
|
created_utc
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@subjectLow,
|
||||||
|
@subjectHigh,
|
||||||
|
@createdUtc
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string DeleteInvitationByRecipientSql = """
|
||||||
|
DELETE FROM social_friend_invitations
|
||||||
|
WHERE id = @invitationId
|
||||||
|
AND recipient_subject = @recipientSubject;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string DeleteInvitationBySenderSql = """
|
||||||
|
DELETE FROM social_friend_invitations
|
||||||
|
WHERE id = @invitationId
|
||||||
|
AND sender_subject = @senderSubject;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string DeleteInvitationsBetweenSql = """
|
||||||
|
DELETE FROM social_friend_invitations
|
||||||
|
WHERE (sender_subject = @subjectA AND recipient_subject = @subjectB)
|
||||||
|
OR (sender_subject = @subjectB AND recipient_subject = @subjectA);
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string DeleteFriendshipSql = """
|
||||||
|
DELETE FROM social_friendships
|
||||||
|
WHERE subject_low = @subjectLow
|
||||||
|
AND subject_high = @subjectHigh;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string DeleteUserFriendshipsSql = """
|
||||||
|
DELETE FROM social_friendships
|
||||||
|
WHERE subject_low = @subject
|
||||||
|
OR subject_high = @subject;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string DeleteUserInvitationsSql = """
|
||||||
|
DELETE FROM social_friend_invitations
|
||||||
|
WHERE sender_subject = @subject
|
||||||
|
OR recipient_subject = @subject;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private readonly SiteDataOptions _options = options.Value;
|
||||||
|
private readonly ILogger<MySqlSocialStore> _logger = logger;
|
||||||
|
|
||||||
|
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
await CreateSchemaAsync(connection, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (attempt < _options.InitializationRetries)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Initialisation MySQL impossible pour le module social (tentative {Attempt}/{MaxAttempts}).",
|
||||||
|
attempt,
|
||||||
|
_options.InitializationRetries);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await finalConnection.OpenAsync(cancellationToken);
|
||||||
|
await CreateSchemaAsync(finalConnection, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SocialOverviewResponse> GetOverviewAsync(
|
||||||
|
string subject,
|
||||||
|
Func<string, bool> isOnline,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var friends = new List<SocialFriendResponse>();
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = SelectOverviewFriendsSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var friendSubject = ReadString(reader, "subject");
|
||||||
|
friends.Add(new SocialFriendResponse
|
||||||
|
{
|
||||||
|
Subject = friendSubject,
|
||||||
|
Username = ReadString(reader, "username"),
|
||||||
|
DisplayName = ReadString(reader, "display_name"),
|
||||||
|
Email = ReadNullableString(reader, "email"),
|
||||||
|
Club = ReadNullableString(reader, "club"),
|
||||||
|
City = ReadNullableString(reader, "city"),
|
||||||
|
IsOnline = isOnline(friendSubject),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var receivedInvitations = new List<SocialInvitationResponse>();
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = SelectReceivedInvitationsSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var senderSubject = ReadString(reader, "subject");
|
||||||
|
receivedInvitations.Add(new SocialInvitationResponse
|
||||||
|
{
|
||||||
|
InvitationId = ReadInt64(reader, "id"),
|
||||||
|
Subject = senderSubject,
|
||||||
|
Username = ReadString(reader, "username"),
|
||||||
|
DisplayName = ReadString(reader, "display_name"),
|
||||||
|
Email = ReadNullableString(reader, "email"),
|
||||||
|
CreatedUtc = ReadDateTime(reader, "created_utc"),
|
||||||
|
IsOnline = isOnline(senderSubject),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sentInvitations = new List<SocialInvitationResponse>();
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = SelectSentInvitationsSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var recipientSubject = ReadString(reader, "subject");
|
||||||
|
sentInvitations.Add(new SocialInvitationResponse
|
||||||
|
{
|
||||||
|
InvitationId = ReadInt64(reader, "id"),
|
||||||
|
Subject = recipientSubject,
|
||||||
|
Username = ReadString(reader, "username"),
|
||||||
|
DisplayName = ReadString(reader, "display_name"),
|
||||||
|
Email = ReadNullableString(reader, "email"),
|
||||||
|
CreatedUtc = ReadDateTime(reader, "created_utc"),
|
||||||
|
IsOnline = isOnline(recipientSubject),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SocialOverviewResponse
|
||||||
|
{
|
||||||
|
Friends = friends.ToArray(),
|
||||||
|
ReceivedInvitations = receivedInvitations.ToArray(),
|
||||||
|
SentInvitations = sentInvitations.ToArray(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SocialSearchUserResponse>> SearchUsersAsync(
|
||||||
|
string subject,
|
||||||
|
string? query,
|
||||||
|
Func<string, bool> isOnline,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedQuery = NormalizeQuery(query);
|
||||||
|
if (normalizedQuery is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = SearchUsersTemplateSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
command.Parameters.AddWithValue("@pattern", $"%{normalizedQuery}%");
|
||||||
|
command.Parameters.AddWithValue("@limit", 12);
|
||||||
|
|
||||||
|
var results = new List<SocialSearchUserResponse>();
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var foundSubject = ReadString(reader, "subject");
|
||||||
|
results.Add(new SocialSearchUserResponse
|
||||||
|
{
|
||||||
|
Subject = foundSubject,
|
||||||
|
Username = ReadString(reader, "username"),
|
||||||
|
DisplayName = ReadString(reader, "display_name"),
|
||||||
|
Email = ReadNullableString(reader, "email"),
|
||||||
|
Club = ReadNullableString(reader, "club"),
|
||||||
|
City = ReadNullableString(reader, "city"),
|
||||||
|
IsOnline = isOnline(foundSubject),
|
||||||
|
IsFriend = ReadBoolean(reader, "is_friend"),
|
||||||
|
HasSentInvitation = ReadBoolean(reader, "has_sent_invitation"),
|
||||||
|
HasReceivedInvitation = ReadBoolean(reader, "has_received_invitation"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendInvitationAsync(
|
||||||
|
string senderSubject,
|
||||||
|
string recipientSubject,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.Equals(senderSubject, recipientSubject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Impossible de t'inviter toi-meme en ami.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!await UserExistsAsync(connection, transaction, recipientSubject, cancellationToken))
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Le joueur cible est introuvable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var pair = NormalizePair(senderSubject, recipientSubject);
|
||||||
|
if (await FriendshipExistsAsync(connection, transaction, pair.SubjectLow, pair.SubjectHigh, cancellationToken))
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Ce joueur fait deja partie de tes amis.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var invitation = await ReadInvitationBetweenAsync(connection, transaction, senderSubject, recipientSubject, cancellationToken);
|
||||||
|
if (invitation is not null)
|
||||||
|
{
|
||||||
|
throw new SocialValidationException(
|
||||||
|
string.Equals(invitation.SenderSubject, senderSubject, StringComparison.Ordinal)
|
||||||
|
? "Une invitation est deja en attente pour ce joueur."
|
||||||
|
: "Ce joueur t'a deja envoye une invitation. Accepte-la depuis la page utilisateur.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = InsertInvitationSql;
|
||||||
|
command.Parameters.AddWithValue("@senderSubject", senderSubject);
|
||||||
|
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
|
||||||
|
command.Parameters.AddWithValue("@createdUtc", DateTime.UtcNow);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> AcceptInvitationAsync(long invitationId, string recipientSubject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
|
var invitation = await ReadInvitationForRecipientAsync(connection, transaction, invitationId, recipientSubject, cancellationToken)
|
||||||
|
?? throw new SocialValidationException("Invitation introuvable ou deja traitee.");
|
||||||
|
|
||||||
|
var pair = NormalizePair(invitation.SenderSubject, invitation.RecipientSubject);
|
||||||
|
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = InsertFriendshipSql;
|
||||||
|
command.Parameters.AddWithValue("@subjectLow", pair.SubjectLow);
|
||||||
|
command.Parameters.AddWithValue("@subjectHigh", pair.SubjectHigh);
|
||||||
|
command.Parameters.AddWithValue("@createdUtc", DateTime.UtcNow);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = DeleteInvitationsBetweenSql;
|
||||||
|
command.Parameters.AddWithValue("@subjectA", invitation.SenderSubject);
|
||||||
|
command.Parameters.AddWithValue("@subjectB", invitation.RecipientSubject);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return invitation.SenderSubject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> DeclineInvitationAsync(long invitationId, string recipientSubject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||||
|
var invitation = await ReadInvitationForRecipientAsync(connection, transaction: null, invitationId, recipientSubject, cancellationToken)
|
||||||
|
?? throw new SocialValidationException("Invitation introuvable ou deja traitee.");
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = DeleteInvitationByRecipientSql;
|
||||||
|
command.Parameters.AddWithValue("@invitationId", invitationId);
|
||||||
|
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
|
||||||
|
|
||||||
|
var deleted = await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
if (deleted <= 0)
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Invitation introuvable ou deja traitee.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return invitation.SenderSubject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> CancelInvitationAsync(long invitationId, string senderSubject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||||
|
var invitation = await ReadInvitationForSenderAsync(connection, transaction: null, invitationId, senderSubject, cancellationToken)
|
||||||
|
?? throw new SocialValidationException("Invitation introuvable ou deja retiree.");
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = DeleteInvitationBySenderSql;
|
||||||
|
command.Parameters.AddWithValue("@invitationId", invitationId);
|
||||||
|
command.Parameters.AddWithValue("@senderSubject", senderSubject);
|
||||||
|
|
||||||
|
var deleted = await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
if (deleted <= 0)
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Invitation introuvable ou deja retiree.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return invitation.RecipientSubject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveFriendAsync(string subject, string friendSubject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var pair = NormalizePair(subject, friendSubject);
|
||||||
|
|
||||||
|
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = DeleteFriendshipSql;
|
||||||
|
command.Parameters.AddWithValue("@subjectLow", pair.SubjectLow);
|
||||||
|
command.Parameters.AddWithValue("@subjectHigh", pair.SubjectHigh);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> AreFriendsAsync(string subject, string otherSubject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var pair = NormalizePair(subject, otherSubject);
|
||||||
|
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||||
|
return await FriendshipExistsAsync(connection, transaction: null, pair.SubjectLow, pair.SubjectHigh, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> GetRelevantPresenceSubjectsAsync(string subject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = SelectRelevantPresenceSubjectsSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
|
||||||
|
var subjects = new List<string>();
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
subjects.Add(ReadString(reader, "participant_subject"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return subjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> GetFriendSubjectsAsync(string subject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = SelectFriendSubjectsSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
|
||||||
|
var subjects = new List<string>();
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
subjects.Add(ReadString(reader, "friend_subject"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return subjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteUserAsync(string subject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = DeleteUserFriendshipsSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = DeleteUserInvitationsSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateSchemaAsync(MySqlConnection connection, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = CreateFriendshipsTableSql;
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = CreateInvitationsTableSql;
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string SubjectLow, string SubjectHigh) NormalizePair(string subjectA, string subjectB)
|
||||||
|
=> string.CompareOrdinal(subjectA, subjectB) <= 0
|
||||||
|
? (subjectA, subjectB)
|
||||||
|
: (subjectB, subjectA);
|
||||||
|
|
||||||
|
private static string? NormalizeQuery(string? query)
|
||||||
|
{
|
||||||
|
var trimmed = query?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.Length < 2)
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("La recherche d'amis demande au moins 2 caracteres.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.Length > 80
|
||||||
|
? trimmed[..80]
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> UserExistsAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
MySqlTransaction transaction,
|
||||||
|
string subject,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = SelectKnownUserSubjectSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
return await command.ExecuteScalarAsync(cancellationToken) is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> FriendshipExistsAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
MySqlTransaction? transaction,
|
||||||
|
string subjectLow,
|
||||||
|
string subjectHigh,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = SelectFriendshipExistsSql;
|
||||||
|
command.Parameters.AddWithValue("@subjectLow", subjectLow);
|
||||||
|
command.Parameters.AddWithValue("@subjectHigh", subjectHigh);
|
||||||
|
return await command.ExecuteScalarAsync(cancellationToken) is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<InvitationRow?> ReadInvitationBetweenAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
MySqlTransaction transaction,
|
||||||
|
string subjectA,
|
||||||
|
string subjectB,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = SelectInvitationBetweenSql;
|
||||||
|
command.Parameters.AddWithValue("@subjectA", subjectA);
|
||||||
|
command.Parameters.AddWithValue("@subjectB", subjectB);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InvitationRow(
|
||||||
|
ReadInt64(reader, "id"),
|
||||||
|
ReadString(reader, "sender_subject"),
|
||||||
|
ReadString(reader, "recipient_subject"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<InvitationRow?> ReadInvitationForRecipientAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
MySqlTransaction? transaction,
|
||||||
|
long invitationId,
|
||||||
|
string recipientSubject,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = SelectInvitationForRecipientSql;
|
||||||
|
command.Parameters.AddWithValue("@invitationId", invitationId);
|
||||||
|
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InvitationRow(
|
||||||
|
ReadInt64(reader, "id"),
|
||||||
|
ReadString(reader, "sender_subject"),
|
||||||
|
ReadString(reader, "recipient_subject"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<InvitationRow?> ReadInvitationForSenderAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
MySqlTransaction? transaction,
|
||||||
|
long invitationId,
|
||||||
|
string senderSubject,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
sender_subject,
|
||||||
|
recipient_subject
|
||||||
|
FROM social_friend_invitations
|
||||||
|
WHERE id = @invitationId
|
||||||
|
AND sender_subject = @senderSubject
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("@invitationId", invitationId);
|
||||||
|
command.Parameters.AddWithValue("@senderSubject", senderSubject);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InvitationRow(
|
||||||
|
ReadInt64(reader, "id"),
|
||||||
|
ReadString(reader, "sender_subject"),
|
||||||
|
ReadString(reader, "recipient_subject"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ReadBoolean(MySqlDataReader reader, string columnName)
|
||||||
|
=> Convert.ToInt32(reader[columnName]) > 0;
|
||||||
|
|
||||||
|
private static string ReadString(MySqlDataReader reader, string columnName)
|
||||||
|
=> reader.GetString(reader.GetOrdinal(columnName));
|
||||||
|
|
||||||
|
private static long ReadInt64(MySqlDataReader reader, string columnName)
|
||||||
|
=> reader.GetInt64(reader.GetOrdinal(columnName));
|
||||||
|
|
||||||
|
private static DateTime ReadDateTime(MySqlDataReader reader, string columnName)
|
||||||
|
=> reader.GetDateTime(reader.GetOrdinal(columnName));
|
||||||
|
|
||||||
|
private static string? ReadNullableString(MySqlDataReader reader, string columnName)
|
||||||
|
{
|
||||||
|
var ordinal = reader.GetOrdinal(columnName);
|
||||||
|
return reader.IsDBNull(ordinal)
|
||||||
|
? null
|
||||||
|
: reader.GetString(ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record InvitationRow(long Id, string SenderSubject, string RecipientSubject);
|
||||||
|
}
|
||||||
211
ChessCubing.Server/Social/PlayInviteCoordinator.cs
Normal file
211
ChessCubing.Server/Social/PlayInviteCoordinator.cs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
namespace ChessCubing.Server.Social;
|
||||||
|
|
||||||
|
public sealed class PlayInviteCoordinator
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan InviteLifetime = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
private readonly object _sync = new();
|
||||||
|
private readonly Dictionary<string, PlayInviteState> _invitesById = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, string> _inviteByParticipant = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public PlayInviteMessage CreateInvite(PlayInviteParticipant sender, PlayInviteParticipant recipient, string recipientColor)
|
||||||
|
{
|
||||||
|
var normalizedColor = NormalizeRecipientColor(recipientColor);
|
||||||
|
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
CleanupExpiredUnsafe();
|
||||||
|
|
||||||
|
if (_inviteByParticipant.ContainsKey(sender.Subject))
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Une invitation de partie est deja en cours pour ton compte.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_inviteByParticipant.ContainsKey(recipient.Subject))
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Cet ami traite deja une autre invitation de partie.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var state = new PlayInviteState(
|
||||||
|
Guid.NewGuid().ToString("N"),
|
||||||
|
sender,
|
||||||
|
recipient,
|
||||||
|
normalizedColor,
|
||||||
|
nowUtc,
|
||||||
|
nowUtc.Add(InviteLifetime));
|
||||||
|
|
||||||
|
_invitesById[state.InviteId] = state;
|
||||||
|
_inviteByParticipant[sender.Subject] = state.InviteId;
|
||||||
|
_inviteByParticipant[recipient.Subject] = state.InviteId;
|
||||||
|
|
||||||
|
return MapInvite(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayInviteCloseResult CancelInvite(string inviteId, string senderSubject)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
var state = GetActiveInviteUnsafe(inviteId);
|
||||||
|
if (!string.Equals(state.Sender.Subject, senderSubject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Seul l'expediteur peut annuler cette invitation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveInviteUnsafe(state);
|
||||||
|
return new PlayInviteCloseResult(
|
||||||
|
state.Sender.Subject,
|
||||||
|
state.Recipient.Subject,
|
||||||
|
new PlayInviteClosedMessage
|
||||||
|
{
|
||||||
|
InviteId = state.InviteId,
|
||||||
|
Reason = "cancelled",
|
||||||
|
Message = $"{state.Sender.DisplayName} a annule l'invitation de partie.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayInviteCloseResult DeclineInvite(string inviteId, string recipientSubject)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
var state = GetActiveInviteUnsafe(inviteId);
|
||||||
|
if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Seul le destinataire peut refuser cette invitation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveInviteUnsafe(state);
|
||||||
|
return new PlayInviteCloseResult(
|
||||||
|
state.Sender.Subject,
|
||||||
|
state.Recipient.Subject,
|
||||||
|
new PlayInviteClosedMessage
|
||||||
|
{
|
||||||
|
InviteId = state.InviteId,
|
||||||
|
Reason = "declined",
|
||||||
|
Message = $"{state.Recipient.DisplayName} a refuse la partie.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayInviteAcceptResult AcceptInvite(string inviteId, string recipientSubject)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
var state = GetActiveInviteUnsafe(inviteId);
|
||||||
|
if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Seul le destinataire peut accepter cette invitation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveInviteUnsafe(state);
|
||||||
|
|
||||||
|
var white = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal)
|
||||||
|
? state.Recipient
|
||||||
|
: state.Sender;
|
||||||
|
var black = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal)
|
||||||
|
? state.Sender
|
||||||
|
: state.Recipient;
|
||||||
|
|
||||||
|
var session = new PlaySessionResponse
|
||||||
|
{
|
||||||
|
SessionId = Guid.NewGuid().ToString("N"),
|
||||||
|
WhiteSubject = white.Subject,
|
||||||
|
WhiteName = white.DisplayName,
|
||||||
|
BlackSubject = black.Subject,
|
||||||
|
BlackName = black.DisplayName,
|
||||||
|
InitiatorSubject = state.Sender.Subject,
|
||||||
|
RecipientSubject = state.Recipient.Subject,
|
||||||
|
ConfirmedUtc = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new PlayInviteAcceptResult(state.Sender.Subject, state.Recipient.Subject, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRecipientColor(string recipientColor)
|
||||||
|
{
|
||||||
|
var normalized = recipientColor.Trim().ToLowerInvariant();
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"white" => "white",
|
||||||
|
"black" => "black",
|
||||||
|
_ => throw new SocialValidationException("La couleur demandee pour l'ami doit etre blanc ou noir."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayInviteMessage MapInvite(PlayInviteState state)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
InviteId = state.InviteId,
|
||||||
|
SenderSubject = state.Sender.Subject,
|
||||||
|
SenderUsername = state.Sender.Username,
|
||||||
|
SenderDisplayName = state.Sender.DisplayName,
|
||||||
|
RecipientSubject = state.Recipient.Subject,
|
||||||
|
RecipientUsername = state.Recipient.Username,
|
||||||
|
RecipientDisplayName = state.Recipient.DisplayName,
|
||||||
|
RecipientColor = state.RecipientColor,
|
||||||
|
CreatedUtc = state.CreatedUtc,
|
||||||
|
ExpiresUtc = state.ExpiresUtc,
|
||||||
|
};
|
||||||
|
|
||||||
|
private PlayInviteState GetActiveInviteUnsafe(string inviteId)
|
||||||
|
{
|
||||||
|
CleanupExpiredUnsafe();
|
||||||
|
|
||||||
|
if (!_invitesById.TryGetValue(inviteId, out var state))
|
||||||
|
{
|
||||||
|
throw new SocialValidationException("Cette invitation de partie n'est plus disponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupExpiredUnsafe()
|
||||||
|
{
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var expiredInviteIds = _invitesById.Values
|
||||||
|
.Where(state => state.ExpiresUtc <= nowUtc)
|
||||||
|
.Select(state => state.InviteId)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var expiredInviteId in expiredInviteIds)
|
||||||
|
{
|
||||||
|
if (_invitesById.TryGetValue(expiredInviteId, out var state))
|
||||||
|
{
|
||||||
|
RemoveInviteUnsafe(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveInviteUnsafe(PlayInviteState state)
|
||||||
|
{
|
||||||
|
_invitesById.Remove(state.InviteId);
|
||||||
|
_inviteByParticipant.Remove(state.Sender.Subject);
|
||||||
|
_inviteByParticipant.Remove(state.Recipient.Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PlayInviteState(
|
||||||
|
string InviteId,
|
||||||
|
PlayInviteParticipant Sender,
|
||||||
|
PlayInviteParticipant Recipient,
|
||||||
|
string RecipientColor,
|
||||||
|
DateTime CreatedUtc,
|
||||||
|
DateTime ExpiresUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct PlayInviteParticipant(
|
||||||
|
string Subject,
|
||||||
|
string Username,
|
||||||
|
string DisplayName);
|
||||||
|
|
||||||
|
public sealed record PlayInviteCloseResult(
|
||||||
|
string SenderSubject,
|
||||||
|
string RecipientSubject,
|
||||||
|
PlayInviteClosedMessage ClosedMessage);
|
||||||
|
|
||||||
|
public sealed record PlayInviteAcceptResult(
|
||||||
|
string SenderSubject,
|
||||||
|
string RecipientSubject,
|
||||||
|
PlaySessionResponse Session);
|
||||||
152
ChessCubing.Server/Social/SocialContracts.cs
Normal file
152
ChessCubing.Server/Social/SocialContracts.cs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
namespace ChessCubing.Server.Social;
|
||||||
|
|
||||||
|
public sealed class SocialOverviewResponse
|
||||||
|
{
|
||||||
|
public SocialFriendResponse[] Friends { get; init; } = [];
|
||||||
|
|
||||||
|
public SocialInvitationResponse[] ReceivedInvitations { get; init; } = [];
|
||||||
|
|
||||||
|
public SocialInvitationResponse[] SentInvitations { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SocialFriendResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string? Club { get; init; }
|
||||||
|
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
public bool IsOnline { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SocialInvitationResponse
|
||||||
|
{
|
||||||
|
public long InvitationId { get; init; }
|
||||||
|
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public bool IsOnline { get; init; }
|
||||||
|
|
||||||
|
public DateTime CreatedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SocialSearchUserResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string? Club { get; init; }
|
||||||
|
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
public bool IsOnline { get; init; }
|
||||||
|
|
||||||
|
public bool IsFriend { get; init; }
|
||||||
|
|
||||||
|
public bool HasSentInvitation { get; init; }
|
||||||
|
|
||||||
|
public bool HasReceivedInvitation { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SendFriendInvitationRequest
|
||||||
|
{
|
||||||
|
public string TargetSubject { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PresenceSnapshotMessage
|
||||||
|
{
|
||||||
|
public string[] OnlineSubjects { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PresenceChangedMessage
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsOnline { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlayInviteMessage
|
||||||
|
{
|
||||||
|
public string InviteId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string SenderSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string SenderUsername { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string SenderDisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string RecipientSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string RecipientUsername { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string RecipientDisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string RecipientColor { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime CreatedUtc { get; init; }
|
||||||
|
|
||||||
|
public DateTime ExpiresUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlayInviteClosedMessage
|
||||||
|
{
|
||||||
|
public string InviteId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Reason { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Message { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlaySessionResponse
|
||||||
|
{
|
||||||
|
public string SessionId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string WhiteSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string WhiteName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string BlackSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string BlackName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string InitiatorSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string RecipientSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime ConfirmedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CollaborativeMatchStateMessage
|
||||||
|
{
|
||||||
|
public string SessionId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? MatchJson { get; init; }
|
||||||
|
|
||||||
|
public string Route { get; init; } = "/application.html";
|
||||||
|
|
||||||
|
public string SenderSubject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public long Revision { get; init; }
|
||||||
|
|
||||||
|
public DateTime UpdatedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SocialValidationException(string message) : Exception(message);
|
||||||
218
ChessCubing.Server/Social/SocialHub.cs
Normal file
218
ChessCubing.Server/Social/SocialHub.cs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
using ChessCubing.Server.Users;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace ChessCubing.Server.Social;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
public sealed class SocialHub(
|
||||||
|
ConnectedUserTracker tracker,
|
||||||
|
MySqlSocialStore socialStore,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
PlayInviteCoordinator playInviteCoordinator,
|
||||||
|
CollaborativeMatchCoordinator collaborativeMatchCoordinator) : Hub
|
||||||
|
{
|
||||||
|
private readonly ConnectedUserTracker _tracker = tracker;
|
||||||
|
private readonly MySqlSocialStore _socialStore = socialStore;
|
||||||
|
private readonly MySqlUserProfileStore _profileStore = profileStore;
|
||||||
|
private readonly PlayInviteCoordinator _playInviteCoordinator = playInviteCoordinator;
|
||||||
|
private readonly CollaborativeMatchCoordinator _collaborativeMatchCoordinator = collaborativeMatchCoordinator;
|
||||||
|
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
var siteUser = RequireCurrentUser();
|
||||||
|
var becameOnline = _tracker.TrackConnection(Context.ConnectionId, siteUser.Subject);
|
||||||
|
|
||||||
|
await SendPresenceSnapshotAsync(siteUser.Subject, Context.ConnectionAborted);
|
||||||
|
|
||||||
|
if (becameOnline)
|
||||||
|
{
|
||||||
|
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(siteUser.Subject, Context.ConnectionAborted);
|
||||||
|
await NotifyPresenceChangedAsync(relevantSubjects, siteUser.Subject, isOnline: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
var (subject, becameOffline) = _tracker.RemoveConnection(Context.ConnectionId);
|
||||||
|
if (becameOffline && !string.IsNullOrWhiteSpace(subject))
|
||||||
|
{
|
||||||
|
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(subject, CancellationToken.None);
|
||||||
|
await NotifyPresenceChangedAsync(relevantSubjects, subject, isOnline: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RequestPresenceSnapshot()
|
||||||
|
{
|
||||||
|
var siteUser = RequireCurrentUser();
|
||||||
|
await SendPresenceSnapshotAsync(siteUser.Subject, Context.ConnectionAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PlayInviteMessage> SendPlayInvite(string recipientSubject, string recipientColor)
|
||||||
|
{
|
||||||
|
var sender = RequireCurrentUser();
|
||||||
|
var normalizedRecipientSubject = recipientSubject.Trim();
|
||||||
|
|
||||||
|
if (!await _socialStore.AreFriendsAsync(sender.Subject, normalizedRecipientSubject, Context.ConnectionAborted))
|
||||||
|
{
|
||||||
|
throw new HubException("Seuls tes amis peuvent recevoir une invitation de partie.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_tracker.IsOnline(normalizedRecipientSubject))
|
||||||
|
{
|
||||||
|
throw new HubException("Cet ami n'est plus connecte pour le moment.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var senderProfile = await _profileStore.GetOrCreateAsync(sender, Context.ConnectionAborted);
|
||||||
|
var recipientProfile = await _profileStore.FindBySubjectAsync(normalizedRecipientSubject, Context.ConnectionAborted);
|
||||||
|
if (recipientProfile is null)
|
||||||
|
{
|
||||||
|
throw new HubException("Le profil de cet ami est introuvable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var message = _playInviteCoordinator.CreateInvite(
|
||||||
|
new PlayInviteParticipant(sender.Subject, sender.Username, senderProfile.DisplayName),
|
||||||
|
new PlayInviteParticipant(recipientProfile.Subject, recipientProfile.Username, recipientProfile.DisplayName),
|
||||||
|
recipientColor);
|
||||||
|
|
||||||
|
await Clients.Users([sender.Subject, recipientProfile.Subject])
|
||||||
|
.SendAsync("PlayInviteUpdated", message, Context.ConnectionAborted);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
catch (SocialValidationException exception)
|
||||||
|
{
|
||||||
|
throw new HubException(exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RespondToPlayInvite(string inviteId, bool accept)
|
||||||
|
{
|
||||||
|
var recipient = RequireCurrentUser();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (accept)
|
||||||
|
{
|
||||||
|
var accepted = _playInviteCoordinator.AcceptInvite(inviteId, recipient.Subject);
|
||||||
|
_collaborativeMatchCoordinator.RegisterSession(accepted.Session);
|
||||||
|
await Clients.Users([accepted.SenderSubject, accepted.RecipientSubject])
|
||||||
|
.SendAsync("PlayInviteAccepted", accepted.Session, Context.ConnectionAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var declined = _playInviteCoordinator.DeclineInvite(inviteId, recipient.Subject);
|
||||||
|
await Clients.Users([declined.SenderSubject, declined.RecipientSubject])
|
||||||
|
.SendAsync("PlayInviteClosed", declined.ClosedMessage, Context.ConnectionAborted);
|
||||||
|
}
|
||||||
|
catch (SocialValidationException exception)
|
||||||
|
{
|
||||||
|
throw new HubException(exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CancelPlayInvite(string inviteId)
|
||||||
|
{
|
||||||
|
var sender = RequireCurrentUser();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cancelled = _playInviteCoordinator.CancelInvite(inviteId, sender.Subject);
|
||||||
|
await Clients.Users([cancelled.SenderSubject, cancelled.RecipientSubject])
|
||||||
|
.SendAsync("PlayInviteClosed", cancelled.ClosedMessage, Context.ConnectionAborted);
|
||||||
|
}
|
||||||
|
catch (SocialValidationException exception)
|
||||||
|
{
|
||||||
|
throw new HubException(exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CollaborativeMatchStateMessage?> JoinPlaySession(string sessionId)
|
||||||
|
{
|
||||||
|
var siteUser = RequireCurrentUser();
|
||||||
|
var normalizedSessionId = sessionId.Trim();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var latestState = _collaborativeMatchCoordinator.GetLatestState(normalizedSessionId, siteUser.Subject);
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, BuildSessionGroupName(normalizedSessionId), Context.ConnectionAborted);
|
||||||
|
return latestState;
|
||||||
|
}
|
||||||
|
catch (SocialValidationException exception)
|
||||||
|
{
|
||||||
|
throw new HubException(exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LeavePlaySession(string sessionId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, BuildSessionGroupName(sessionId.Trim()), Context.ConnectionAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PublishMatchState(string sessionId, string? matchJson, string route)
|
||||||
|
{
|
||||||
|
var siteUser = RequireCurrentUser();
|
||||||
|
var normalizedSessionId = sessionId.Trim();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var message = _collaborativeMatchCoordinator.PublishState(normalizedSessionId, siteUser.Subject, matchJson, route);
|
||||||
|
await Clients.GroupExcept(BuildSessionGroupName(normalizedSessionId), [Context.ConnectionId])
|
||||||
|
.SendAsync("CollaborativeMatchStateUpdated", message, Context.ConnectionAborted);
|
||||||
|
}
|
||||||
|
catch (SocialValidationException exception)
|
||||||
|
{
|
||||||
|
throw new HubException(exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthenticatedSiteUser RequireCurrentUser()
|
||||||
|
=> AuthenticatedSiteUserFactory.FromClaimsPrincipal(Context.User ?? new System.Security.Claims.ClaimsPrincipal())
|
||||||
|
?? throw new HubException("La session utilisateur est incomplete.");
|
||||||
|
|
||||||
|
private async Task SendPresenceSnapshotAsync(string subject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(subject, cancellationToken);
|
||||||
|
var onlineSubjects = relevantSubjects
|
||||||
|
.Where(_tracker.IsOnline)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
await Clients.Caller.SendAsync(
|
||||||
|
"PresenceSnapshot",
|
||||||
|
new PresenceSnapshotMessage { OnlineSubjects = onlineSubjects },
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task NotifyPresenceChangedAsync(IReadOnlyList<string> subjects, string changedSubject, bool isOnline)
|
||||||
|
{
|
||||||
|
var distinctSubjects = subjects
|
||||||
|
.Where(subject => !string.IsNullOrWhiteSpace(subject))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return distinctSubjects.Length == 0
|
||||||
|
? Task.CompletedTask
|
||||||
|
: Clients.Users(distinctSubjects).SendAsync(
|
||||||
|
"PresenceChanged",
|
||||||
|
new PresenceChangedMessage
|
||||||
|
{
|
||||||
|
Subject = changedSubject,
|
||||||
|
IsOnline = isOnline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildSessionGroupName(string sessionId)
|
||||||
|
=> $"play-session:{sessionId}";
|
||||||
|
}
|
||||||
992
ChessCubing.Server/Stats/MySqlPlayerStatsStore.cs
Normal file
992
ChessCubing.Server/Stats/MySqlPlayerStatsStore.cs
Normal file
@@ -0,0 +1,992 @@
|
|||||||
|
using ChessCubing.Server.Data;
|
||||||
|
using ChessCubing.Server.Users;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MySqlConnector;
|
||||||
|
|
||||||
|
namespace ChessCubing.Server.Stats;
|
||||||
|
|
||||||
|
public sealed class MySqlPlayerStatsStore(
|
||||||
|
IOptions<SiteDataOptions> options,
|
||||||
|
ILogger<MySqlPlayerStatsStore> logger)
|
||||||
|
{
|
||||||
|
private const string MatchResultWhite = "white";
|
||||||
|
private const string MatchResultBlack = "black";
|
||||||
|
private const string MatchResultStopped = "stopped";
|
||||||
|
private const int DefaultElo = 1200;
|
||||||
|
private const int EloKFactor = 32;
|
||||||
|
private const int RecentMatchLimit = 12;
|
||||||
|
|
||||||
|
private const string CreatePlayerStatsTableSql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS site_player_stats (
|
||||||
|
subject VARCHAR(190) NOT NULL,
|
||||||
|
current_elo INT NOT NULL,
|
||||||
|
ranked_games INT NOT NULL,
|
||||||
|
casual_games INT NOT NULL,
|
||||||
|
wins INT NOT NULL,
|
||||||
|
losses INT NOT NULL,
|
||||||
|
stopped_games INT NOT NULL,
|
||||||
|
white_wins INT NOT NULL,
|
||||||
|
black_wins INT NOT NULL,
|
||||||
|
white_losses INT NOT NULL,
|
||||||
|
black_losses INT NOT NULL,
|
||||||
|
total_moves INT NOT NULL,
|
||||||
|
total_cube_rounds INT NOT NULL,
|
||||||
|
total_cube_entries INT NOT NULL,
|
||||||
|
total_cube_time_ms BIGINT NOT NULL,
|
||||||
|
best_cube_time_ms BIGINT NULL,
|
||||||
|
last_match_utc DATETIME(6) NULL,
|
||||||
|
updated_utc DATETIME(6) NOT NULL,
|
||||||
|
PRIMARY KEY (subject)
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string CreateMatchResultsTableSql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS site_match_results (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
match_id VARCHAR(80) NOT NULL,
|
||||||
|
collaboration_session_id VARCHAR(80) NULL,
|
||||||
|
recorded_by_subject VARCHAR(190) NOT NULL,
|
||||||
|
white_subject VARCHAR(190) NULL,
|
||||||
|
white_name VARCHAR(120) NOT NULL,
|
||||||
|
black_subject VARCHAR(190) NULL,
|
||||||
|
black_name VARCHAR(120) NOT NULL,
|
||||||
|
winner_subject VARCHAR(190) NULL,
|
||||||
|
result VARCHAR(20) NOT NULL,
|
||||||
|
mode VARCHAR(40) NOT NULL,
|
||||||
|
preset VARCHAR(40) NOT NULL,
|
||||||
|
match_label VARCHAR(120) NULL,
|
||||||
|
block_number INT NOT NULL,
|
||||||
|
white_moves INT NOT NULL,
|
||||||
|
black_moves INT NOT NULL,
|
||||||
|
cube_rounds INT NOT NULL,
|
||||||
|
white_best_cube_ms BIGINT NULL,
|
||||||
|
black_best_cube_ms BIGINT NULL,
|
||||||
|
white_average_cube_ms BIGINT NULL,
|
||||||
|
black_average_cube_ms BIGINT NULL,
|
||||||
|
is_ranked TINYINT(1) NOT NULL,
|
||||||
|
white_elo_before INT NULL,
|
||||||
|
white_elo_after INT NULL,
|
||||||
|
black_elo_before INT NULL,
|
||||||
|
black_elo_after INT NULL,
|
||||||
|
completed_utc DATETIME(6) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_site_match_results_match_id (match_id),
|
||||||
|
KEY idx_site_match_results_white_subject (white_subject, completed_utc),
|
||||||
|
KEY idx_site_match_results_black_subject (black_subject, completed_utc)
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string EnsurePlayerStatsRowSql = """
|
||||||
|
INSERT INTO site_player_stats (
|
||||||
|
subject,
|
||||||
|
current_elo,
|
||||||
|
ranked_games,
|
||||||
|
casual_games,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
stopped_games,
|
||||||
|
white_wins,
|
||||||
|
black_wins,
|
||||||
|
white_losses,
|
||||||
|
black_losses,
|
||||||
|
total_moves,
|
||||||
|
total_cube_rounds,
|
||||||
|
total_cube_entries,
|
||||||
|
total_cube_time_ms,
|
||||||
|
best_cube_time_ms,
|
||||||
|
last_match_utc,
|
||||||
|
updated_utc
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@subject,
|
||||||
|
@currentElo,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
@updatedUtc
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
subject = VALUES(subject);
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectPlayerStatsForUpdateSql = """
|
||||||
|
SELECT
|
||||||
|
subject,
|
||||||
|
current_elo,
|
||||||
|
ranked_games,
|
||||||
|
casual_games,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
stopped_games,
|
||||||
|
white_wins,
|
||||||
|
black_wins,
|
||||||
|
white_losses,
|
||||||
|
black_losses,
|
||||||
|
total_moves,
|
||||||
|
total_cube_rounds,
|
||||||
|
total_cube_entries,
|
||||||
|
total_cube_time_ms,
|
||||||
|
best_cube_time_ms,
|
||||||
|
last_match_utc,
|
||||||
|
updated_utc
|
||||||
|
FROM site_player_stats
|
||||||
|
WHERE subject = @subject
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectPlayerStatsSql = """
|
||||||
|
SELECT
|
||||||
|
subject,
|
||||||
|
current_elo,
|
||||||
|
ranked_games,
|
||||||
|
casual_games,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
stopped_games,
|
||||||
|
white_wins,
|
||||||
|
black_wins,
|
||||||
|
white_losses,
|
||||||
|
black_losses,
|
||||||
|
total_moves,
|
||||||
|
total_cube_rounds,
|
||||||
|
total_cube_entries,
|
||||||
|
total_cube_time_ms,
|
||||||
|
best_cube_time_ms,
|
||||||
|
last_match_utc,
|
||||||
|
updated_utc
|
||||||
|
FROM site_player_stats
|
||||||
|
WHERE subject = @subject
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string UpdatePlayerStatsSql = """
|
||||||
|
UPDATE site_player_stats
|
||||||
|
SET
|
||||||
|
current_elo = @currentElo,
|
||||||
|
ranked_games = @rankedGames,
|
||||||
|
casual_games = @casualGames,
|
||||||
|
wins = @wins,
|
||||||
|
losses = @losses,
|
||||||
|
stopped_games = @stoppedGames,
|
||||||
|
white_wins = @whiteWins,
|
||||||
|
black_wins = @blackWins,
|
||||||
|
white_losses = @whiteLosses,
|
||||||
|
black_losses = @blackLosses,
|
||||||
|
total_moves = @totalMoves,
|
||||||
|
total_cube_rounds = @totalCubeRounds,
|
||||||
|
total_cube_entries = @totalCubeEntries,
|
||||||
|
total_cube_time_ms = @totalCubeTimeMs,
|
||||||
|
best_cube_time_ms = @bestCubeTimeMs,
|
||||||
|
last_match_utc = @lastMatchUtc,
|
||||||
|
updated_utc = @updatedUtc
|
||||||
|
WHERE subject = @subject;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string InsertMatchResultSql = """
|
||||||
|
INSERT INTO site_match_results (
|
||||||
|
match_id,
|
||||||
|
collaboration_session_id,
|
||||||
|
recorded_by_subject,
|
||||||
|
white_subject,
|
||||||
|
white_name,
|
||||||
|
black_subject,
|
||||||
|
black_name,
|
||||||
|
winner_subject,
|
||||||
|
result,
|
||||||
|
mode,
|
||||||
|
preset,
|
||||||
|
match_label,
|
||||||
|
block_number,
|
||||||
|
white_moves,
|
||||||
|
black_moves,
|
||||||
|
cube_rounds,
|
||||||
|
white_best_cube_ms,
|
||||||
|
black_best_cube_ms,
|
||||||
|
white_average_cube_ms,
|
||||||
|
black_average_cube_ms,
|
||||||
|
is_ranked,
|
||||||
|
white_elo_before,
|
||||||
|
white_elo_after,
|
||||||
|
black_elo_before,
|
||||||
|
black_elo_after,
|
||||||
|
completed_utc
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@matchId,
|
||||||
|
@collaborationSessionId,
|
||||||
|
@recordedBySubject,
|
||||||
|
@whiteSubject,
|
||||||
|
@whiteName,
|
||||||
|
@blackSubject,
|
||||||
|
@blackName,
|
||||||
|
@winnerSubject,
|
||||||
|
@result,
|
||||||
|
@mode,
|
||||||
|
@preset,
|
||||||
|
@matchLabel,
|
||||||
|
@blockNumber,
|
||||||
|
@whiteMoves,
|
||||||
|
@blackMoves,
|
||||||
|
@cubeRounds,
|
||||||
|
@whiteBestCubeMs,
|
||||||
|
@blackBestCubeMs,
|
||||||
|
@whiteAverageCubeMs,
|
||||||
|
@blackAverageCubeMs,
|
||||||
|
@isRanked,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
@completedUtc
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string UpdateMatchResultEloSql = """
|
||||||
|
UPDATE site_match_results
|
||||||
|
SET
|
||||||
|
white_elo_before = @whiteEloBefore,
|
||||||
|
white_elo_after = @whiteEloAfter,
|
||||||
|
black_elo_before = @blackEloBefore,
|
||||||
|
black_elo_after = @blackEloAfter
|
||||||
|
WHERE match_id = @matchId;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectRecentMatchesSql = """
|
||||||
|
SELECT
|
||||||
|
match_id,
|
||||||
|
white_subject,
|
||||||
|
white_name,
|
||||||
|
black_subject,
|
||||||
|
black_name,
|
||||||
|
result,
|
||||||
|
mode,
|
||||||
|
preset,
|
||||||
|
match_label,
|
||||||
|
white_moves,
|
||||||
|
black_moves,
|
||||||
|
cube_rounds,
|
||||||
|
white_best_cube_ms,
|
||||||
|
black_best_cube_ms,
|
||||||
|
white_average_cube_ms,
|
||||||
|
black_average_cube_ms,
|
||||||
|
is_ranked,
|
||||||
|
white_elo_before,
|
||||||
|
white_elo_after,
|
||||||
|
black_elo_before,
|
||||||
|
black_elo_after,
|
||||||
|
completed_utc
|
||||||
|
FROM site_match_results
|
||||||
|
WHERE white_subject = @subject OR black_subject = @subject
|
||||||
|
ORDER BY completed_utc DESC, id DESC
|
||||||
|
LIMIT @limit;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private readonly SiteDataOptions _options = options.Value;
|
||||||
|
private readonly ILogger<MySqlPlayerStatsStore> _logger = logger;
|
||||||
|
|
||||||
|
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
await CreateSchemaAsync(connection, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (attempt < _options.InitializationRetries)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Initialisation MySQL impossible pour les statistiques joueurs (tentative {Attempt}/{MaxAttempts}).",
|
||||||
|
attempt,
|
||||||
|
_options.InitializationRetries);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await finalConnection.OpenAsync(cancellationToken);
|
||||||
|
await CreateSchemaAsync(finalConnection, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ReportCompletedMatchResponse> RecordCompletedMatchAsync(
|
||||||
|
AuthenticatedSiteUser reporter,
|
||||||
|
ReportCompletedMatchRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeRequest(reporter, request);
|
||||||
|
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await TryInsertMatchReservationAsync(connection, transaction, normalized, cancellationToken))
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
return new ReportCompletedMatchResponse
|
||||||
|
{
|
||||||
|
Recorded = true,
|
||||||
|
IsDuplicate = true,
|
||||||
|
IsRanked = normalized.IsRanked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerStatsRow? whiteStats = null;
|
||||||
|
PlayerStatsRow? blackStats = null;
|
||||||
|
EloSnapshot? elo = null;
|
||||||
|
|
||||||
|
if (normalized.WhiteSubject is not null)
|
||||||
|
{
|
||||||
|
await EnsurePlayerStatsRowAsync(connection, transaction, normalized.WhiteSubject, normalized.CompletedUtc, cancellationToken);
|
||||||
|
whiteStats = await ReadPlayerStatsForUpdateAsync(connection, transaction, normalized.WhiteSubject, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.BlackSubject is not null)
|
||||||
|
{
|
||||||
|
await EnsurePlayerStatsRowAsync(connection, transaction, normalized.BlackSubject, normalized.CompletedUtc, cancellationToken);
|
||||||
|
blackStats = await ReadPlayerStatsForUpdateAsync(connection, transaction, normalized.BlackSubject, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.IsRanked && whiteStats is not null && blackStats is not null)
|
||||||
|
{
|
||||||
|
elo = ComputeElo(whiteStats.CurrentElo, blackStats.CurrentElo, normalized.Result);
|
||||||
|
whiteStats = whiteStats with { CurrentElo = elo.WhiteAfter };
|
||||||
|
blackStats = blackStats with { CurrentElo = elo.BlackAfter };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (whiteStats is not null)
|
||||||
|
{
|
||||||
|
whiteStats = ApplyMatchToStats(
|
||||||
|
whiteStats,
|
||||||
|
normalized,
|
||||||
|
MatchResultWhite,
|
||||||
|
normalized.WhiteMoves,
|
||||||
|
normalized.WhiteCubeTimes,
|
||||||
|
normalized.IsRanked,
|
||||||
|
elo?.WhiteAfter);
|
||||||
|
|
||||||
|
await UpdatePlayerStatsAsync(connection, transaction, whiteStats, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blackStats is not null)
|
||||||
|
{
|
||||||
|
blackStats = ApplyMatchToStats(
|
||||||
|
blackStats,
|
||||||
|
normalized,
|
||||||
|
MatchResultBlack,
|
||||||
|
normalized.BlackMoves,
|
||||||
|
normalized.BlackCubeTimes,
|
||||||
|
normalized.IsRanked,
|
||||||
|
elo?.BlackAfter);
|
||||||
|
|
||||||
|
await UpdatePlayerStatsAsync(connection, transaction, blackStats, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await UpdateMatchEloAsync(connection, transaction, normalized.MatchId, elo, cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new ReportCompletedMatchResponse
|
||||||
|
{
|
||||||
|
Recorded = true,
|
||||||
|
IsDuplicate = false,
|
||||||
|
IsRanked = normalized.IsRanked,
|
||||||
|
WhiteEloAfter = elo?.WhiteAfter,
|
||||||
|
BlackEloAfter = elo?.BlackAfter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserStatsResponse> GetUserStatsAsync(string subject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedSubject = NormalizeRequiredValue(subject, "subject", 190);
|
||||||
|
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
var stats = await ReadPlayerStatsAsync(connection, normalizedSubject, cancellationToken);
|
||||||
|
var recentMatches = await ReadRecentMatchesAsync(connection, normalizedSubject, cancellationToken);
|
||||||
|
|
||||||
|
if (stats is null)
|
||||||
|
{
|
||||||
|
return new UserStatsResponse
|
||||||
|
{
|
||||||
|
Subject = normalizedSubject,
|
||||||
|
CurrentElo = DefaultElo,
|
||||||
|
RecentMatches = recentMatches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UserStatsResponse
|
||||||
|
{
|
||||||
|
Subject = stats.Subject,
|
||||||
|
CurrentElo = stats.CurrentElo,
|
||||||
|
RankedGames = stats.RankedGames,
|
||||||
|
CasualGames = stats.CasualGames,
|
||||||
|
Wins = stats.Wins,
|
||||||
|
Losses = stats.Losses,
|
||||||
|
StoppedGames = stats.StoppedGames,
|
||||||
|
WhiteWins = stats.WhiteWins,
|
||||||
|
BlackWins = stats.BlackWins,
|
||||||
|
WhiteLosses = stats.WhiteLosses,
|
||||||
|
BlackLosses = stats.BlackLosses,
|
||||||
|
TotalMoves = stats.TotalMoves,
|
||||||
|
TotalCubeRounds = stats.TotalCubeRounds,
|
||||||
|
BestCubeTimeMs = stats.BestCubeTimeMs,
|
||||||
|
AverageCubeTimeMs = stats.TotalCubeEntries <= 0
|
||||||
|
? null
|
||||||
|
: (long?)Math.Round((double)stats.TotalCubeTimeMs / stats.TotalCubeEntries, MidpointRounding.AwayFromZero),
|
||||||
|
LastMatchUtc = stats.LastMatchUtc,
|
||||||
|
RecentMatches = recentMatches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CreateSchemaAsync(MySqlConnection connection, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = CreatePlayerStatsTableSql;
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = CreateMatchResultsTableSql;
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsurePlayerStatsRowAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
MySqlTransaction transaction,
|
||||||
|
string subject,
|
||||||
|
DateTime nowUtc,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = EnsurePlayerStatsRowSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
command.Parameters.AddWithValue("@currentElo", DefaultElo);
|
||||||
|
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> TryInsertMatchReservationAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
MySqlTransaction transaction,
|
||||||
|
NormalizedCompletedMatch normalized,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = InsertMatchResultSql;
|
||||||
|
command.Parameters.AddWithValue("@matchId", normalized.MatchId);
|
||||||
|
command.Parameters.AddWithValue("@collaborationSessionId", (object?)normalized.CollaborationSessionId ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@recordedBySubject", normalized.RecordedBySubject);
|
||||||
|
command.Parameters.AddWithValue("@whiteSubject", (object?)normalized.WhiteSubject ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@whiteName", normalized.WhiteName);
|
||||||
|
command.Parameters.AddWithValue("@blackSubject", (object?)normalized.BlackSubject ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@blackName", normalized.BlackName);
|
||||||
|
command.Parameters.AddWithValue("@winnerSubject", (object?)normalized.WinnerSubject ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@result", normalized.Result);
|
||||||
|
command.Parameters.AddWithValue("@mode", normalized.Mode);
|
||||||
|
command.Parameters.AddWithValue("@preset", normalized.Preset);
|
||||||
|
command.Parameters.AddWithValue("@matchLabel", (object?)normalized.MatchLabel ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@blockNumber", normalized.BlockNumber);
|
||||||
|
command.Parameters.AddWithValue("@whiteMoves", normalized.WhiteMoves);
|
||||||
|
command.Parameters.AddWithValue("@blackMoves", normalized.BlackMoves);
|
||||||
|
command.Parameters.AddWithValue("@cubeRounds", normalized.CubeRounds.Length);
|
||||||
|
command.Parameters.AddWithValue("@whiteBestCubeMs", (object?)normalized.WhiteCubeTimes.BestMs ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@blackBestCubeMs", (object?)normalized.BlackCubeTimes.BestMs ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@whiteAverageCubeMs", (object?)normalized.WhiteCubeTimes.AverageMs ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@blackAverageCubeMs", (object?)normalized.BlackCubeTimes.AverageMs ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@isRanked", normalized.IsRanked);
|
||||||
|
command.Parameters.AddWithValue("@completedUtc", normalized.CompletedUtc);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (MySqlException exception) when (exception.Number == 1062)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PlayerStatsRow?> ReadPlayerStatsAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
string subject,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = SelectPlayerStatsSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
return await reader.ReadAsync(cancellationToken)
|
||||||
|
? MapPlayerStats(reader)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PlayerStatsRow> ReadPlayerStatsForUpdateAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
MySqlTransaction transaction,
|
||||||
|
string subject,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = SelectPlayerStatsForUpdateSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("La ligne de statistiques joueur est introuvable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return MapPlayerStats(reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task UpdatePlayerStatsAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
MySqlTransaction transaction,
|
||||||
|
PlayerStatsRow stats,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = UpdatePlayerStatsSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", stats.Subject);
|
||||||
|
command.Parameters.AddWithValue("@currentElo", stats.CurrentElo);
|
||||||
|
command.Parameters.AddWithValue("@rankedGames", stats.RankedGames);
|
||||||
|
command.Parameters.AddWithValue("@casualGames", stats.CasualGames);
|
||||||
|
command.Parameters.AddWithValue("@wins", stats.Wins);
|
||||||
|
command.Parameters.AddWithValue("@losses", stats.Losses);
|
||||||
|
command.Parameters.AddWithValue("@stoppedGames", stats.StoppedGames);
|
||||||
|
command.Parameters.AddWithValue("@whiteWins", stats.WhiteWins);
|
||||||
|
command.Parameters.AddWithValue("@blackWins", stats.BlackWins);
|
||||||
|
command.Parameters.AddWithValue("@whiteLosses", stats.WhiteLosses);
|
||||||
|
command.Parameters.AddWithValue("@blackLosses", stats.BlackLosses);
|
||||||
|
command.Parameters.AddWithValue("@totalMoves", stats.TotalMoves);
|
||||||
|
command.Parameters.AddWithValue("@totalCubeRounds", stats.TotalCubeRounds);
|
||||||
|
command.Parameters.AddWithValue("@totalCubeEntries", stats.TotalCubeEntries);
|
||||||
|
command.Parameters.AddWithValue("@totalCubeTimeMs", stats.TotalCubeTimeMs);
|
||||||
|
command.Parameters.AddWithValue("@bestCubeTimeMs", (object?)stats.BestCubeTimeMs ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@lastMatchUtc", (object?)stats.LastMatchUtc ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@updatedUtc", stats.UpdatedUtc);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task UpdateMatchEloAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
MySqlTransaction transaction,
|
||||||
|
string matchId,
|
||||||
|
EloSnapshot? elo,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = UpdateMatchResultEloSql;
|
||||||
|
command.Parameters.AddWithValue("@matchId", matchId);
|
||||||
|
command.Parameters.AddWithValue("@whiteEloBefore", (object?)elo?.WhiteBefore ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@whiteEloAfter", (object?)elo?.WhiteAfter ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@blackEloBefore", (object?)elo?.BlackBefore ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@blackEloAfter", (object?)elo?.BlackAfter ?? DBNull.Value);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<UserRecentMatchResponse[]> ReadRecentMatchesAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
string subject,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = SelectRecentMatchesSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
command.Parameters.AddWithValue("@limit", RecentMatchLimit);
|
||||||
|
|
||||||
|
var matches = new List<UserRecentMatchResponse>();
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
matches.Add(MapRecentMatch(reader, subject));
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerStatsRow ApplyMatchToStats(
|
||||||
|
PlayerStatsRow current,
|
||||||
|
NormalizedCompletedMatch normalized,
|
||||||
|
string playerColor,
|
||||||
|
int playerMoves,
|
||||||
|
CubeTimeSummary cubeTimes,
|
||||||
|
bool isRanked,
|
||||||
|
int? eloAfter)
|
||||||
|
{
|
||||||
|
var isWhite = playerColor == MatchResultWhite;
|
||||||
|
var isWin = normalized.Result == playerColor;
|
||||||
|
var isLoss = normalized.Result is MatchResultWhite or MatchResultBlack && normalized.Result != playerColor;
|
||||||
|
var isStopped = normalized.Result == MatchResultStopped;
|
||||||
|
|
||||||
|
return current with
|
||||||
|
{
|
||||||
|
RankedGames = current.RankedGames + (isRanked ? 1 : 0),
|
||||||
|
CasualGames = current.CasualGames + (isRanked ? 0 : 1),
|
||||||
|
Wins = current.Wins + (isWin ? 1 : 0),
|
||||||
|
Losses = current.Losses + (isLoss ? 1 : 0),
|
||||||
|
StoppedGames = current.StoppedGames + (isStopped ? 1 : 0),
|
||||||
|
WhiteWins = current.WhiteWins + (isWhite && isWin ? 1 : 0),
|
||||||
|
BlackWins = current.BlackWins + (!isWhite && isWin ? 1 : 0),
|
||||||
|
WhiteLosses = current.WhiteLosses + (isWhite && isLoss ? 1 : 0),
|
||||||
|
BlackLosses = current.BlackLosses + (!isWhite && isLoss ? 1 : 0),
|
||||||
|
TotalMoves = current.TotalMoves + playerMoves,
|
||||||
|
TotalCubeRounds = current.TotalCubeRounds + normalized.CubeRounds.Length,
|
||||||
|
TotalCubeEntries = current.TotalCubeEntries + cubeTimes.Count,
|
||||||
|
TotalCubeTimeMs = current.TotalCubeTimeMs + cubeTimes.TotalMs,
|
||||||
|
BestCubeTimeMs = MinNullable(current.BestCubeTimeMs, cubeTimes.BestMs),
|
||||||
|
LastMatchUtc = normalized.CompletedUtc,
|
||||||
|
UpdatedUtc = normalized.CompletedUtc,
|
||||||
|
CurrentElo = eloAfter ?? current.CurrentElo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EloSnapshot ComputeElo(int whiteRating, int blackRating, string result)
|
||||||
|
{
|
||||||
|
var whiteScore = result == MatchResultWhite ? 1d : 0d;
|
||||||
|
var expectedWhite = 1d / (1d + Math.Pow(10d, (blackRating - whiteRating) / 400d));
|
||||||
|
var whiteDelta = (int)Math.Round(EloKFactor * (whiteScore - expectedWhite), MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
return new EloSnapshot(
|
||||||
|
whiteRating,
|
||||||
|
whiteRating + whiteDelta,
|
||||||
|
blackRating,
|
||||||
|
blackRating - whiteDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserRecentMatchResponse MapRecentMatch(MySqlDataReader reader, string subject)
|
||||||
|
{
|
||||||
|
var whiteSubject = ReadNullableString(reader, "white_subject");
|
||||||
|
var blackSubject = ReadNullableString(reader, "black_subject");
|
||||||
|
var playerColor = string.Equals(whiteSubject, subject, StringComparison.Ordinal) ? MatchResultWhite : MatchResultBlack;
|
||||||
|
var isWhite = playerColor == MatchResultWhite;
|
||||||
|
var result = ReadString(reader, "result");
|
||||||
|
var isWin = result == playerColor;
|
||||||
|
var isLoss = result is MatchResultWhite or MatchResultBlack && result != playerColor;
|
||||||
|
var eloBefore = isWhite ? ReadNullableInt(reader, "white_elo_before") : ReadNullableInt(reader, "black_elo_before");
|
||||||
|
var eloAfter = isWhite ? ReadNullableInt(reader, "white_elo_after") : ReadNullableInt(reader, "black_elo_after");
|
||||||
|
|
||||||
|
return new UserRecentMatchResponse
|
||||||
|
{
|
||||||
|
MatchId = ReadString(reader, "match_id"),
|
||||||
|
CompletedUtc = ReadDateTime(reader, "completed_utc"),
|
||||||
|
Result = result,
|
||||||
|
Mode = ReadString(reader, "mode"),
|
||||||
|
Preset = ReadString(reader, "preset"),
|
||||||
|
MatchLabel = ReadNullableString(reader, "match_label"),
|
||||||
|
PlayerColor = playerColor,
|
||||||
|
PlayerName = isWhite ? ReadString(reader, "white_name") : ReadString(reader, "black_name"),
|
||||||
|
OpponentName = isWhite ? ReadString(reader, "black_name") : ReadString(reader, "white_name"),
|
||||||
|
OpponentSubject = isWhite ? blackSubject : whiteSubject,
|
||||||
|
IsRanked = ReadBoolean(reader, "is_ranked"),
|
||||||
|
IsWin = isWin,
|
||||||
|
IsLoss = isLoss,
|
||||||
|
PlayerMoves = isWhite ? ReadInt(reader, "white_moves") : ReadInt(reader, "black_moves"),
|
||||||
|
OpponentMoves = isWhite ? ReadInt(reader, "black_moves") : ReadInt(reader, "white_moves"),
|
||||||
|
CubeRounds = ReadInt(reader, "cube_rounds"),
|
||||||
|
PlayerBestCubeTimeMs = isWhite ? ReadNullableLong(reader, "white_best_cube_ms") : ReadNullableLong(reader, "black_best_cube_ms"),
|
||||||
|
PlayerAverageCubeTimeMs = isWhite ? ReadNullableLong(reader, "white_average_cube_ms") : ReadNullableLong(reader, "black_average_cube_ms"),
|
||||||
|
EloBefore = eloBefore,
|
||||||
|
EloAfter = eloAfter,
|
||||||
|
EloDelta = eloBefore is not null && eloAfter is not null ? eloAfter.Value - eloBefore.Value : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerStatsRow MapPlayerStats(MySqlDataReader reader)
|
||||||
|
=> new(
|
||||||
|
ReadString(reader, "subject"),
|
||||||
|
ReadInt(reader, "current_elo"),
|
||||||
|
ReadInt(reader, "ranked_games"),
|
||||||
|
ReadInt(reader, "casual_games"),
|
||||||
|
ReadInt(reader, "wins"),
|
||||||
|
ReadInt(reader, "losses"),
|
||||||
|
ReadInt(reader, "stopped_games"),
|
||||||
|
ReadInt(reader, "white_wins"),
|
||||||
|
ReadInt(reader, "black_wins"),
|
||||||
|
ReadInt(reader, "white_losses"),
|
||||||
|
ReadInt(reader, "black_losses"),
|
||||||
|
ReadInt(reader, "total_moves"),
|
||||||
|
ReadInt(reader, "total_cube_rounds"),
|
||||||
|
ReadInt(reader, "total_cube_entries"),
|
||||||
|
ReadLong(reader, "total_cube_time_ms"),
|
||||||
|
ReadNullableLong(reader, "best_cube_time_ms"),
|
||||||
|
ReadNullableDateTime(reader, "last_match_utc"),
|
||||||
|
ReadDateTime(reader, "updated_utc"));
|
||||||
|
|
||||||
|
private static NormalizedCompletedMatch NormalizeRequest(AuthenticatedSiteUser reporter, ReportCompletedMatchRequest request)
|
||||||
|
{
|
||||||
|
var matchId = NormalizeRequiredValue(request.MatchId, "identifiant de match", 80);
|
||||||
|
var collaborationSessionId = NormalizeOptionalValue(request.CollaborationSessionId, "session collaborative", 80);
|
||||||
|
var whiteSubject = NormalizeOptionalValue(request.WhiteSubject, "subject blanc", 190);
|
||||||
|
var blackSubject = NormalizeOptionalValue(request.BlackSubject, "subject noir", 190);
|
||||||
|
var whiteName = NormalizeRequiredValue(request.WhiteName, "joueur blanc", 120);
|
||||||
|
var blackName = NormalizeRequiredValue(request.BlackName, "joueur noir", 120);
|
||||||
|
var mode = NormalizeRequiredValue(request.Mode, "mode", 40);
|
||||||
|
var preset = NormalizeRequiredValue(request.Preset, "preset", 40);
|
||||||
|
var matchLabel = NormalizeOptionalValue(request.MatchLabel, "nom de rencontre", 120);
|
||||||
|
var result = NormalizeResult(request.Result);
|
||||||
|
var blockNumber = Math.Clamp(request.BlockNumber, 1, 999);
|
||||||
|
var whiteMoves = Math.Clamp(request.WhiteMoves, 0, 9999);
|
||||||
|
var blackMoves = Math.Clamp(request.BlackMoves, 0, 9999);
|
||||||
|
|
||||||
|
if (whiteSubject is null && blackSubject is null)
|
||||||
|
{
|
||||||
|
throw new PlayerStatsValidationException("Impossible d'enregistrer une partie sans joueur identifie.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (whiteSubject is not null &&
|
||||||
|
blackSubject is not null &&
|
||||||
|
string.Equals(whiteSubject, blackSubject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new PlayerStatsValidationException("Les deux cotes ne peuvent pas pointer vers le meme compte.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(reporter.Subject, whiteSubject, StringComparison.Ordinal) &&
|
||||||
|
!string.Equals(reporter.Subject, blackSubject, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new PlayerStatsValidationException("Le compte connecte doit correspondre a l'un des deux joueurs pour enregistrer la partie.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var cubeRounds = (request.CubeRounds ?? [])
|
||||||
|
.Take(64)
|
||||||
|
.Select(round => new NormalizedCubeRound(
|
||||||
|
Math.Clamp(round.BlockNumber, 1, 999),
|
||||||
|
round.Number is null ? null : Math.Clamp(round.Number.Value, 1, 999),
|
||||||
|
NormalizeCubeDuration(round.White),
|
||||||
|
NormalizeCubeDuration(round.Black)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var whiteCubeTimes = SummarizeCubeTimes(cubeRounds.Select(round => round.White));
|
||||||
|
var blackCubeTimes = SummarizeCubeTimes(cubeRounds.Select(round => round.Black));
|
||||||
|
var isRanked = whiteSubject is not null &&
|
||||||
|
blackSubject is not null &&
|
||||||
|
result is MatchResultWhite or MatchResultBlack;
|
||||||
|
|
||||||
|
return new NormalizedCompletedMatch(
|
||||||
|
matchId,
|
||||||
|
collaborationSessionId,
|
||||||
|
reporter.Subject,
|
||||||
|
whiteSubject,
|
||||||
|
whiteName,
|
||||||
|
blackSubject,
|
||||||
|
blackName,
|
||||||
|
result,
|
||||||
|
mode,
|
||||||
|
preset,
|
||||||
|
matchLabel,
|
||||||
|
blockNumber,
|
||||||
|
whiteMoves,
|
||||||
|
blackMoves,
|
||||||
|
cubeRounds,
|
||||||
|
whiteCubeTimes,
|
||||||
|
blackCubeTimes,
|
||||||
|
isRanked,
|
||||||
|
result == MatchResultWhite
|
||||||
|
? whiteSubject
|
||||||
|
: result == MatchResultBlack
|
||||||
|
? blackSubject
|
||||||
|
: null,
|
||||||
|
DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CubeTimeSummary SummarizeCubeTimes(IEnumerable<long?> values)
|
||||||
|
{
|
||||||
|
var normalized = values
|
||||||
|
.Where(value => value is > 0)
|
||||||
|
.Select(value => value!.Value)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (normalized.Length == 0)
|
||||||
|
{
|
||||||
|
return new CubeTimeSummary(0, 0, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CubeTimeSummary(
|
||||||
|
normalized.Length,
|
||||||
|
normalized.Sum(),
|
||||||
|
normalized.Min(),
|
||||||
|
(long)Math.Round(normalized.Average(), MidpointRounding.AwayFromZero));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long? NormalizeCubeDuration(long? value)
|
||||||
|
=> value is > 0
|
||||||
|
? Math.Clamp(value.Value, 1, 3_600_000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string NormalizeResult(string? value)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeRequiredValue(value, "resultat", 20).ToLowerInvariant();
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
MatchResultWhite => MatchResultWhite,
|
||||||
|
MatchResultBlack => MatchResultBlack,
|
||||||
|
MatchResultStopped => MatchResultStopped,
|
||||||
|
_ => throw new PlayerStatsValidationException("Le resultat doit etre white, black ou stopped."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRequiredValue(string? value, string fieldName, int maxLength)
|
||||||
|
=> NormalizeOptionalValue(value, fieldName, maxLength)
|
||||||
|
?? throw new PlayerStatsValidationException($"Le champ {fieldName} est obligatoire.");
|
||||||
|
|
||||||
|
private static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
|
||||||
|
{
|
||||||
|
var trimmed = value?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.Length > maxLength)
|
||||||
|
{
|
||||||
|
throw new PlayerStatsValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long? MinNullable(long? current, long? candidate)
|
||||||
|
{
|
||||||
|
if (candidate is null)
|
||||||
|
{
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current is null
|
||||||
|
? candidate
|
||||||
|
: Math.Min(current.Value, candidate.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadString(MySqlDataReader reader, string column)
|
||||||
|
=> reader.GetString(reader.GetOrdinal(column));
|
||||||
|
|
||||||
|
private static string? ReadNullableString(MySqlDataReader reader, string column)
|
||||||
|
{
|
||||||
|
var ordinal = reader.GetOrdinal(column);
|
||||||
|
return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadInt(MySqlDataReader reader, string column)
|
||||||
|
=> reader.GetInt32(reader.GetOrdinal(column));
|
||||||
|
|
||||||
|
private static int? ReadNullableInt(MySqlDataReader reader, string column)
|
||||||
|
{
|
||||||
|
var ordinal = reader.GetOrdinal(column);
|
||||||
|
return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ReadLong(MySqlDataReader reader, string column)
|
||||||
|
=> reader.GetInt64(reader.GetOrdinal(column));
|
||||||
|
|
||||||
|
private static long? ReadNullableLong(MySqlDataReader reader, string column)
|
||||||
|
{
|
||||||
|
var ordinal = reader.GetOrdinal(column);
|
||||||
|
return reader.IsDBNull(ordinal) ? null : reader.GetInt64(ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ReadBoolean(MySqlDataReader reader, string column)
|
||||||
|
=> reader.GetBoolean(reader.GetOrdinal(column));
|
||||||
|
|
||||||
|
private static DateTime ReadDateTime(MySqlDataReader reader, string column)
|
||||||
|
=> reader.GetDateTime(reader.GetOrdinal(column));
|
||||||
|
|
||||||
|
private static DateTime? ReadNullableDateTime(MySqlDataReader reader, string column)
|
||||||
|
{
|
||||||
|
var ordinal = reader.GetOrdinal(column);
|
||||||
|
return reader.IsDBNull(ordinal) ? null : reader.GetDateTime(ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PlayerStatsRow(
|
||||||
|
string Subject,
|
||||||
|
int CurrentElo,
|
||||||
|
int RankedGames,
|
||||||
|
int CasualGames,
|
||||||
|
int Wins,
|
||||||
|
int Losses,
|
||||||
|
int StoppedGames,
|
||||||
|
int WhiteWins,
|
||||||
|
int BlackWins,
|
||||||
|
int WhiteLosses,
|
||||||
|
int BlackLosses,
|
||||||
|
int TotalMoves,
|
||||||
|
int TotalCubeRounds,
|
||||||
|
int TotalCubeEntries,
|
||||||
|
long TotalCubeTimeMs,
|
||||||
|
long? BestCubeTimeMs,
|
||||||
|
DateTime? LastMatchUtc,
|
||||||
|
DateTime UpdatedUtc);
|
||||||
|
|
||||||
|
private sealed record EloSnapshot(
|
||||||
|
int WhiteBefore,
|
||||||
|
int WhiteAfter,
|
||||||
|
int BlackBefore,
|
||||||
|
int BlackAfter);
|
||||||
|
|
||||||
|
private sealed record NormalizedCubeRound(
|
||||||
|
int BlockNumber,
|
||||||
|
int? Number,
|
||||||
|
long? White,
|
||||||
|
long? Black);
|
||||||
|
|
||||||
|
private sealed record CubeTimeSummary(
|
||||||
|
int Count,
|
||||||
|
long TotalMs,
|
||||||
|
long? BestMs,
|
||||||
|
long? AverageMs);
|
||||||
|
|
||||||
|
private sealed record NormalizedCompletedMatch(
|
||||||
|
string MatchId,
|
||||||
|
string? CollaborationSessionId,
|
||||||
|
string RecordedBySubject,
|
||||||
|
string? WhiteSubject,
|
||||||
|
string WhiteName,
|
||||||
|
string? BlackSubject,
|
||||||
|
string BlackName,
|
||||||
|
string Result,
|
||||||
|
string Mode,
|
||||||
|
string Preset,
|
||||||
|
string? MatchLabel,
|
||||||
|
int BlockNumber,
|
||||||
|
int WhiteMoves,
|
||||||
|
int BlackMoves,
|
||||||
|
NormalizedCubeRound[] CubeRounds,
|
||||||
|
CubeTimeSummary WhiteCubeTimes,
|
||||||
|
CubeTimeSummary BlackCubeTimes,
|
||||||
|
bool IsRanked,
|
||||||
|
string? WinnerSubject,
|
||||||
|
DateTime CompletedUtc);
|
||||||
|
}
|
||||||
140
ChessCubing.Server/Stats/PlayerStatsContracts.cs
Normal file
140
ChessCubing.Server/Stats/PlayerStatsContracts.cs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
namespace ChessCubing.Server.Stats;
|
||||||
|
|
||||||
|
public sealed class ReportCompletedMatchRequest
|
||||||
|
{
|
||||||
|
public string MatchId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? CollaborationSessionId { get; init; }
|
||||||
|
|
||||||
|
public string? WhiteSubject { get; init; }
|
||||||
|
|
||||||
|
public string WhiteName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? BlackSubject { get; init; }
|
||||||
|
|
||||||
|
public string BlackName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Result { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Mode { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Preset { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? MatchLabel { get; init; }
|
||||||
|
|
||||||
|
public int BlockNumber { get; init; }
|
||||||
|
|
||||||
|
public int WhiteMoves { get; init; }
|
||||||
|
|
||||||
|
public int BlackMoves { get; init; }
|
||||||
|
|
||||||
|
public ReportCompletedCubeRound[] CubeRounds { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReportCompletedCubeRound
|
||||||
|
{
|
||||||
|
public int BlockNumber { get; init; }
|
||||||
|
|
||||||
|
public int? Number { get; init; }
|
||||||
|
|
||||||
|
public long? White { get; init; }
|
||||||
|
|
||||||
|
public long? Black { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReportCompletedMatchResponse
|
||||||
|
{
|
||||||
|
public bool Recorded { get; init; }
|
||||||
|
|
||||||
|
public bool IsDuplicate { get; init; }
|
||||||
|
|
||||||
|
public bool IsRanked { get; init; }
|
||||||
|
|
||||||
|
public int? WhiteEloAfter { get; init; }
|
||||||
|
|
||||||
|
public int? BlackEloAfter { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserStatsResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public int CurrentElo { get; init; }
|
||||||
|
|
||||||
|
public int RankedGames { get; init; }
|
||||||
|
|
||||||
|
public int CasualGames { get; init; }
|
||||||
|
|
||||||
|
public int Wins { get; init; }
|
||||||
|
|
||||||
|
public int Losses { get; init; }
|
||||||
|
|
||||||
|
public int StoppedGames { get; init; }
|
||||||
|
|
||||||
|
public int WhiteWins { get; init; }
|
||||||
|
|
||||||
|
public int BlackWins { get; init; }
|
||||||
|
|
||||||
|
public int WhiteLosses { get; init; }
|
||||||
|
|
||||||
|
public int BlackLosses { get; init; }
|
||||||
|
|
||||||
|
public int TotalMoves { get; init; }
|
||||||
|
|
||||||
|
public int TotalCubeRounds { get; init; }
|
||||||
|
|
||||||
|
public long? BestCubeTimeMs { get; init; }
|
||||||
|
|
||||||
|
public long? AverageCubeTimeMs { get; init; }
|
||||||
|
|
||||||
|
public DateTime? LastMatchUtc { get; init; }
|
||||||
|
|
||||||
|
public UserRecentMatchResponse[] RecentMatches { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserRecentMatchResponse
|
||||||
|
{
|
||||||
|
public string MatchId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime CompletedUtc { get; init; }
|
||||||
|
|
||||||
|
public string Result { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Mode { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Preset { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? MatchLabel { get; init; }
|
||||||
|
|
||||||
|
public string PlayerColor { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string PlayerName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string OpponentName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? OpponentSubject { get; init; }
|
||||||
|
|
||||||
|
public bool IsRanked { get; init; }
|
||||||
|
|
||||||
|
public bool IsWin { get; init; }
|
||||||
|
|
||||||
|
public bool IsLoss { get; init; }
|
||||||
|
|
||||||
|
public int PlayerMoves { get; init; }
|
||||||
|
|
||||||
|
public int OpponentMoves { get; init; }
|
||||||
|
|
||||||
|
public int CubeRounds { get; init; }
|
||||||
|
|
||||||
|
public long? PlayerBestCubeTimeMs { get; init; }
|
||||||
|
|
||||||
|
public long? PlayerAverageCubeTimeMs { get; init; }
|
||||||
|
|
||||||
|
public int? EloBefore { get; init; }
|
||||||
|
|
||||||
|
public int? EloAfter { get; init; }
|
||||||
|
|
||||||
|
public int? EloDelta { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlayerStatsValidationException(string message) : Exception(message);
|
||||||
63
ChessCubing.Server/Users/AuthenticatedSiteUser.cs
Normal file
63
ChessCubing.Server/Users/AuthenticatedSiteUser.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using ChessCubing.Server.Auth;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace ChessCubing.Server.Users;
|
||||||
|
|
||||||
|
public sealed record AuthenticatedSiteUser(
|
||||||
|
string Subject,
|
||||||
|
string Username,
|
||||||
|
string? Email,
|
||||||
|
string DisplayName);
|
||||||
|
|
||||||
|
public static class AuthenticatedSiteUserFactory
|
||||||
|
{
|
||||||
|
public static AuthenticatedSiteUser? FromKeycloakUserInfo(KeycloakUserInfo userInfo)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(userInfo.Subject))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var username = string.IsNullOrWhiteSpace(userInfo.PreferredUsername)
|
||||||
|
? userInfo.Subject
|
||||||
|
: userInfo.PreferredUsername;
|
||||||
|
|
||||||
|
var displayName = string.IsNullOrWhiteSpace(userInfo.Name)
|
||||||
|
? username
|
||||||
|
: userInfo.Name;
|
||||||
|
|
||||||
|
return new AuthenticatedSiteUser(
|
||||||
|
userInfo.Subject.Trim(),
|
||||||
|
username.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(userInfo.Email) ? null : userInfo.Email.Trim(),
|
||||||
|
displayName.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AuthenticatedSiteUser? FromClaimsPrincipal(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
if (user.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subject = user.FindFirst("sub")?.Value
|
||||||
|
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(subject))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var username = user.FindFirst("preferred_username")?.Value
|
||||||
|
?? user.Identity?.Name
|
||||||
|
?? subject;
|
||||||
|
var email = user.FindFirst("email")?.Value;
|
||||||
|
var displayName = user.FindFirst("name")?.Value
|
||||||
|
?? username;
|
||||||
|
|
||||||
|
return new AuthenticatedSiteUser(
|
||||||
|
subject.Trim(),
|
||||||
|
username.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||||
|
displayName.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
407
ChessCubing.Server/Users/MySqlUserProfileStore.cs
Normal file
407
ChessCubing.Server/Users/MySqlUserProfileStore.cs
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
using ChessCubing.Server.Data;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MySqlConnector;
|
||||||
|
|
||||||
|
namespace ChessCubing.Server.Users;
|
||||||
|
|
||||||
|
public sealed class MySqlUserProfileStore(
|
||||||
|
IOptions<SiteDataOptions> options,
|
||||||
|
ILogger<MySqlUserProfileStore> logger)
|
||||||
|
{
|
||||||
|
private const string CreateTableSql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS site_users (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
subject VARCHAR(190) NOT NULL,
|
||||||
|
username VARCHAR(120) NOT NULL,
|
||||||
|
email VARCHAR(255) NULL,
|
||||||
|
display_name VARCHAR(120) NOT NULL,
|
||||||
|
club VARCHAR(120) NULL,
|
||||||
|
city VARCHAR(120) NULL,
|
||||||
|
preferred_format VARCHAR(40) NULL,
|
||||||
|
favorite_cube VARCHAR(120) NULL,
|
||||||
|
bio TEXT NULL,
|
||||||
|
created_utc DATETIME(6) NOT NULL,
|
||||||
|
updated_utc DATETIME(6) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_site_users_subject (subject)
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string UpsertIdentitySql = """
|
||||||
|
INSERT INTO site_users (
|
||||||
|
subject,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
created_utc,
|
||||||
|
updated_utc
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@subject,
|
||||||
|
@username,
|
||||||
|
@email,
|
||||||
|
@displayName,
|
||||||
|
@createdUtc,
|
||||||
|
@updatedUtc
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
username = VALUES(username),
|
||||||
|
email = VALUES(email);
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string UpsertProfileSql = """
|
||||||
|
INSERT INTO site_users (
|
||||||
|
subject,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
club,
|
||||||
|
city,
|
||||||
|
preferred_format,
|
||||||
|
favorite_cube,
|
||||||
|
bio,
|
||||||
|
created_utc,
|
||||||
|
updated_utc
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@subject,
|
||||||
|
@username,
|
||||||
|
@email,
|
||||||
|
@displayName,
|
||||||
|
@club,
|
||||||
|
@city,
|
||||||
|
@preferredFormat,
|
||||||
|
@favoriteCube,
|
||||||
|
@bio,
|
||||||
|
@createdUtc,
|
||||||
|
@updatedUtc
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
username = VALUES(username),
|
||||||
|
email = VALUES(email),
|
||||||
|
display_name = VALUES(display_name),
|
||||||
|
club = VALUES(club),
|
||||||
|
city = VALUES(city),
|
||||||
|
preferred_format = VALUES(preferred_format),
|
||||||
|
favorite_cube = VALUES(favorite_cube),
|
||||||
|
bio = VALUES(bio),
|
||||||
|
updated_utc = VALUES(updated_utc);
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectProfileSql = """
|
||||||
|
SELECT
|
||||||
|
subject,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
club,
|
||||||
|
city,
|
||||||
|
preferred_format,
|
||||||
|
favorite_cube,
|
||||||
|
bio,
|
||||||
|
created_utc,
|
||||||
|
updated_utc
|
||||||
|
FROM site_users
|
||||||
|
WHERE subject = @subject
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectAllProfilesSql = """
|
||||||
|
SELECT
|
||||||
|
subject,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
club,
|
||||||
|
city,
|
||||||
|
preferred_format,
|
||||||
|
favorite_cube,
|
||||||
|
bio,
|
||||||
|
created_utc,
|
||||||
|
updated_utc
|
||||||
|
FROM site_users
|
||||||
|
ORDER BY updated_utc DESC, created_utc DESC, username ASC;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string DeleteProfileSql = """
|
||||||
|
DELETE FROM site_users
|
||||||
|
WHERE subject = @subject;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private readonly SiteDataOptions _options = options.Value;
|
||||||
|
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
|
||||||
|
|
||||||
|
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = CreateTableSql;
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (attempt < _options.InitializationRetries)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Initialisation MySQL impossible pour le profil utilisateur (tentative {Attempt}/{MaxAttempts}).",
|
||||||
|
attempt,
|
||||||
|
_options.InitializationRetries);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await finalConnection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using var finalCommand = finalConnection.CreateCommand();
|
||||||
|
finalCommand.CommandText = CreateTableSql;
|
||||||
|
await finalCommand.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileResponse> GetOrCreateAsync(
|
||||||
|
AuthenticatedSiteUser user,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = UpsertIdentitySql;
|
||||||
|
command.Parameters.AddWithValue("@subject", user.Subject);
|
||||||
|
command.Parameters.AddWithValue("@username", user.Username);
|
||||||
|
command.Parameters.AddWithValue("@email", (object?)user.Email ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@displayName", user.DisplayName);
|
||||||
|
command.Parameters.AddWithValue("@createdUtc", nowUtc);
|
||||||
|
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileResponse> UpdateAsync(
|
||||||
|
AuthenticatedSiteUser user,
|
||||||
|
UpdateUserProfileRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var input = NormalizeInput(user, request);
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = UpsertProfileSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", user.Subject);
|
||||||
|
command.Parameters.AddWithValue("@username", user.Username);
|
||||||
|
command.Parameters.AddWithValue("@email", (object?)user.Email ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@displayName", input.DisplayName);
|
||||||
|
command.Parameters.AddWithValue("@club", (object?)input.Club ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@city", (object?)input.City ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@preferredFormat", (object?)input.PreferredFormat ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@favoriteCube", (object?)input.FavoriteCube ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@bio", (object?)input.Bio ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@createdUtc", nowUtc);
|
||||||
|
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ValidateAdminUpdate(string fallbackDisplayName, UpdateUserProfileRequest request)
|
||||||
|
=> _ = NormalizeInput(fallbackDisplayName, request);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<UserProfileResponse>> ListAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = SelectAllProfilesSql;
|
||||||
|
|
||||||
|
var profiles = new List<UserProfileResponse>();
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
profiles.Add(MapProfile(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileResponse?> FindBySubjectAsync(string subject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = SelectProfileSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
return await reader.ReadAsync(cancellationToken)
|
||||||
|
? MapProfile(reader)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileResponse> AdminUpsertAsync(
|
||||||
|
string subject,
|
||||||
|
string username,
|
||||||
|
string? email,
|
||||||
|
string fallbackDisplayName,
|
||||||
|
UpdateUserProfileRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var input = NormalizeInput(fallbackDisplayName, request);
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = UpsertProfileSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
command.Parameters.AddWithValue("@username", username);
|
||||||
|
command.Parameters.AddWithValue("@email", (object?)email ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@displayName", input.DisplayName);
|
||||||
|
command.Parameters.AddWithValue("@club", (object?)input.Club ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@city", (object?)input.City ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@preferredFormat", (object?)input.PreferredFormat ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@favoriteCube", (object?)input.FavoriteCube ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@bio", (object?)input.Bio ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@createdUtc", nowUtc);
|
||||||
|
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ReadProfileAsync(connection, subject, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(string subject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = DeleteProfileSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
|
||||||
|
=> NormalizeInput(user.DisplayName, request);
|
||||||
|
|
||||||
|
private static UserProfileInput NormalizeInput(string fallbackDisplayName, UpdateUserProfileRequest request)
|
||||||
|
{
|
||||||
|
var displayName = NormalizeOptionalValue(request.DisplayName, "nom affiche", 120) ?? fallbackDisplayName;
|
||||||
|
var club = NormalizeOptionalValue(request.Club, "club", 120);
|
||||||
|
var city = NormalizeOptionalValue(request.City, "ville", 120);
|
||||||
|
var favoriteCube = NormalizeOptionalValue(request.FavoriteCube, "cube favori", 120);
|
||||||
|
var bio = NormalizeOptionalValue(request.Bio, "bio", 1200);
|
||||||
|
var preferredFormat = NormalizePreferredFormat(request.PreferredFormat);
|
||||||
|
|
||||||
|
return new UserProfileInput(displayName, club, city, preferredFormat, favoriteCube, bio);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
|
||||||
|
{
|
||||||
|
var trimmed = value?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.Length > maxLength)
|
||||||
|
{
|
||||||
|
throw new UserProfileValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizePreferredFormat(string? value)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeOptionalValue(value, "format prefere", 40);
|
||||||
|
if (normalized is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"Twice" => "Twice",
|
||||||
|
"Time" => "Time",
|
||||||
|
"Les deux" => "Les deux",
|
||||||
|
_ => throw new UserProfileValidationException("Le format prefere doit etre Twice, Time ou Les deux."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<UserProfileResponse> ReadProfileAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
string subject,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = SelectProfileSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Le profil utilisateur n'a pas pu etre charge.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return MapProfile(reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserProfileResponse MapProfile(MySqlDataReader reader)
|
||||||
|
{
|
||||||
|
var subjectOrdinal = reader.GetOrdinal("subject");
|
||||||
|
var usernameOrdinal = reader.GetOrdinal("username");
|
||||||
|
var emailOrdinal = reader.GetOrdinal("email");
|
||||||
|
var displayNameOrdinal = reader.GetOrdinal("display_name");
|
||||||
|
var clubOrdinal = reader.GetOrdinal("club");
|
||||||
|
var cityOrdinal = reader.GetOrdinal("city");
|
||||||
|
var preferredFormatOrdinal = reader.GetOrdinal("preferred_format");
|
||||||
|
var favoriteCubeOrdinal = reader.GetOrdinal("favorite_cube");
|
||||||
|
var bioOrdinal = reader.GetOrdinal("bio");
|
||||||
|
var createdUtcOrdinal = reader.GetOrdinal("created_utc");
|
||||||
|
var updatedUtcOrdinal = reader.GetOrdinal("updated_utc");
|
||||||
|
|
||||||
|
return new UserProfileResponse
|
||||||
|
{
|
||||||
|
Subject = reader.GetString(subjectOrdinal),
|
||||||
|
Username = reader.GetString(usernameOrdinal),
|
||||||
|
Email = reader.IsDBNull(emailOrdinal) ? null : reader.GetString(emailOrdinal),
|
||||||
|
DisplayName = reader.GetString(displayNameOrdinal),
|
||||||
|
Club = reader.IsDBNull(clubOrdinal) ? null : reader.GetString(clubOrdinal),
|
||||||
|
City = reader.IsDBNull(cityOrdinal) ? null : reader.GetString(cityOrdinal),
|
||||||
|
PreferredFormat = reader.IsDBNull(preferredFormatOrdinal) ? null : reader.GetString(preferredFormatOrdinal),
|
||||||
|
FavoriteCube = reader.IsDBNull(favoriteCubeOrdinal) ? null : reader.GetString(favoriteCubeOrdinal),
|
||||||
|
Bio = reader.IsDBNull(bioOrdinal) ? null : reader.GetString(bioOrdinal),
|
||||||
|
CreatedUtc = DateTime.SpecifyKind(reader.GetDateTime(createdUtcOrdinal), DateTimeKind.Utc),
|
||||||
|
UpdatedUtc = DateTime.SpecifyKind(reader.GetDateTime(updatedUtcOrdinal), DateTimeKind.Utc),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record UserProfileInput(
|
||||||
|
string DisplayName,
|
||||||
|
string? Club,
|
||||||
|
string? City,
|
||||||
|
string? PreferredFormat,
|
||||||
|
string? FavoriteCube,
|
||||||
|
string? Bio);
|
||||||
|
}
|
||||||
43
ChessCubing.Server/Users/UserProfileContracts.cs
Normal file
43
ChessCubing.Server/Users/UserProfileContracts.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
namespace ChessCubing.Server.Users;
|
||||||
|
|
||||||
|
public sealed class UserProfileResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Club { get; init; }
|
||||||
|
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; init; }
|
||||||
|
|
||||||
|
public string? FavoriteCube { get; init; }
|
||||||
|
|
||||||
|
public string? Bio { get; init; }
|
||||||
|
|
||||||
|
public DateTime CreatedUtc { get; init; }
|
||||||
|
|
||||||
|
public DateTime UpdatedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateUserProfileRequest
|
||||||
|
{
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
|
||||||
|
public string? Club { get; init; }
|
||||||
|
|
||||||
|
public string? City { get; init; }
|
||||||
|
|
||||||
|
public string? PreferredFormat { get; init; }
|
||||||
|
|
||||||
|
public string? FavoriteCube { get; init; }
|
||||||
|
|
||||||
|
public string? Bio { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserProfileValidationException(string message) : Exception(message);
|
||||||
14
Dockerfile
14
Dockerfile
@@ -1,4 +1,16 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY ChessCubing.App/ChessCubing.App.csproj ChessCubing.App/
|
||||||
|
RUN dotnet restore ChessCubing.App/ChessCubing.App.csproj
|
||||||
|
|
||||||
|
COPY ChessCubing.App/ ChessCubing.App/
|
||||||
|
COPY ethan/ ethan/
|
||||||
|
COPY brice/ brice/
|
||||||
|
COPY favicon.png logo.png transparent.png styles.css site.webmanifest ChessCubing_Time_Reglement_Officiel_V1-1.pdf ChessCubing_Twice_Reglement_Officiel_V2-1.pdf ./
|
||||||
|
RUN dotnet publish ChessCubing.App/ChessCubing.App.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY . /usr/share/nginx/html
|
COPY --from=build /app/publish/wwwroot /usr/share/nginx/html
|
||||||
|
|||||||
17
Dockerfile.auth
Normal file
17
Dockerfile.auth
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY ChessCubing.Server/ChessCubing.Server.csproj ChessCubing.Server/
|
||||||
|
RUN dotnet restore ChessCubing.Server/ChessCubing.Server.csproj
|
||||||
|
|
||||||
|
COPY ChessCubing.Server/ ChessCubing.Server/
|
||||||
|
RUN dotnet publish ChessCubing.Server/ChessCubing.Server.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
|
||||||
|
COPY --from=build /app/publish ./
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "ChessCubing.Server.dll"]
|
||||||
188
README.md
188
README.md
@@ -1,25 +1,63 @@
|
|||||||
# ChessCubing Arena
|
# ChessCubing Arena
|
||||||
|
|
||||||
Application web mobile-first pour téléphone et tablette, pensée comme application officielle de suivi de match pour `ChessCubing Twice` et `ChessCubing Time`.
|
Application web mobile-first pour telephone et tablette, maintenant migree vers **Blazor WebAssembly en C# / .NET 10**.
|
||||||
|
|
||||||
## Ce que fait cette première version
|
## Ce que fait l'application
|
||||||
|
|
||||||
- configure une rencontre `Twice` ou `Time`
|
- configure une rencontre `Twice` ou `Time`
|
||||||
- sépare l'application en pages dédiées : configuration, phase chrono, phase cube
|
- separe l'experience en pages dediees : configuration, phase chrono, phase cube
|
||||||
- permet de définir librement le temps de partie et le temps par coup
|
- permet de definir librement le temps de partie et le temps par coup
|
||||||
- suit les quotas `FAST`, `FREEZE` et `MASTERS`
|
- suit les quotas `FAST`, `FREEZE` et `MASTERS`
|
||||||
- orchestre la phase cube avec désignation du cube, capture des temps et préparation de la partie suivante
|
- orchestre la phase cube avec designation du cube, capture des temps et preparation de la partie suivante
|
||||||
- applique la logique du double coup V2 en `Twice`
|
- applique la logique du double coup V2 en `Twice`
|
||||||
- applique les ajustements `bloc -` et `bloc +` en `Time` avec plafond de 120 s pris en compte
|
- applique les ajustements `bloc -` et `bloc +` en `Time` avec plafond de 120 s pris en compte
|
||||||
- conserve un historique local dans le navigateur
|
- conserve l'etat du match dans le navigateur
|
||||||
- propose une page chrono pensée pour le téléphone avec deux grandes zones tactiles, une par joueur
|
- propose une page chrono pensee pour le telephone avec deux grandes zones tactiles
|
||||||
- ouvre automatiquement la page cube dès que la phase chess de la partie est terminée
|
- ouvre automatiquement la page cube des que la phase chess est terminee
|
||||||
|
|
||||||
## Hypothèse de produit
|
## Architecture
|
||||||
|
|
||||||
Cette version est volontairement construite comme une **application d'arbitrage et de direction de match** autour d'un vrai échiquier physique, et non comme un moteur d'échecs complet. C'est le choix le plus fidèle aux règlements fournis et le plus réaliste pour une utilisation immédiate en club, en démonstration ou en tournoi.
|
Le coeur de l'application se trouve dans `ChessCubing.App/`.
|
||||||
|
|
||||||
## Démarrage avec Docker
|
- `ChessCubing.App/Pages/` : pages Razor du site et de l'application
|
||||||
|
- `ChessCubing.App/Services/MatchEngine.cs` : logique metier des matchs
|
||||||
|
- `ChessCubing.App/Services/MatchStore.cs` : persistance navigateur
|
||||||
|
- `ChessCubing.App/Services/AppAuthenticationStateProvider.cs` : session locale cote client
|
||||||
|
- `ChessCubing.Server/` : backend d'authentification Keycloak et API utilisateur reliee a MySQL
|
||||||
|
- `ChessCubing.App/wwwroot/` : assets statiques, manifeste, PDFs, appli Ethan
|
||||||
|
- `keycloak/realm/chesscubing-realm.json` : realm importable avec client Keycloak et roles
|
||||||
|
- `keycloak/scripts/init-config.sh` : synchronisation automatique du client Keycloak au demarrage
|
||||||
|
- `docker-compose.yml` + `Dockerfile` + `Dockerfile.auth` : front Blazor, API d'auth, Keycloak/Postgres et MySQL pour les donnees du site
|
||||||
|
|
||||||
|
Le projet continue a exposer les routes historiques `index.html`, `application.html`, `chrono.html`, `cube.html` et `reglement.html`.
|
||||||
|
|
||||||
|
## Authentification Keycloak
|
||||||
|
|
||||||
|
L'application embarque maintenant une authentification integree basee sur Keycloak, sans redirection utilisateur vers une page externe.
|
||||||
|
|
||||||
|
- toutes les pages du site restent accessibles sans connexion
|
||||||
|
- un menu general en haut des pages site regroupe la navigation et les actions `Se connecter` / `Creer un compte` dans une modal integree
|
||||||
|
- le formulaire de connexion et le formulaire de creation de compte sont rendus directement dans l'application
|
||||||
|
- un backend local `ChessCubing.Server` appelle Keycloak cote serveur pour la connexion, l'inscription et la recuperation du profil
|
||||||
|
- une session cookie locale est ensuite exposee au front via `/api/auth/session`
|
||||||
|
- les roles Keycloak du realm restent exposes dans l'application
|
||||||
|
- l'etat du match est isole par utilisateur dans le navigateur grace a une cle de stockage derivee du compte connecte
|
||||||
|
- une page `utilisateur` permet maintenant d'editer un profil du site persiste en base MySQL via `/api/users/me`
|
||||||
|
- une page `administration` reservee au role `admin` propose maintenant une table utilisateurs avec actions d'ajout, modification et suppression via `/api/admin/users`
|
||||||
|
|
||||||
|
Le realm importe par defaut :
|
||||||
|
|
||||||
|
- realm : `chesscubing`
|
||||||
|
- client Keycloak : `chesscubing-web`
|
||||||
|
- roles de realm : `admin`, `organizer`, `player`
|
||||||
|
- inscription utilisateur : activee
|
||||||
|
- direct access grant : active
|
||||||
|
|
||||||
|
La gestion des utilisateurs peut maintenant demarrer depuis la page d'administration du site pour les usages courants. La console d'administration Keycloak reste utile pour les reglages avances, notamment les roles.
|
||||||
|
|
||||||
|
## Demarrage local
|
||||||
|
|
||||||
|
### Avec Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
docker compose down
|
||||||
@@ -28,16 +66,50 @@ docker compose up -d --build
|
|||||||
|
|
||||||
L'application est ensuite disponible sur `http://localhost:8080`.
|
L'application est ensuite disponible sur `http://localhost:8080`.
|
||||||
|
|
||||||
## Déploiement dans un LXC Proxmox
|
La console Keycloak est servie via le meme nginx sur `http://localhost:8080/auth/admin/`.
|
||||||
|
L'API d'authentification integree est servie derriere le meme point d'entree via `/api/auth/*`.
|
||||||
|
L'API utilisateur et sa persistance MySQL sont egalement servies via `/api/users/*`.
|
||||||
|
|
||||||
Deux scripts Bash permettent de créer un conteneur LXC Debian sur Proxmox puis de le mettre à jour depuis Git.
|
Identifiants d'administration par defaut pour le premier demarrage local :
|
||||||
|
|
||||||
Prérequis sur la machine qui lance les scripts :
|
- utilisateur : `admin`
|
||||||
|
- mot de passe : `admin`
|
||||||
|
|
||||||
|
Ces valeurs peuvent etre surchargees via les variables d'environnement de `.env.example`.
|
||||||
|
La base MySQL du site utilise les variables `SITE_DB_*` du meme fichier.
|
||||||
|
Pour un deploiement hors localhost, `PUBLIC_BASE_URL` doit pointer vers l'URL publique du site et `WEB_PORT` vers le port HTTP expose.
|
||||||
|
|
||||||
|
Au demarrage, le service `keycloak-init` resynchronise automatiquement le realm courant pour garder l'inscription active et autoriser le flux de connexion integre, meme si la base Keycloak existe deja.
|
||||||
|
|
||||||
|
Si vous voulez reimporter completement le realm fourni dans `keycloak/realm/`, il faut repartir d'une base vide, par exemple avec `docker compose down -v` avant le redemarrage.
|
||||||
|
|
||||||
|
### Avec .NET 10
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet restore ChessCubing.App/ChessCubing.App.csproj
|
||||||
|
dotnet run --project ChessCubing.App/ChessCubing.App.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Le mode Docker reste la voie recommandee pour cette integration, car nginx y reverse-proxy egalement Keycloak sous `/auth/` et l'API d'auth sous `/api/`.
|
||||||
|
|
||||||
|
## Deploiement dans un LXC Proxmox
|
||||||
|
|
||||||
|
Deux scripts Bash permettent de creer un conteneur LXC Debian sur Proxmox puis de le mettre a jour depuis Git.
|
||||||
|
|
||||||
|
Prerrequis sur la machine qui lance les scripts :
|
||||||
|
|
||||||
- en mode distant : `ssh` et `sshpass`
|
- en mode distant : `ssh` et `sshpass`
|
||||||
- en mode local sur l'hôte Proxmox : aucun paquet supplémentaire n'est installé sur Proxmox
|
- en mode local sur l'hote Proxmox : aucun paquet supplementaire n'est installe sur Proxmox
|
||||||
|
|
||||||
Le déploiement dans le LXC n'utilise pas Docker. Le script installe `nginx`, `git` et `rsync` dans le conteneur, clone le dépôt principal, synchronise aussi le projet d'Ethan, puis publie uniquement les fichiers web.
|
Le deploiement dans le LXC Proxmox utilise maintenant Docker dans le conteneur pour lancer la meme stack qu'en local : `web`, `auth`, `keycloak`, `postgres` et `mysql`.
|
||||||
|
|
||||||
|
Le script prepare une URL publique pour Keycloak via `PUBLIC_BASE_URL`, installe Docker dans le LXC, puis lance la stack dans le conteneur.
|
||||||
|
|
||||||
|
Les mises a jour conservent maintenant `mysql`, `postgres` et `keycloak` en place. Le script ne fait plus de `docker compose down` ni de purge complete des images a chaque update, ce qui evite de retelecharger ou recreer inutilement l'infrastructure.
|
||||||
|
|
||||||
|
Si le site est expose derriere un reverse proxy, `PUBLIC_BASE_URL` doit etre l'URL publique finale servie par ce proxy.
|
||||||
|
|
||||||
|
Pour un usage confortable, il est recommande de prevoir un LXC avec au moins 2 vCPU, 4 Go de RAM et 16 Go de disque.
|
||||||
|
|
||||||
### Installer un nouveau LXC
|
### Installer un nouveau LXC
|
||||||
|
|
||||||
@@ -45,7 +117,8 @@ Le déploiement dans le LXC n'utilise pas Docker. Le script installe `nginx`, `g
|
|||||||
./scripts/install-proxmox-lxc.sh \
|
./scripts/install-proxmox-lxc.sh \
|
||||||
--proxmox-host 10.0.0.2 \
|
--proxmox-host 10.0.0.2 \
|
||||||
--proxmox-user root@pam \
|
--proxmox-user root@pam \
|
||||||
--proxmox-password 'secret'
|
--proxmox-password 'secret' \
|
||||||
|
--public-base-url https://jeu.exemple.fr
|
||||||
```
|
```
|
||||||
|
|
||||||
Version "curl | bash" :
|
Version "curl | bash" :
|
||||||
@@ -54,45 +127,16 @@ Version "curl | bash" :
|
|||||||
bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/install-chesscubing-proxmox.sh)"
|
bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/install-chesscubing-proxmox.sh)"
|
||||||
```
|
```
|
||||||
|
|
||||||
Cette version pose les questions nécessaires si les variables d'environnement ne sont pas déjà définies.
|
### Mettre a jour depuis Git
|
||||||
|
|
||||||
Si elle est lancée directement sur l'hôte Proxmox, elle passe automatiquement en mode local :
|
|
||||||
|
|
||||||
- elle ne demande ni serveur, ni login, ni mot de passe SSH
|
|
||||||
- elle n'installe rien sur l'hôte Proxmox
|
|
||||||
- elle crée uniquement le LXC puis installe les dépendances dans ce LXC
|
|
||||||
|
|
||||||
Valeurs par défaut utiles :
|
|
||||||
|
|
||||||
- LXC nommé `chesscubing-web`
|
|
||||||
- IP du LXC en `dhcp`
|
|
||||||
- branche Git `main`
|
|
||||||
- dépôt `https://git.jeannerot.fr/christophe/chesscubing.git`
|
|
||||||
- dépôt Ethan `https://git.jeannerot.fr/Mineloulou/Chesscubing.git`
|
|
||||||
|
|
||||||
Options utiles si besoin :
|
|
||||||
|
|
||||||
- `--ctid 120`
|
|
||||||
- `--lxc-ip 192.168.1.50/24 --gateway 192.168.1.1`
|
|
||||||
- `--template-storage local`
|
|
||||||
- `--rootfs-storage local-lvm`
|
|
||||||
- `--branch main`
|
|
||||||
- `--ethan-branch main`
|
|
||||||
|
|
||||||
À la fin, le script affiche :
|
|
||||||
|
|
||||||
- le `CTID`
|
|
||||||
- le mot de passe `root` du LXC
|
|
||||||
- l'URL probable du site
|
|
||||||
|
|
||||||
### Mettre à jour depuis Git
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/update-proxmox-lxc.sh \
|
./scripts/update-proxmox-lxc.sh \
|
||||||
--proxmox-host 10.0.0.2 \
|
--proxmox-host 10.0.0.2 \
|
||||||
--proxmox-user root@pam \
|
--proxmox-user root@pam \
|
||||||
--proxmox-password 'secret' \
|
--proxmox-password 'secret' \
|
||||||
--ctid 120
|
--ctid 120 \
|
||||||
|
--public-base-url https://jeu.exemple.fr \
|
||||||
|
--disk-gb 16
|
||||||
```
|
```
|
||||||
|
|
||||||
Version "curl | bash" :
|
Version "curl | bash" :
|
||||||
@@ -101,30 +145,22 @@ Version "curl | bash" :
|
|||||||
bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/update-chesscubing-proxmox.sh)"
|
bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch/main/update-chesscubing-proxmox.sh)"
|
||||||
```
|
```
|
||||||
|
|
||||||
Sur l'hôte Proxmox, cette commande met à jour le LXC local sans passer par SSH.
|
## Fichiers cles
|
||||||
Par défaut, elle cible le conteneur `chesscubing-web` sans demander le `CTID`.
|
|
||||||
|
|
||||||
On peut aussi cibler le conteneur par nom si on n'a pas le `CTID` :
|
- `ChessCubing.App/Pages/Home.razor` : page d'accueil du site
|
||||||
|
- `ChessCubing.App/Pages/UserPage.razor` : page utilisateur connectee a MySQL
|
||||||
```bash
|
- `ChessCubing.App/Pages/AdminPage.razor` : premiere page d'administration pour gerer les utilisateurs
|
||||||
./scripts/update-proxmox-lxc.sh \
|
- `ChessCubing.App/Pages/ApplicationPage.razor` : configuration et reprise de match
|
||||||
--proxmox-host 10.0.0.2 \
|
- `ChessCubing.App/Pages/ChronoPage.razor` : phase chrono
|
||||||
--proxmox-user root@pam \
|
- `ChessCubing.App/Pages/CubePage.razor` : phase cube
|
||||||
--proxmox-password 'secret' \
|
- `ChessCubing.App/Pages/RulesPage.razor` : synthese du reglement
|
||||||
--hostname chesscubing-web
|
- `ChessCubing.App/Services/MatchEngine.cs` : regles de jeu et transitions
|
||||||
```
|
- `ChessCubing.App/Services/AppAuthenticationStateProvider.cs` : etat de session cote client
|
||||||
|
- `ChessCubing.Server/Program.cs` : endpoints `/api/auth/*` et `/api/users/*`
|
||||||
Le script de mise à jour exécute un `git pull --ff-only` pour le dépôt principal et le dépôt d'Ethan dans le conteneur, puis republie les fichiers statiques via `nginx`, y compris la route `/ethan/`.
|
- `ChessCubing.Server/Program.cs` : endpoints `/api/auth/*`, `/api/users/*` et `/api/admin/users/*`
|
||||||
|
- `ChessCubing.Server/Users/MySqlUserProfileStore.cs` : creation de table et persistance du profil utilisateur
|
||||||
## Fichiers clés
|
- `keycloak/realm/chesscubing-realm.json` : realm, roles et client Keycloak importes
|
||||||
|
- `keycloak/scripts/init-config.sh` : mise en conformite du client Keycloak au demarrage
|
||||||
- `index.html` : page d'accueil du site
|
- `docker-compose.yml` + `Dockerfile` + `Dockerfile.auth` : execution locale
|
||||||
- `application.html` : page de configuration et reprise de match
|
- `scripts/install-proxmox-lxc.sh` : creation et deploiement d'un LXC Proxmox
|
||||||
- `chrono.html` : page dédiée à la phase chrono
|
- `scripts/update-proxmox-lxc.sh` : mise a jour d'un LXC existant depuis Git
|
||||||
- `cube.html` : page dédiée à la phase cube
|
|
||||||
- `reglement.html` : page éditoriale qui présente le règlement officiel
|
|
||||||
- `styles.css` : design mobile/tablette
|
|
||||||
- `app.js` : logique de match et arbitrage
|
|
||||||
- `docker-compose.yml` + `Dockerfile` : exécution locale
|
|
||||||
- `scripts/install-proxmox-lxc.sh` : création et déploiement d'un LXC Proxmox
|
|
||||||
- `scripts/update-proxmox-lxc.sh` : mise à jour d'un LXC existant depuis Git
|
|
||||||
|
|||||||
191
app.js
191
app.js
@@ -9,6 +9,7 @@ const DEFAULT_MOVE_LIMIT_MS = 20000;
|
|||||||
const TIME_MODE_INITIAL_CLOCK_MS = 600000;
|
const TIME_MODE_INITIAL_CLOCK_MS = 600000;
|
||||||
const CUBE_TIME_CAP_MS = 120000;
|
const CUBE_TIME_CAP_MS = 120000;
|
||||||
const CUBE_START_HOLD_MS = 2000;
|
const CUBE_START_HOLD_MS = 2000;
|
||||||
|
const CUBE_PHASE_ALERT_NAVIGATION_DELAY_MS = 700;
|
||||||
|
|
||||||
const PRESETS = {
|
const PRESETS = {
|
||||||
fast: {
|
fast: {
|
||||||
@@ -31,7 +32,7 @@ const PRESETS = {
|
|||||||
const MODES = {
|
const MODES = {
|
||||||
twice: {
|
twice: {
|
||||||
label: "ChessCubing Twice",
|
label: "ChessCubing Twice",
|
||||||
subtitle: "Le gagnant du cube ouvre la partie suivante.",
|
subtitle: "Le gagnant du cube ouvre le Block suivant.",
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
label: "ChessCubing Time",
|
label: "ChessCubing Time",
|
||||||
@@ -56,6 +57,8 @@ window.requestAnimationFrame(() => {
|
|||||||
|
|
||||||
let match = readStoredMatch();
|
let match = readStoredMatch();
|
||||||
let dirty = false;
|
let dirty = false;
|
||||||
|
let audioContext = null;
|
||||||
|
let cubePhaseAlertRetryCleanup = null;
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
if (normalizeRecoveredMatch(match)) {
|
if (normalizeRecoveredMatch(match)) {
|
||||||
@@ -67,6 +70,8 @@ if (match) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("beforeunload", flushState);
|
window.addEventListener("beforeunload", flushState);
|
||||||
|
document.addEventListener("pointerdown", tryUnlockAudioContext, { passive: true });
|
||||||
|
document.addEventListener("keydown", tryUnlockAudioContext);
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -110,6 +115,120 @@ function syncViewportHeight() {
|
|||||||
document.documentElement.style.setProperty("--app-viewport-height", `${Math.round(viewportHeight)}px`);
|
document.documentElement.style.setProperty("--app-viewport-height", `${Math.round(viewportHeight)}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAudioContext() {
|
||||||
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioContextClass) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioContext) {
|
||||||
|
try {
|
||||||
|
audioContext = new AudioContextClass();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryUnlockAudioContext() {
|
||||||
|
const context = getAudioContext();
|
||||||
|
if (!context || context.state === "running") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.resume().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playCubePhaseAlert() {
|
||||||
|
const context = getAudioContext();
|
||||||
|
if (!context || context.state !== "running") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = [
|
||||||
|
{ frequency: 740, offset: 0, duration: 0.12, gain: 0.035 },
|
||||||
|
{ frequency: 988, offset: 0.18, duration: 0.12, gain: 0.04 },
|
||||||
|
{ frequency: 1318, offset: 0.36, duration: 0.2, gain: 0.05 },
|
||||||
|
];
|
||||||
|
const startAt = context.currentTime + 0.02;
|
||||||
|
|
||||||
|
pattern.forEach(({ frequency, offset, duration, gain }) => {
|
||||||
|
const oscillator = context.createOscillator();
|
||||||
|
const envelope = context.createGain();
|
||||||
|
const toneStartAt = startAt + offset;
|
||||||
|
|
||||||
|
oscillator.type = "triangle";
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, toneStartAt);
|
||||||
|
envelope.gain.setValueAtTime(0.0001, toneStartAt);
|
||||||
|
envelope.gain.exponentialRampToValueAtTime(gain, toneStartAt + 0.02);
|
||||||
|
envelope.gain.exponentialRampToValueAtTime(0.0001, toneStartAt + duration);
|
||||||
|
|
||||||
|
oscillator.connect(envelope);
|
||||||
|
envelope.connect(context.destination);
|
||||||
|
oscillator.start(toneStartAt);
|
||||||
|
oscillator.stop(toneStartAt + duration + 0.03);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCubePhaseAlertRetry() {
|
||||||
|
if (typeof cubePhaseAlertRetryCleanup === "function") {
|
||||||
|
cubePhaseAlertRetryCleanup();
|
||||||
|
cubePhaseAlertRetryCleanup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCubePhaseAlertRetry(storedMatch) {
|
||||||
|
if (!storedMatch?.cube?.phaseAlertPending || cubePhaseAlertRetryCleanup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryPlayOnGesture = () => {
|
||||||
|
tryUnlockAudioContext();
|
||||||
|
if (!playCubePhaseAlert()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
storedMatch.cube.phaseAlertPending = false;
|
||||||
|
dirty = true;
|
||||||
|
persistMatch();
|
||||||
|
clearCubePhaseAlertRetry();
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = ["pointerdown", "keydown"];
|
||||||
|
cubePhaseAlertRetryCleanup = () => {
|
||||||
|
events.forEach((eventName) => {
|
||||||
|
document.removeEventListener(eventName, tryPlayOnGesture);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
events.forEach((eventName) => {
|
||||||
|
document.addEventListener(eventName, tryPlayOnGesture, { passive: eventName === "pointerdown" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePlayPendingCubePhaseAlert(storedMatch) {
|
||||||
|
if (!storedMatch?.cube?.phaseAlertPending) {
|
||||||
|
clearCubePhaseAlertRetry();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tryUnlockAudioContext();
|
||||||
|
if (!playCubePhaseAlert()) {
|
||||||
|
scheduleCubePhaseAlertRetry(storedMatch);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
storedMatch.cube.phaseAlertPending = false;
|
||||||
|
dirty = true;
|
||||||
|
persistMatch();
|
||||||
|
clearCubePhaseAlertRetry();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function initSetupPage() {
|
function initSetupPage() {
|
||||||
const form = document.querySelector("#setupForm");
|
const form = document.querySelector("#setupForm");
|
||||||
const summary = document.querySelector("#setupSummary");
|
const summary = document.querySelector("#setupSummary");
|
||||||
@@ -121,7 +240,7 @@ function initSetupPage() {
|
|||||||
const competitionFields = Array.from(document.querySelectorAll("[data-competition-field]"));
|
const competitionFields = Array.from(document.querySelectorAll("[data-competition-field]"));
|
||||||
const moveSecondsField = document.querySelector("#moveSecondsField");
|
const moveSecondsField = document.querySelector("#moveSecondsField");
|
||||||
const timeInitialField = document.querySelector("#timeInitialField");
|
const timeInitialField = document.querySelector("#timeInitialField");
|
||||||
const blockSecondsLabel = document.querySelector("#blockSecondsLabel");
|
const blockDurationLabel = document.querySelector("#blockDurationLabel");
|
||||||
const moveSecondsInput = form?.querySelector('[name="moveSeconds"]');
|
const moveSecondsInput = form?.querySelector('[name="moveSeconds"]');
|
||||||
const timeInitialInput = form?.querySelector('[name="timeInitialMinutes"]');
|
const timeInitialInput = form?.querySelector('[name="timeInitialMinutes"]');
|
||||||
|
|
||||||
@@ -171,7 +290,7 @@ function initSetupPage() {
|
|||||||
const mode = getRadioValue(form, "mode") || "twice";
|
const mode = getRadioValue(form, "mode") || "twice";
|
||||||
const preset = getRadioValue(form, "preset") || "fast";
|
const preset = getRadioValue(form, "preset") || "fast";
|
||||||
const quota = PRESETS[preset].quota;
|
const quota = PRESETS[preset].quota;
|
||||||
const blockDurationMs = getDurationInputMs(form, "blockSeconds", DEFAULT_BLOCK_DURATION_MS);
|
const blockDurationMs = getMinuteInputMs(form, "blockMinutes", DEFAULT_BLOCK_DURATION_MS);
|
||||||
const moveLimitMs = getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS);
|
const moveLimitMs = getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS);
|
||||||
const timeInitialMs = getMinuteInputMs(form, "timeInitialMinutes", TIME_MODE_INITIAL_CLOCK_MS);
|
const timeInitialMs = getMinuteInputMs(form, "timeInitialMinutes", TIME_MODE_INITIAL_CLOCK_MS);
|
||||||
const blockLabel = getBlockLabel(mode);
|
const blockLabel = getBlockLabel(mode);
|
||||||
@@ -179,9 +298,9 @@ function initSetupPage() {
|
|||||||
const timeImpact =
|
const timeImpact =
|
||||||
mode === "time"
|
mode === "time"
|
||||||
? `Chronos cumules de ${formatClock(timeInitialMs)} par joueur, ajustes apres chaque phase cube avec plafond de 120 s pris en compte. Aucun temps par coup en mode Time.`
|
? `Chronos cumules de ${formatClock(timeInitialMs)} 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 la partie suivante, avec double coup V2 possible.";
|
: "Le gagnant du cube commence le Block suivant, avec double coup V2 possible.";
|
||||||
const timingText = moveLimitActive
|
const timingText = moveLimitActive
|
||||||
? `Temps configures : partie ${formatClock(blockDurationMs)}, coup ${formatClock(moveLimitMs)}.`
|
? `Temps configures : Block ${formatClock(blockDurationMs)}, coup ${formatClock(moveLimitMs)}.`
|
||||||
: `Temps configures : Block ${formatClock(blockDurationMs)}, temps de chaque joueur ${formatClock(timeInitialMs)}.`;
|
: `Temps configures : Block ${formatClock(blockDurationMs)}, temps de chaque joueur ${formatClock(timeInitialMs)}.`;
|
||||||
const quotaText = moveLimitActive
|
const quotaText = moveLimitActive
|
||||||
? `Quota actif : ${quota} coups par joueur.`
|
? `Quota actif : ${quota} coups par joueur.`
|
||||||
@@ -205,9 +324,8 @@ function initSetupPage() {
|
|||||||
|
|
||||||
document.body.classList.toggle("time-setup-mode", !moveLimitActive);
|
document.body.classList.toggle("time-setup-mode", !moveLimitActive);
|
||||||
|
|
||||||
if (blockSecondsLabel instanceof HTMLElement) {
|
if (blockDurationLabel instanceof HTMLElement) {
|
||||||
blockSecondsLabel.textContent =
|
blockDurationLabel.textContent = "Temps du Block (minutes)";
|
||||||
blockLabel === "Block" ? "Temps du Block (secondes)" : "Temps partie (secondes)";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
syncCompetitionFields();
|
syncCompetitionFields();
|
||||||
@@ -299,7 +417,7 @@ function initSetupPage() {
|
|||||||
competitionMode: isCheckboxChecked(form, "competitionMode"),
|
competitionMode: isCheckboxChecked(form, "competitionMode"),
|
||||||
mode: getRadioValue(form, "mode") || "twice",
|
mode: getRadioValue(form, "mode") || "twice",
|
||||||
preset: getRadioValue(form, "preset") || "fast",
|
preset: getRadioValue(form, "preset") || "fast",
|
||||||
blockDurationMs: getDurationInputMs(form, "blockSeconds", DEFAULT_BLOCK_DURATION_MS),
|
blockDurationMs: getMinuteInputMs(form, "blockMinutes", DEFAULT_BLOCK_DURATION_MS),
|
||||||
moveLimitMs: getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS),
|
moveLimitMs: getDurationInputMs(form, "moveSeconds", DEFAULT_MOVE_LIMIT_MS),
|
||||||
timeInitialMs: getMinuteInputMs(form, "timeInitialMinutes", TIME_MODE_INITIAL_CLOCK_MS),
|
timeInitialMs: getMinuteInputMs(form, "timeInitialMinutes", TIME_MODE_INITIAL_CLOCK_MS),
|
||||||
whiteName: sanitizeText(data.get("whiteName")) || "Blanc",
|
whiteName: sanitizeText(data.get("whiteName")) || "Blanc",
|
||||||
@@ -325,7 +443,25 @@ function initChronoPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cubeNavigationTimeoutId = null;
|
||||||
const goToCubePage = () => {
|
const goToCubePage = () => {
|
||||||
|
if (cubeNavigationTimeoutId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match?.cube?.phaseAlertPending) {
|
||||||
|
tryUnlockAudioContext();
|
||||||
|
if (playCubePhaseAlert()) {
|
||||||
|
match.cube.phaseAlertPending = false;
|
||||||
|
dirty = true;
|
||||||
|
persistMatch();
|
||||||
|
cubeNavigationTimeoutId = window.setTimeout(() => {
|
||||||
|
replaceTo("cube.html");
|
||||||
|
}, CUBE_PHASE_ALERT_NAVIGATION_DELAY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dirty = true;
|
dirty = true;
|
||||||
persistMatch();
|
persistMatch();
|
||||||
replaceTo("cube.html");
|
replaceTo("cube.html");
|
||||||
@@ -605,7 +741,7 @@ function initChronoPage() {
|
|||||||
document.body.classList.toggle("time-mode", timeMode);
|
document.body.classList.toggle("time-mode", timeMode);
|
||||||
refs.title.textContent = match.config.matchLabel;
|
refs.title.textContent = match.config.matchLabel;
|
||||||
refs.subtitle.textContent = `${blockHeading} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`;
|
refs.subtitle.textContent = `${blockHeading} - ${MODES[match.config.mode].label} - ${renderModeContext(match)}`;
|
||||||
refs.blockTimerLabel.textContent = timeMode ? "Temps Block" : "Temps partie";
|
refs.blockTimerLabel.textContent = "Temps Block";
|
||||||
refs.blockTimer.textContent = formatClock(match.blockRemainingMs);
|
refs.blockTimer.textContent = formatClock(match.blockRemainingMs);
|
||||||
refs.moveTimer.textContent = usesMoveLimit(match) ? formatClock(match.moveRemainingMs) : "--:--";
|
refs.moveTimer.textContent = usesMoveLimit(match) ? formatClock(match.moveRemainingMs) : "--:--";
|
||||||
refs.moveTimerCard.hidden = timeMode;
|
refs.moveTimerCard.hidden = timeMode;
|
||||||
@@ -638,8 +774,8 @@ function initChronoPage() {
|
|||||||
refs.spineLabel.textContent = timeMode ? "Etat du Block" : "Pret";
|
refs.spineLabel.textContent = timeMode ? "Etat du Block" : "Pret";
|
||||||
refs.spineHeadline.textContent = blockHeading;
|
refs.spineHeadline.textContent = blockHeading;
|
||||||
refs.spineText.textContent =
|
refs.spineText.textContent =
|
||||||
"Demarrez la partie, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.";
|
"Demarrez le Block, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.";
|
||||||
refs.primaryButton.textContent = timeMode ? "Demarrer le Block" : "Demarrer la partie";
|
refs.primaryButton.textContent = "Demarrer le Block";
|
||||||
refs.arbiterStatus.textContent = `${blockHeading} pret. ${playerName(match, match.currentTurn)} commencera.`;
|
refs.arbiterStatus.textContent = `${blockHeading} pret. ${playerName(match, match.currentTurn)} commencera.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,6 +817,8 @@ function initCubePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maybePlayPendingCubePhaseAlert(match);
|
||||||
|
|
||||||
const refs = {
|
const refs = {
|
||||||
title: document.querySelector("#cubeTitle"),
|
title: document.querySelector("#cubeTitle"),
|
||||||
subtitle: document.querySelector("#cubeSubtitle"),
|
subtitle: document.querySelector("#cubeSubtitle"),
|
||||||
@@ -790,6 +928,7 @@ function initCubePage() {
|
|||||||
resultModalKey = null;
|
resultModalKey = null;
|
||||||
replayCubePhase(match);
|
replayCubePhase(match);
|
||||||
dirty = true;
|
dirty = true;
|
||||||
|
maybePlayPendingCubePhaseAlert(match);
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -806,6 +945,7 @@ function initCubePage() {
|
|||||||
resultModalKey = null;
|
resultModalKey = null;
|
||||||
replayCubePhase(match);
|
replayCubePhase(match);
|
||||||
dirty = true;
|
dirty = true;
|
||||||
|
maybePlayPendingCubePhaseAlert(match);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1115,10 +1255,10 @@ function initCubePage() {
|
|||||||
key: `twice:${match.blockNumber}:${match.cube.round}:${white}:${black}`,
|
key: `twice:${match.blockNumber}:${match.cube.round}:${white}:${black}`,
|
||||||
title: isTie ? "Egalite parfaite" : "Résumé du cube",
|
title: isTie ? "Egalite parfaite" : "Résumé du cube",
|
||||||
winner: winner ? playerName(match, winner) : "Egalite parfaite",
|
winner: winner ? playerName(match, winner) : "Egalite parfaite",
|
||||||
outcome: isTie ? "Rejouer la phase cube" : `${playerName(match, winner)} ouvrira la partie suivante`,
|
outcome: isTie ? "Rejouer la phase cube" : `${playerName(match, winner)} ouvrira le Block suivant`,
|
||||||
summary: isTie
|
summary: isTie
|
||||||
? "Le règlement Twice impose de rejouer immédiatement la phase cube."
|
? "Le règlement Twice impose de rejouer immédiatement la phase cube."
|
||||||
: "Validez ce résultat pour préparer la partie suivante.",
|
: "Validez ce résultat pour preparer le Block suivant.",
|
||||||
actionLabel: isTie ? "Rejouer la phase cube" : "Appliquer et ouvrir la page chrono",
|
actionLabel: isTie ? "Rejouer la phase cube" : "Appliquer et ouvrir la page chrono",
|
||||||
whiteName,
|
whiteName,
|
||||||
blackName,
|
blackName,
|
||||||
@@ -1212,7 +1352,7 @@ function initCubePage() {
|
|||||||
refs.centerValue.textContent = "Phase cube complete";
|
refs.centerValue.textContent = "Phase cube complete";
|
||||||
refs.spineLabel.textContent = "Suite";
|
refs.spineLabel.textContent = "Suite";
|
||||||
refs.spineHeadline.textContent = "Ouvrir la page chrono";
|
refs.spineHeadline.textContent = "Ouvrir la page chrono";
|
||||||
refs.spineText.textContent = "Appliquer le resultat du cube pour preparer la partie suivante.";
|
refs.spineText.textContent = "Appliquer le resultat du cube pour preparer le Block suivant.";
|
||||||
}
|
}
|
||||||
refs.primaryButton.textContent = "Voir le résumé du cube";
|
refs.primaryButton.textContent = "Voir le résumé du cube";
|
||||||
refs.helpStatus.textContent = refs.spineText.textContent;
|
refs.helpStatus.textContent = refs.spineText.textContent;
|
||||||
@@ -1303,6 +1443,7 @@ function createMatch(config) {
|
|||||||
running: false,
|
running: false,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
elapsedMs: 0,
|
elapsedMs: 0,
|
||||||
|
phaseAlertPending: false,
|
||||||
times: {
|
times: {
|
||||||
white: null,
|
white: null,
|
||||||
black: null,
|
black: null,
|
||||||
@@ -1325,7 +1466,7 @@ function createMatch(config) {
|
|||||||
logEvent(
|
logEvent(
|
||||||
newMatch,
|
newMatch,
|
||||||
usesMoveLimit(config.mode)
|
usesMoveLimit(config.mode)
|
||||||
? `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, partie ${formatClock(config.blockDurationMs)} et coup ${formatClock(config.moveLimitMs)}.`
|
? `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, Block ${formatClock(config.blockDurationMs)} et coup ${formatClock(config.moveLimitMs)}.`
|
||||||
: `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, Block ${formatClock(config.blockDurationMs)} et chrono initial ${formatClock(getTimeInitialMs(config))} par joueur, sans temps par coup.`,
|
: `Match cree en mode ${MODES[config.mode].label}, cadence ${PRESETS[config.preset].label}, Block ${formatClock(config.blockDurationMs)} et chrono initial ${formatClock(getTimeInitialMs(config))} par joueur, sans temps par coup.`,
|
||||||
);
|
);
|
||||||
logEvent(newMatch, `Les Blancs commencent ${formatBlockHeading(config, 1)}.`);
|
logEvent(newMatch, `Les Blancs commencent ${formatBlockHeading(config, 1)}.`);
|
||||||
@@ -1405,6 +1546,7 @@ function normalizeRecoveredMatch(storedMatch) {
|
|||||||
running: false,
|
running: false,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
elapsedMs: 0,
|
elapsedMs: 0,
|
||||||
|
phaseAlertPending: false,
|
||||||
times: { white: null, black: null },
|
times: { white: null, black: null },
|
||||||
playerState: {
|
playerState: {
|
||||||
white: createCubePlayerState(),
|
white: createCubePlayerState(),
|
||||||
@@ -1424,6 +1566,11 @@ function normalizeRecoveredMatch(storedMatch) {
|
|||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof storedMatch.cube.phaseAlertPending !== "boolean") {
|
||||||
|
storedMatch.cube.phaseAlertPending = false;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white);
|
storedMatch.cube.playerState.white = normalizeCubePlayerState(storedMatch.cube.playerState.white);
|
||||||
storedMatch.cube.playerState.black = normalizeCubePlayerState(storedMatch.cube.playerState.black);
|
storedMatch.cube.playerState.black = normalizeCubePlayerState(storedMatch.cube.playerState.black);
|
||||||
storedMatch.cube.running = isAnyCubeTimerRunning(storedMatch);
|
storedMatch.cube.running = isAnyCubeTimerRunning(storedMatch);
|
||||||
@@ -1537,6 +1684,7 @@ function openCubePhase(storedMatch, reason = "") {
|
|||||||
storedMatch.cube.running = false;
|
storedMatch.cube.running = false;
|
||||||
storedMatch.cube.startedAt = null;
|
storedMatch.cube.startedAt = null;
|
||||||
storedMatch.cube.elapsedMs = 0;
|
storedMatch.cube.elapsedMs = 0;
|
||||||
|
storedMatch.cube.phaseAlertPending = true;
|
||||||
storedMatch.cube.times = { white: null, black: null };
|
storedMatch.cube.times = { white: null, black: null };
|
||||||
storedMatch.cube.playerState = {
|
storedMatch.cube.playerState = {
|
||||||
white: createCubePlayerState(),
|
white: createCubePlayerState(),
|
||||||
@@ -1633,7 +1781,7 @@ function applyCubeOutcome(storedMatch) {
|
|||||||
|
|
||||||
function prepareNextTwiceBlock(storedMatch, winner) {
|
function prepareNextTwiceBlock(storedMatch, winner) {
|
||||||
const hadDouble = storedMatch.lastMover !== winner && storedMatch.lastMover !== null;
|
const hadDouble = storedMatch.lastMover !== winner && storedMatch.lastMover !== null;
|
||||||
logEvent(storedMatch, `${playerName(storedMatch, winner)} gagne la phase cube et ouvrira la partie suivante.`);
|
logEvent(storedMatch, `${playerName(storedMatch, winner)} gagne la phase cube et ouvrira le Block suivant.`);
|
||||||
|
|
||||||
storedMatch.blockNumber += 1;
|
storedMatch.blockNumber += 1;
|
||||||
storedMatch.phase = "block";
|
storedMatch.phase = "block";
|
||||||
@@ -1730,6 +1878,7 @@ function replayCubePhase(storedMatch) {
|
|||||||
storedMatch.cube.running = false;
|
storedMatch.cube.running = false;
|
||||||
storedMatch.cube.startedAt = null;
|
storedMatch.cube.startedAt = null;
|
||||||
storedMatch.cube.elapsedMs = 0;
|
storedMatch.cube.elapsedMs = 0;
|
||||||
|
storedMatch.cube.phaseAlertPending = true;
|
||||||
storedMatch.cube.times = { white: null, black: null };
|
storedMatch.cube.times = { white: null, black: null };
|
||||||
storedMatch.cube.playerState = {
|
storedMatch.cube.playerState = {
|
||||||
white: createCubePlayerState(),
|
white: createCubePlayerState(),
|
||||||
@@ -2032,7 +2181,7 @@ function loadDemo(form, onRender) {
|
|||||||
setInputValue(form, "matchLabel", "Demo officielle ChessCubing");
|
setInputValue(form, "matchLabel", "Demo officielle ChessCubing");
|
||||||
setRadioValue(form, "mode", "twice");
|
setRadioValue(form, "mode", "twice");
|
||||||
setRadioValue(form, "preset", "freeze");
|
setRadioValue(form, "preset", "freeze");
|
||||||
setInputValue(form, "blockSeconds", "180");
|
setInputValue(form, "blockMinutes", "3");
|
||||||
setInputValue(form, "moveSeconds", "20");
|
setInputValue(form, "moveSeconds", "20");
|
||||||
setInputValue(form, "timeInitialMinutes", "10");
|
setInputValue(form, "timeInitialMinutes", "10");
|
||||||
setInputValue(form, "whiteName", "Nora");
|
setInputValue(form, "whiteName", "Nora");
|
||||||
@@ -2114,15 +2263,15 @@ function getTimeInitialMs(matchOrConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getBlockLabel(matchOrConfig) {
|
function getBlockLabel(matchOrConfig) {
|
||||||
return isTimeMode(matchOrConfig) ? "Block" : "Partie";
|
return "Block";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBlockPhrase(matchOrConfig) {
|
function getBlockPhrase(matchOrConfig) {
|
||||||
return isTimeMode(matchOrConfig) ? "Le Block" : "La partie";
|
return "Le Block";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBlockGenitivePhrase(matchOrConfig) {
|
function getBlockGenitivePhrase(matchOrConfig) {
|
||||||
return isTimeMode(matchOrConfig) ? "du Block" : "de la partie";
|
return "du Block";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBlockHeading(matchOrConfig, blockNumber) {
|
function formatBlockHeading(matchOrConfig, blockNumber) {
|
||||||
|
|||||||
@@ -51,10 +51,8 @@
|
|||||||
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
|
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stylesheet = document.createElement("link");
|
const stylesheetHref = window.__CHESSCUBING_ASSET_URL__("styles.css");
|
||||||
stylesheet.rel = "stylesheet";
|
document.write(`<link rel="stylesheet" href="${stylesheetHref}" />`);
|
||||||
stylesheet.href = window.__CHESSCUBING_ASSET_URL__("styles.css");
|
|
||||||
document.head.append(stylesheet);
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
@@ -129,7 +127,7 @@
|
|||||||
<input type="radio" name="mode" value="twice" checked />
|
<input type="radio" name="mode" value="twice" checked />
|
||||||
<strong>ChessCubing Twice</strong>
|
<strong>ChessCubing Twice</strong>
|
||||||
<span>
|
<span>
|
||||||
Le gagnant du cube ouvre la partie suivante et peut obtenir un
|
Le gagnant du cube ouvre le Block suivant et peut obtenir un
|
||||||
double coup V2.
|
double coup V2.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -169,14 +167,14 @@
|
|||||||
<legend>Temps personnalisés</legend>
|
<legend>Temps personnalisés</legend>
|
||||||
<div class="timing-grid">
|
<div class="timing-grid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span id="blockSecondsLabel">Temps partie (secondes)</span>
|
<span id="blockDurationLabel">Temps du Block (minutes)</span>
|
||||||
<input
|
<input
|
||||||
name="blockSeconds"
|
name="blockMinutes"
|
||||||
type="number"
|
type="number"
|
||||||
min="30"
|
min="1"
|
||||||
max="1800"
|
max="180"
|
||||||
step="5"
|
step="1"
|
||||||
value="180"
|
value="3"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field" id="timeInitialField" hidden>
|
<label class="field" id="timeInitialField" hidden>
|
||||||
|
|||||||
25
brice/.gitignore
vendored
Normal file
25
brice/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Generated by package manager
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Generated by Cordova
|
||||||
|
/plugins/
|
||||||
|
/platforms/
|
||||||
13
brice/config.xml
Normal file
13
brice/config.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<widget id="org.apache.cordova.hellocordova" version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||||
|
<name>Hello Cordova</name>
|
||||||
|
<description>
|
||||||
|
A sample Apache Cordova application that responds to the deviceready event.
|
||||||
|
</description>
|
||||||
|
<author email="dev@cordova.apache.org" href="https://cordova.apache.org">
|
||||||
|
Apache Cordova Team
|
||||||
|
</author>
|
||||||
|
<content src="index.html" />
|
||||||
|
<allow-intent href="http://*/*" />
|
||||||
|
<allow-intent href="https://*/*" />
|
||||||
|
</widget>
|
||||||
897
brice/package-lock.json
generated
Normal file
897
brice/package-lock.json
generated
Normal file
@@ -0,0 +1,897 @@
|
|||||||
|
{
|
||||||
|
"name": "org.apache.cordova.hellocordova",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "org.apache.cordova.hellocordova",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"cordova-android": "^14.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@netflix/nerror": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@netflix/nerror/-/nerror-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-b+MGNyP9/LXkapreJzNUzcvuzZslj/RGgdVVJ16P2wSlYatfLycPObImqVJSmNAdyeShvNeM/pl3sVZsObFueg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assert-plus": "^1.0.0",
|
||||||
|
"extsprintf": "^1.4.0",
|
||||||
|
"lodash": "^4.17.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodelib/fs.stat": "2.0.5",
|
||||||
|
"run-parallel": "^1.1.9"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nodelib/fs.stat": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nodelib/fs.walk": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodelib/fs.scandir": "2.1.5",
|
||||||
|
"fastq": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.8.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||||
|
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/abbrev": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/android-versions": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/android-versions/-/android-versions-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-oCBvVs2uaL8ohQtesGs78/X7QvFDLbKgTosBRiOIBCss1a/yiakQm/ADuoG2k/AUaI0FfrsFeMl/a+GtEtjEeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.5.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-iFY7JCgHbepc0b82yLaw4IMortylNb6wG4kL+4R0C3iv6i+RHGHux/yUX5BTiRvSX/shMnngjR1YyNMnXEFh5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/assert-plus": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/big-integer": {
|
||||||
|
"version": "1.6.52",
|
||||||
|
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
||||||
|
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Unlicense",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bplist-parser": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"big-integer": "1.6.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 5.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/braces": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fill-range": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cordova-android": {
|
||||||
|
"version": "14.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cordova-android/-/cordova-android-14.0.1.tgz",
|
||||||
|
"integrity": "sha512-HMBMdGu/JlSQtmBuDEpKWf/pE75SpF3FksxZ+mqYuL3qSIN8lN/QsNurwYaPAP7zWXN2DNpvwlpOJItS5VhdLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"android-versions": "^2.1.0",
|
||||||
|
"cordova-common": "^5.0.1",
|
||||||
|
"dedent": "^1.5.3",
|
||||||
|
"execa": "^5.1.1",
|
||||||
|
"fast-glob": "^3.3.3",
|
||||||
|
"is-path-inside": "^3.0.3",
|
||||||
|
"nopt": "^8.1.0",
|
||||||
|
"properties-parser": "^0.6.0",
|
||||||
|
"semver": "^7.7.1",
|
||||||
|
"string-argv": "^0.3.1",
|
||||||
|
"untildify": "^4.0.0",
|
||||||
|
"which": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cordova-common": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cordova-common/-/cordova-common-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-OA2NQ6wvhNz4GytPYwTdlA9xfG7Yf7ufkj4u97m3rUfoL/AECwwj0GVT2CYpk/0Fk6HyuHA3QYCxfDPYsKzI1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@netflix/nerror": "^1.1.3",
|
||||||
|
"ansi": "^0.3.1",
|
||||||
|
"bplist-parser": "^0.3.2",
|
||||||
|
"cross-spawn": "^7.0.6",
|
||||||
|
"elementtree": "^0.1.7",
|
||||||
|
"endent": "^2.1.0",
|
||||||
|
"fast-glob": "^3.3.3",
|
||||||
|
"lodash.zip": "^4.2.0",
|
||||||
|
"plist": "^3.1.0",
|
||||||
|
"q": "^1.5.1",
|
||||||
|
"read-chunk": "^3.2.0",
|
||||||
|
"strip-bom": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn/node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn/node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dedent": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"babel-plugin-macros": "^3.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"babel-plugin-macros": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/elementtree": {
|
||||||
|
"version": "0.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz",
|
||||||
|
"integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": "1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/endent": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/endent/-/endent-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-r8VyPX7XL8U01Xgnb1CjZ3XV+z90cXIJ9JPE/R9SEC9vpw2P6CfsRPJmp20DppC5N7ZAMCmjYkJIa744Iyg96w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dedent": "^0.7.0",
|
||||||
|
"fast-json-parse": "^1.0.3",
|
||||||
|
"objectorarray": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/endent/node_modules/dedent": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/execa": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.3",
|
||||||
|
"get-stream": "^6.0.0",
|
||||||
|
"human-signals": "^2.1.0",
|
||||||
|
"is-stream": "^2.0.0",
|
||||||
|
"merge-stream": "^2.0.0",
|
||||||
|
"npm-run-path": "^4.0.1",
|
||||||
|
"onetime": "^5.1.2",
|
||||||
|
"signal-exit": "^3.0.3",
|
||||||
|
"strip-final-newline": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/extsprintf": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": [
|
||||||
|
"node >=0.6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fast-glob": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodelib/fs.stat": "^2.0.2",
|
||||||
|
"@nodelib/fs.walk": "^1.2.3",
|
||||||
|
"glob-parent": "^5.1.2",
|
||||||
|
"merge2": "^1.3.0",
|
||||||
|
"micromatch": "^4.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-json-parse": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-json-parse/-/fast-json-parse-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fastq": {
|
||||||
|
"version": "1.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
|
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"reusify": "^1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fill-range": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"to-regex-range": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-stream": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob-parent": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"is-glob": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/human-signals": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-extglob": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-glob": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-extglob": "^2.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-number": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-path-inside": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-stream": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.zip": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/merge-stream": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/merge2": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mimic-fn": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nopt": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"abbrev": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"nopt": "bin/nopt.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/npm-run-path": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/objectorarray": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/objectorarray/-/objectorarray-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-eJJDYkhJFFbBBAxeh8xW+weHlkI28n2ZdQV/J/DNfWfSKlGEf2xcfAbZTv3riEXHAhL9SVOTs2pRmXiSTf78xg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/onetime": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-fn": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-finally": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pify": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/plist": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.8.8",
|
||||||
|
"base64-js": "^1.5.1",
|
||||||
|
"xmlbuilder": "^15.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/properties-parser": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/properties-parser/-/properties-parser-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-qvr2cSmoA0dln0MARAKwBzPkkXn7FqwX+RVVNpMdMJc7rt9mqO2cXwluxtux9fHrLhjnPFaQkS8BM0kFrTCnSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/q": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==",
|
||||||
|
"deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6.0",
|
||||||
|
"teleport": ">=0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/queue-microtask": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/read-chunk": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pify": "^4.0.1",
|
||||||
|
"with-open-file": "^0.1.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reusify": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"iojs": ">=1.0.0",
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/run-parallel": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"queue-microtask": "^1.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/string-argv": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6.19"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-bom": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-final-newline": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/to-regex-range": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-number": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/untildify": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^3.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/which.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/with-open-file": {
|
||||||
|
"version": "0.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz",
|
||||||
|
"integrity": "sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-finally": "^1.0.0",
|
||||||
|
"p-try": "^2.1.0",
|
||||||
|
"pify": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "15.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
brice/package.json
Normal file
23
brice/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "org.apache.cordova.hellocordova",
|
||||||
|
"displayName": "Hello Cordova",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A sample Apache Cordova application that responds to the deviceready event.",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ecosystem:cordova"
|
||||||
|
],
|
||||||
|
"author": "Apache Cordova Team",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"cordova-android": "^14.0.1"
|
||||||
|
},
|
||||||
|
"cordova": {
|
||||||
|
"platforms": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
brice/www/cordova.js
vendored
Normal file
1
brice/www/cordova.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Browser stub for Cordova builds.
|
||||||
269
brice/www/css/clock-scene.css
Normal file
269
brice/www/css/clock-scene.css
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
background: #c89d70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-clock {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top center, rgba(255, 235, 200, 0.5), transparent 24%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(112, 68, 21, 0.18)),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
#d8b186 0,
|
||||||
|
#d8b186 2px,
|
||||||
|
#c99d71 2px,
|
||||||
|
#c99d71 5px,
|
||||||
|
#bc8f65 5px,
|
||||||
|
#bc8f65 7px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-clock::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 39%, rgba(255, 217, 122, 0.75), transparent 8%),
|
||||||
|
radial-gradient(circle at 50% 47%, rgba(255, 221, 143, 0.42), transparent 18%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-shell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 4vh 5vw 6vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 5vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-topbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.8vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-board {
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 12px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: clamp(18px, 3vw, 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-zone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2.4vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-badge {
|
||||||
|
min-width: 190px;
|
||||||
|
padding: 0.45em 1.4em;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(110, 72, 34, 0.22);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 246, 231, 0.96), rgba(232, 207, 171, 0.92));
|
||||||
|
color: #352112;
|
||||||
|
text-align: center;
|
||||||
|
font-family: "Cinzel", serif;
|
||||||
|
font-size: clamp(1.6rem, 2vw, 2.4rem);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 14px rgba(80, 43, 8, 0.15),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-badge-dark {
|
||||||
|
border-color: rgba(42, 24, 14, 0.5);
|
||||||
|
background: linear-gradient(180deg, rgba(63, 39, 22, 0.96), rgba(37, 22, 13, 0.98));
|
||||||
|
color: #f6ead7;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 18px rgba(37, 16, 7, 0.28),
|
||||||
|
inset 0 1px 0 rgba(255, 235, 214, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-panel {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 460px);
|
||||||
|
min-height: clamp(280px, 48vh, 420px);
|
||||||
|
border-radius: 38px;
|
||||||
|
padding: clamp(28px, 4vh, 42px) clamp(24px, 3vw, 34px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2.4vh;
|
||||||
|
box-shadow:
|
||||||
|
0 20px 30px rgba(74, 45, 20, 0.18),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-panel-white {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 246, 228, 0.98), rgba(241, 215, 171, 0.98));
|
||||||
|
border: 2px solid rgba(255, 211, 94, 0.75);
|
||||||
|
color: #2c1d14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-panel-black {
|
||||||
|
background: linear-gradient(180deg, rgba(77, 53, 37, 0.96), rgba(43, 29, 21, 0.98));
|
||||||
|
border: 2px solid rgba(46, 28, 20, 0.78);
|
||||||
|
color: #f0e1cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-light {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-divider {
|
||||||
|
width: 100%;
|
||||||
|
height: min(48vh, 420px);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, rgba(183, 131, 68, 0), rgba(183, 131, 68, 0.9), rgba(183, 131, 68, 0));
|
||||||
|
box-shadow: 0 0 22px rgba(255, 211, 119, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-clock .button {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
top: auto;
|
||||||
|
right: auto;
|
||||||
|
border-radius: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
background: transparent;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-clock #white_button,
|
||||||
|
.scene-clock #black_button {
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-clock .click {
|
||||||
|
transform: scale(0.985);
|
||||||
|
filter: brightness(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-clock .TextClock {
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-family: "Cormorant Garamond", serif;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-clock #TimeWhite,
|
||||||
|
.scene-clock #TimeBlack,
|
||||||
|
.scene-clock #BlockTime,
|
||||||
|
.scene-clock #MoveLeftWhite,
|
||||||
|
.scene-clock #MoveLeftBlack,
|
||||||
|
.scene-clock #BlockType {
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-status {
|
||||||
|
color: #2f1d13;
|
||||||
|
font-size: clamp(2rem, 2.6vw, 3.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-mode {
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 0.28em 1.2em;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(112, 76, 41, 0.3);
|
||||||
|
background: linear-gradient(180deg, rgba(251, 242, 228, 0.98), rgba(232, 208, 178, 0.94));
|
||||||
|
color: #342012;
|
||||||
|
font-size: clamp(1.8rem, 2.2vw, 3rem);
|
||||||
|
box-shadow:
|
||||||
|
0 5px 16px rgba(111, 70, 28, 0.12),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-time {
|
||||||
|
font-size: clamp(4.8rem, 9vw, 8.4rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-moves {
|
||||||
|
max-width: 82%;
|
||||||
|
font-size: clamp(2rem, 3.2vw, 3.6rem);
|
||||||
|
line-height: 1.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-left::before {
|
||||||
|
opacity: 1;
|
||||||
|
background: radial-gradient(circle at 24% 58%, rgba(255, 214, 115, 0.35), transparent 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-right::before {
|
||||||
|
opacity: 1;
|
||||||
|
background: radial-gradient(circle at 76% 58%, rgba(255, 196, 92, 0.22), transparent 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-left #white_button {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(255, 220, 113, 0.8),
|
||||||
|
0 0 38px rgba(255, 205, 95, 0.55),
|
||||||
|
0 0 70px rgba(255, 202, 93, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-right #black_button {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(255, 196, 102, 0.72),
|
||||||
|
0 0 34px rgba(255, 179, 82, 0.3),
|
||||||
|
0 0 62px rgba(255, 179, 82, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.clock-shell {
|
||||||
|
padding: 3vh 4vw 5vh;
|
||||||
|
gap: 3vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-divider {
|
||||||
|
width: 72%;
|
||||||
|
height: 8px;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-panel {
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
230
brice/www/css/cube-scene.css
Normal file
230
brice/www/css/cube-scene.css
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
.scene-cube {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 18%, rgba(255, 236, 196, 0.28), transparent 26%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(41, 28, 18, 0.16)),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
#d3ae84 0,
|
||||||
|
#d3ae84 2px,
|
||||||
|
#c39a70 2px,
|
||||||
|
#c39a70 5px,
|
||||||
|
#b78760 5px,
|
||||||
|
#b78760 7px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-shell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 4vh 5vw 6vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-topbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.4vh;
|
||||||
|
max-width: 720px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-title {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
color: #2f1d13;
|
||||||
|
font-family: "Cinzel", serif;
|
||||||
|
font-size: clamp(2.1rem, 2.6vw, 3.6rem);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#BlockTypeTimer {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 0.3em 1.2em;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(105, 70, 35, 0.28);
|
||||||
|
background: linear-gradient(180deg, rgba(251, 242, 228, 0.97), rgba(230, 205, 174, 0.92));
|
||||||
|
color: #372315;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow:
|
||||||
|
0 6px 18px rgba(93, 57, 25, 0.14),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(60, 37, 24, 0.86);
|
||||||
|
font-family: "Cormorant Garamond", serif;
|
||||||
|
font-size: clamp(1.3rem, 1.7vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-board {
|
||||||
|
width: min(1120px, 100%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 14px minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
gap: clamp(18px, 3vw, 42px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-lane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2.2vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-badge {
|
||||||
|
min-width: 190px;
|
||||||
|
padding: 0.45em 1.4em;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(110, 72, 34, 0.22);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 246, 231, 0.96), rgba(232, 207, 171, 0.92));
|
||||||
|
color: #352112;
|
||||||
|
text-align: center;
|
||||||
|
font-family: "Cinzel", serif;
|
||||||
|
font-size: clamp(1.6rem, 2vw, 2.4rem);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 14px rgba(80, 43, 8, 0.15),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-badge-dark {
|
||||||
|
border-color: rgba(42, 24, 14, 0.5);
|
||||||
|
background: linear-gradient(180deg, rgba(63, 39, 22, 0.96), rgba(37, 22, 13, 0.98));
|
||||||
|
color: #f6ead7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-divider {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, rgba(183, 131, 68, 0), rgba(183, 131, 68, 0.9), rgba(183, 131, 68, 0));
|
||||||
|
box-shadow: 0 0 22px rgba(255, 211, 119, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.TimerCube {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 470px);
|
||||||
|
min-height: clamp(320px, 52vh, 470px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: clamp(24px, 4vh, 38px);
|
||||||
|
border-radius: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2.6vh;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-pad {
|
||||||
|
background: linear-gradient(180deg, rgba(248, 233, 207, 0.98), rgba(223, 190, 149, 0.98));
|
||||||
|
border: 2px solid rgba(235, 202, 120, 0.72);
|
||||||
|
color: #2a1b12;
|
||||||
|
box-shadow:
|
||||||
|
0 18px 28px rgba(88, 52, 21, 0.16),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-pad-dark {
|
||||||
|
background: linear-gradient(180deg, rgba(86, 58, 39, 0.98), rgba(53, 35, 24, 0.98));
|
||||||
|
border: 2px solid rgba(74, 47, 30, 0.96);
|
||||||
|
color: #f3e4d2;
|
||||||
|
box-shadow:
|
||||||
|
0 18px 30px rgba(29, 18, 11, 0.3),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.12) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.12) 1px, transparent 1px);
|
||||||
|
background-size: 33.333% 33.333%;
|
||||||
|
opacity: 0.15;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-pad-dark .cube-grid {
|
||||||
|
opacity: 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.TimerText {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
z-index: 1;
|
||||||
|
font-family: "Cormorant Garamond", serif;
|
||||||
|
font-size: clamp(2rem, 3vw, 3.5rem);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#TextWhite,
|
||||||
|
#TextBlack {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-hint {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Cormorant Garamond", serif;
|
||||||
|
font-size: clamp(1.3rem, 1.8vw, 2rem);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
opacity: 0.84;
|
||||||
|
}
|
||||||
|
|
||||||
|
.TimerCube.RedClick {
|
||||||
|
background: linear-gradient(180deg, rgba(237, 92, 81, 0.98), rgba(148, 26, 23, 0.98));
|
||||||
|
border-color: rgba(122, 12, 12, 0.85);
|
||||||
|
color: #fff1ec;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(255, 133, 116, 0.32),
|
||||||
|
0 22px 34px rgba(111, 21, 16, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.TimerCube.GreenClick {
|
||||||
|
background: linear-gradient(180deg, rgba(124, 223, 87, 0.98), rgba(38, 121, 40, 0.98));
|
||||||
|
border-color: rgba(39, 96, 41, 0.88);
|
||||||
|
color: #effde9;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(151, 255, 134, 0.28),
|
||||||
|
0 22px 34px rgba(25, 84, 28, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.TimerCube:active {
|
||||||
|
transform: scale(0.988);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.cube-shell {
|
||||||
|
padding: 3vh 4vw 5vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-divider {
|
||||||
|
width: 72%;
|
||||||
|
min-height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.TimerCube {
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
brice/www/css/style.css
Normal file
94
brice/www/css/style.css
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.scene{
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: linear-gradient(to right, #583305,#8A5009 );
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.button{
|
||||||
|
position: absolute;
|
||||||
|
width: 50%;
|
||||||
|
height: 50vh;
|
||||||
|
top: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#black_button{
|
||||||
|
right: 0%;
|
||||||
|
background: linear-gradient(to top, #A55B00, #583305);
|
||||||
|
}
|
||||||
|
|
||||||
|
#white_button{
|
||||||
|
right: 50%;
|
||||||
|
background: linear-gradient(to top , #E9BB82, #583305);
|
||||||
|
}
|
||||||
|
.click{
|
||||||
|
filter:brightness(0.9);
|
||||||
|
}
|
||||||
|
.TextClock{
|
||||||
|
position: absolute;
|
||||||
|
font-family: "Dancing Script", cursive;
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
font-weight: 0;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 3vw;
|
||||||
|
}
|
||||||
|
#TimeBlack{
|
||||||
|
left: 70%;
|
||||||
|
top: 35%;
|
||||||
|
}
|
||||||
|
#TimeWhite{
|
||||||
|
top: 35%;
|
||||||
|
left: 20%;
|
||||||
|
}
|
||||||
|
#BlockTime{
|
||||||
|
top:5%;
|
||||||
|
left: 35%;
|
||||||
|
}
|
||||||
|
#MoveLeftWhite{
|
||||||
|
left: 13%;
|
||||||
|
top: 25%
|
||||||
|
}
|
||||||
|
#MoveLeftBlack{
|
||||||
|
left: 63%;
|
||||||
|
top: 25%;
|
||||||
|
}
|
||||||
|
#BlockType{
|
||||||
|
top:15%;
|
||||||
|
left: 48%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease, left 1s ease, right 1s ease;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at center,
|
||||||
|
rgba(255, 255, 0, 0.6),
|
||||||
|
transparent 50%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow à gauche */
|
||||||
|
.glow-left::before {
|
||||||
|
left: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow à droite */
|
||||||
|
.glow-right::before {
|
||||||
|
right: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
BIN
brice/www/img/logo.png
Normal file
BIN
brice/www/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
91
brice/www/index.html
Normal file
91
brice/www/index.html
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<script src="js/scene.js"></script>
|
||||||
|
<script src="cordova.js"></script>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="css/clock-scene.css">
|
||||||
|
<link rel="stylesheet" href="css/cube-scene.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Cinzel:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
|
||||||
|
<div id="sceneClock" class="scene scene-clock">
|
||||||
|
<div class="clock-shell">
|
||||||
|
<div class="clock-topbar">
|
||||||
|
<h1 id="BlockTime" class="TextClock clock-status">Temps restant Block : 3:00</h1>
|
||||||
|
<h1 id="BlockType" class="TextClock clock-mode">Block -</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clock-board">
|
||||||
|
<section class="player-zone player-zone-white">
|
||||||
|
<div class="player-badge">BLANC</div>
|
||||||
|
<div class="clock-panel clock-panel-white">
|
||||||
|
<div class="button" id="white_button" aria-label="White clock button"></div>
|
||||||
|
<div class="panel-light"></div>
|
||||||
|
<h1 id="TimeWhite" class="TextClock clock-time">10:00</h1>
|
||||||
|
<h1 id="MoveLeftWhite" class="TextClock clock-moves">Coup restant Blanc : 8</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="clock-divider"></div>
|
||||||
|
|
||||||
|
<section class="player-zone player-zone-black">
|
||||||
|
<div class="player-badge player-badge-dark">NOIR</div>
|
||||||
|
<div class="clock-panel clock-panel-black">
|
||||||
|
<div class="button" id="black_button" aria-label="Black clock button"></div>
|
||||||
|
<div class="panel-light"></div>
|
||||||
|
<h1 id="TimeBlack" class="TextClock clock-time">10:00</h1>
|
||||||
|
<h1 id="MoveLeftBlack" class="TextClock clock-moves">Coup restant Noir : 8</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scene scene-cube" id="sceneCube">
|
||||||
|
<div class="cube-shell">
|
||||||
|
<div class="cube-topbar">
|
||||||
|
<h1 class="TimerText cube-title">Phase Cube</h1>
|
||||||
|
<h1 class="TimerText" id="BlockTypeTimer">Block -</h1>
|
||||||
|
<p class="cube-subtitle">Maintiens 2 secondes pour lancer le timer, puis touche de nouveau quand le cube est termine.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cube-board">
|
||||||
|
<section class="cube-lane cube-lane-white">
|
||||||
|
<div class="cube-badge">BLANC</div>
|
||||||
|
<div class="TimerCube cube-pad" id="TimerWhite">
|
||||||
|
<div class="cube-grid"></div>
|
||||||
|
<h1 class="TimerText" id="TextWhite">Temps au cube Blanc : 0:00</h1>
|
||||||
|
<p class="cube-hint">Appui long pour demarrer</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="cube-divider"></div>
|
||||||
|
|
||||||
|
<section class="cube-lane cube-lane-black">
|
||||||
|
<div class="cube-badge cube-badge-dark">NOIR</div>
|
||||||
|
<div class="TimerCube cube-pad cube-pad-dark" id="TimerBlack">
|
||||||
|
<div class="cube-grid"></div>
|
||||||
|
<h1 class="TimerText" id="TextBlack">Temps au cube Noir : 0:00</h1>
|
||||||
|
<p class="cube-hint">Touchez quand le cube est fini</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/ClockFrontEnd.js"></script>
|
||||||
|
<script src="js/TimerFrontEnd.js"></script>
|
||||||
|
<script src="js/backend.js"></script>
|
||||||
|
<script src="js/appload.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
brice/www/js/ClockFrontEnd.js
Normal file
87
brice/www/js/ClockFrontEnd.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
let white = document.getElementById("white_button");
|
||||||
|
let black = document.getElementById("black_button");
|
||||||
|
let white_time = document.getElementById("TimeWhite")
|
||||||
|
let black_time = document.getElementById("TimeBlack")
|
||||||
|
let black_move_left = document.getElementById("MoveLeftBlack")
|
||||||
|
let white_move_left = document.getElementById("MoveLeftWhite")
|
||||||
|
let BlockType = document.getElementById("BlockType")
|
||||||
|
let BlockTime = document.getElementById("BlockTime")
|
||||||
|
let trais = true
|
||||||
|
|
||||||
|
|
||||||
|
function msToMinSec(ms) {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle_trais(){
|
||||||
|
trais = !trais
|
||||||
|
if (trais){
|
||||||
|
showGlowRight()
|
||||||
|
}else{
|
||||||
|
showGlowLeft()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function change_move_left_white(number){
|
||||||
|
white_move_left.innerText = `Coup restant Blanc : ${number}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function change_move_left_black(number){
|
||||||
|
black_move_left.innerText = `Coup restant Noir : ${number}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function change_time_block(Time){
|
||||||
|
BlockTime.innerText = `Temps restant Block : ${msToMinSec(Time)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_block_type(type){
|
||||||
|
// true = +
|
||||||
|
// false = -
|
||||||
|
if (type){
|
||||||
|
BlockType.innerText = "Block +"
|
||||||
|
}else{
|
||||||
|
BlockType.innerText = "Block -"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function change_time_white(Time){
|
||||||
|
white_time.innerText = msToMinSec(Time)
|
||||||
|
}
|
||||||
|
|
||||||
|
function change_time_black(Time){
|
||||||
|
black_time.innerText = msToMinSec(Time)
|
||||||
|
}
|
||||||
|
white.addEventListener("pointerdown", () => {
|
||||||
|
if (!trais) white.classList.add("click");
|
||||||
|
if (!has_start) start()
|
||||||
|
else white_touch()
|
||||||
|
});
|
||||||
|
white.addEventListener("pointerup", () => {
|
||||||
|
white.classList.remove("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
black.addEventListener("pointerdown", () => {
|
||||||
|
if (trais )black.classList.add("click");
|
||||||
|
black_touch()
|
||||||
|
});
|
||||||
|
black.addEventListener("pointerup", () => {
|
||||||
|
black.classList.remove("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
function showGlowLeft() {
|
||||||
|
document.body.classList.remove("glow-right");
|
||||||
|
document.body.classList.add("glow-left");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGlowRight() {
|
||||||
|
document.body.classList.remove("glow-left");
|
||||||
|
document.body.classList.add("glow-right");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideGlow() {
|
||||||
|
document.body.classList.remove("glow-left", "glow-right");
|
||||||
|
}
|
||||||
35
brice/www/js/TimerFrontEnd.js
Normal file
35
brice/www/js/TimerFrontEnd.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
let white_Timer = document.getElementById("TimerWhite")
|
||||||
|
let black_Timer = document.getElementById("TimerBlack")
|
||||||
|
let white_Time_Timer = document.getElementById("TextWhite")
|
||||||
|
let black_Time_Timer = document.getElementById("TextBlack")
|
||||||
|
let TimerBlockType = document.getElementById("BlockTypeTimer")
|
||||||
|
|
||||||
|
function Set_block_type_Timer(type){
|
||||||
|
if (type){
|
||||||
|
TimerBlockType.innerText = "Block +"
|
||||||
|
}else{
|
||||||
|
TimerBlockType.innerText = "Block -"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set_White_Time_Cube(Time){
|
||||||
|
white_Time_Timer.innerText = `Temps au cube Blanc : ${msToMinSec(Time)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set_Black_Time_Cube(Time){
|
||||||
|
black_Time_Timer.innerText = `Temps au cube Noir : ${msToMinSec(Time)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
white_Timer.addEventListener("pointerdown", () => {
|
||||||
|
white_timer_touch();
|
||||||
|
});
|
||||||
|
white_Timer.addEventListener("pointerup", () => {
|
||||||
|
white_timer_release();
|
||||||
|
});
|
||||||
|
|
||||||
|
black_Timer.addEventListener("pointerdown", () => {
|
||||||
|
black_timer_touch();
|
||||||
|
});
|
||||||
|
black_Timer.addEventListener("pointerup", () => {
|
||||||
|
black_timer_release();
|
||||||
|
});
|
||||||
3
brice/www/js/appload.js
Normal file
3
brice/www/js/appload.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
load_Clock_scene()
|
||||||
|
update()
|
||||||
239
brice/www/js/backend.js
Normal file
239
brice/www/js/backend.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
|
||||||
|
let White = {
|
||||||
|
moveLeft : 0,
|
||||||
|
Time : 0,
|
||||||
|
TimeStartMove : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let Black = {
|
||||||
|
moveLeft : 0,
|
||||||
|
Time : 0,
|
||||||
|
TimeStartMove : 0
|
||||||
|
}
|
||||||
|
let has_start = false
|
||||||
|
|
||||||
|
Block = {
|
||||||
|
Type : false,
|
||||||
|
Time : 0,
|
||||||
|
last_time : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer = {
|
||||||
|
White_Has_started : false,
|
||||||
|
Black_Has_started : false,
|
||||||
|
Black_time : 0,
|
||||||
|
White_time : 0,
|
||||||
|
White_start_time : 0,
|
||||||
|
Black_start_time : 0,
|
||||||
|
coldown_White : 0,
|
||||||
|
coldown_Black : 0,
|
||||||
|
coldown_Start_White : 0,
|
||||||
|
coldown_Start_Black : 0,
|
||||||
|
White_Has_Finish : false,
|
||||||
|
Black_Has_Finish : false
|
||||||
|
}
|
||||||
|
|
||||||
|
config = {
|
||||||
|
StartTimeWhite : 600000,
|
||||||
|
StartTimeBlack:600000,
|
||||||
|
StartMoveLeft : 8,
|
||||||
|
BlockTime : 180000,
|
||||||
|
last_block : true,
|
||||||
|
trais : false,
|
||||||
|
MaxIncrement : 120000
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function load_Clock_scene(){
|
||||||
|
has_start = false
|
||||||
|
hide_scene("Cube")
|
||||||
|
inizalize_clock()
|
||||||
|
show_scene("Clock")
|
||||||
|
inizalize_clock()
|
||||||
|
}
|
||||||
|
|
||||||
|
function inizalize_clock(){
|
||||||
|
trais = !config.trais
|
||||||
|
toggle_trais()
|
||||||
|
White.Time = config.StartTimeWhite
|
||||||
|
White.moveLeft = config.StartMoveLeft
|
||||||
|
Black.Time = config.StartTimeBlack
|
||||||
|
Black.moveLeft = config.StartMoveLeft
|
||||||
|
Block.Time = config.BlockTime
|
||||||
|
Block.Type = !config.last_block
|
||||||
|
|
||||||
|
change_move_left_black(Black.moveLeft)
|
||||||
|
change_move_left_white(White.moveLeft)
|
||||||
|
change_time_black(Black.Time)
|
||||||
|
change_time_white(White.Time)
|
||||||
|
change_time_block(Block.Time)
|
||||||
|
set_block_type(Block.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(){
|
||||||
|
White.TimeStartMove = Date.now()
|
||||||
|
Black.TimeStartMove = Date.now()
|
||||||
|
Block.last_time = Date.now()
|
||||||
|
has_start = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function load_cube_scene(){
|
||||||
|
has_start = false
|
||||||
|
|
||||||
|
config.StartTimeWhite = White.Time
|
||||||
|
config.StartTimeBlack = Black.Time
|
||||||
|
config.last_block = Block.Type
|
||||||
|
|
||||||
|
Timer = {
|
||||||
|
White_Has_started : false,
|
||||||
|
Black_Has_started : false,
|
||||||
|
Black_time : 0,
|
||||||
|
White_time : 0,
|
||||||
|
White_start_time : 0,
|
||||||
|
Black_start_time : 0,
|
||||||
|
coldown_White : 0,
|
||||||
|
coldown_Black : 0,
|
||||||
|
coldown_Start_White : 0,
|
||||||
|
coldown_Start_Black : 0,
|
||||||
|
White_Has_Finish : false,
|
||||||
|
Black_Has_Finish : false
|
||||||
|
}
|
||||||
|
Set_block_type_Timer(config.last_block)
|
||||||
|
Set_White_Time_Cube(0)
|
||||||
|
Set_Black_Time_Cube(0)
|
||||||
|
hide_scene("Clock")
|
||||||
|
hideGlow()
|
||||||
|
show_scene("Cube")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function black_timer_touch(){
|
||||||
|
if (!Timer.Black_Has_started){
|
||||||
|
Timer.coldown_Start_Black = Date.now()
|
||||||
|
black_Timer.classList.add("RedClick")
|
||||||
|
}else if(!Timer.Black_Has_Finish){
|
||||||
|
Timer.Black_Has_Finish = true
|
||||||
|
black_Timer.classList.add("GreenClick")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function black_timer_release(){
|
||||||
|
if (Timer.coldown_Black > 2000) {
|
||||||
|
Timer.Black_Has_started = true
|
||||||
|
Timer.Black_start_time = Date.now()
|
||||||
|
black_Timer.classList.remove("GreenClick")
|
||||||
|
}else{
|
||||||
|
black_Timer.classList.remove("RedClick")
|
||||||
|
Timer.coldown_Start_Black = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function white_timer_touch(){
|
||||||
|
if (!Timer.White_Has_started){
|
||||||
|
Timer.coldown_Start_White = Date.now()
|
||||||
|
white_Timer.classList.add("RedClick")
|
||||||
|
}else if(!Timer.White_Has_Finish){
|
||||||
|
Timer.White_Has_Finish = true
|
||||||
|
white_Timer.classList.add("GreenClick")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function white_timer_release(){
|
||||||
|
if (Timer.coldown_White > 2000) {
|
||||||
|
Timer.White_Has_started = true
|
||||||
|
Timer.White_start_time = Date.now()
|
||||||
|
white_Timer.classList.remove("GreenClick")
|
||||||
|
}else{
|
||||||
|
white_Timer.classList.remove("RedClick")
|
||||||
|
Timer.coldown_Start_White = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function white_touch(){
|
||||||
|
if (trais) return null
|
||||||
|
if (!has_start) return null
|
||||||
|
toggle_trais()
|
||||||
|
Black.TimeStartMove = Date.now()
|
||||||
|
White.moveLeft -= 1
|
||||||
|
change_move_left_white(White.moveLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
function black_touch(){
|
||||||
|
if (!trais) return null
|
||||||
|
if (!has_start) return null
|
||||||
|
Black.moveLeft -= 1
|
||||||
|
if (Black.moveLeft == 0){
|
||||||
|
load_cube_scene()
|
||||||
|
}else{
|
||||||
|
toggle_trais()
|
||||||
|
White.TimeStartMove = Date.now()
|
||||||
|
change_move_left_black(Black.moveLeft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function update() {
|
||||||
|
if (last_scene == "Clock" && has_start){
|
||||||
|
if (!trais) {// trais au blanc
|
||||||
|
White.Time -= (Date.now() - White.TimeStartMove)
|
||||||
|
White.TimeStartMove = Date.now()
|
||||||
|
}else{
|
||||||
|
Black.Time -= (Date.now() - Black.TimeStartMove)
|
||||||
|
Black.TimeStartMove = Date.now()
|
||||||
|
}
|
||||||
|
Block.Time -= (Date.now() - Block.last_time)
|
||||||
|
Block.last_time = Date.now()
|
||||||
|
if (Block.Time <= 0){
|
||||||
|
load_cube_scene()
|
||||||
|
}
|
||||||
|
|
||||||
|
change_time_white(White.Time)
|
||||||
|
change_time_black(Black.Time)
|
||||||
|
change_time_block(Block.Time)
|
||||||
|
}else if(last_scene == "Cube"){
|
||||||
|
if (!Timer.Black_Has_started){ // noire a pas commencer le cube
|
||||||
|
if (Timer.coldown_Start_Black != 0){ // coldown lancer
|
||||||
|
Timer.coldown_Black = Date.now() - Timer.coldown_Start_Black
|
||||||
|
if (Timer.coldown_Black > 2000){
|
||||||
|
black_Timer.classList.remove("RedClick")
|
||||||
|
black_Timer.classList.add("GreenClick")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}else if(!Timer.Black_Has_Finish){
|
||||||
|
|
||||||
|
Timer.Black_time = Date.now() - Timer.Black_start_time
|
||||||
|
Set_Black_Time_Cube(Timer.Black_time)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Timer.White_Has_started){ // blanc a pas commencer le cube
|
||||||
|
if (Timer.coldown_Start_White != 0){ // coldown lancer
|
||||||
|
Timer.coldown_White = Date.now() - Timer.coldown_Start_White
|
||||||
|
if (Timer.coldown_White > 2000){
|
||||||
|
white_Timer.classList.remove("RedClick")
|
||||||
|
white_Timer.classList.add("GreenClick")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}else if (!Timer.White_Has_Finish){
|
||||||
|
|
||||||
|
Timer.White_time = Date.now() - Timer.White_start_time
|
||||||
|
Set_White_Time_Cube(Timer.White_time)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (Timer.Black_Has_Finish && Timer.White_Has_Finish){
|
||||||
|
if (config.last_block){
|
||||||
|
config.StartTimeWhite += Timer.Black_time
|
||||||
|
config.StartTimeBlack += Timer.Black_time
|
||||||
|
}else{
|
||||||
|
config.StartTimeWhite -= Timer.White_time
|
||||||
|
config.StartTimeBlack -= Timer.Black_time
|
||||||
|
}
|
||||||
|
|
||||||
|
load_Clock_scene()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
}
|
||||||
13
brice/www/js/scene.js
Normal file
13
brice/www/js/scene.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
let last_scene = ""
|
||||||
|
|
||||||
|
function show_scene(name){
|
||||||
|
scene = document.getElementById("scene" + name);
|
||||||
|
console.log(scene)
|
||||||
|
last_scene = name
|
||||||
|
scene.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide_scene(name){
|
||||||
|
scene = document.getElementById("scene" + name);
|
||||||
|
scene.style.display = "none";
|
||||||
|
}
|
||||||
15
brice/www/manifest.json
Normal file
15
brice/www/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "Mon Timer",
|
||||||
|
"short_name": "ChessCubingClock",
|
||||||
|
"start_url": "index.html",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "img/logo.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -51,10 +51,8 @@
|
|||||||
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
|
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stylesheet = document.createElement("link");
|
const stylesheetHref = window.__CHESSCUBING_ASSET_URL__("styles.css");
|
||||||
stylesheet.rel = "stylesheet";
|
document.write(`<link rel="stylesheet" href="${stylesheetHref}" />`);
|
||||||
stylesheet.href = window.__CHESSCUBING_ASSET_URL__("styles.css");
|
|
||||||
document.head.append(stylesheet);
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -51,10 +51,8 @@
|
|||||||
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
|
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stylesheet = document.createElement("link");
|
const stylesheetHref = window.__CHESSCUBING_ASSET_URL__("styles.css");
|
||||||
stylesheet.rel = "stylesheet";
|
document.write(`<link rel="stylesheet" href="${stylesheetHref}" />`);
|
||||||
stylesheet.href = window.__CHESSCUBING_ASSET_URL__("styles.css");
|
|
||||||
document.head.append(stylesheet);
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
682
doc/api-utilisateurs.md
Normal file
682
doc/api-utilisateurs.md
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
# API utilisateurs pour applications externes
|
||||||
|
|
||||||
|
Ce document decrit les endpoints exposes par ChessCubing pour authentifier un utilisateur, recuperer son profil, rechercher d'autres joueurs, gerer les relations sociales, et administrer les comptes.
|
||||||
|
|
||||||
|
Les exemples ci-dessous utilisent `http://localhost:8080` comme URL locale. En production, remplacez cette base par l'URL de votre instance.
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
- API HTTP REST en JSON.
|
||||||
|
- Authentification par cookie de session, pas par bearer token applicatif.
|
||||||
|
- Cookie principal : `chesscubing.auth`
|
||||||
|
- Duree de vie du cookie : 30 jours, avec renouvellement glissant.
|
||||||
|
- Les dates sont retournees en UTC, au format ISO 8601.
|
||||||
|
- Les applications navigateur sur un autre domaine ne peuvent pas consommer cette API directement aujourd'hui : aucune politique CORS n'est configuree cote serveur. Pour une integration externe, privilegier un appel serveur a serveur, ou ajouter CORS dans l'application.
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
1. L'application externe appelle `POST /api/auth/login`.
|
||||||
|
2. Le serveur renvoie un cookie HTTP-only.
|
||||||
|
3. Les appels suivants doivent reutiliser ce cookie.
|
||||||
|
|
||||||
|
Exemple `curl` avec conservation du cookie :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -c cookies.txt \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"kiki","password":"motdepasse"}' \
|
||||||
|
http://localhost:8080/api/auth/login
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis reutilisation du cookie :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -b cookies.txt http://localhost:8080/api/users/me
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/auth/login
|
||||||
|
|
||||||
|
Authentifie un utilisateur Keycloak, cree si besoin son profil site, puis ouvre une session cookie.
|
||||||
|
|
||||||
|
Requete :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "kiki",
|
||||||
|
"password": "motdepasse"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reponse `200 OK` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isAuthenticated": true,
|
||||||
|
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
|
||||||
|
"username": "kiki",
|
||||||
|
"name": "Christophe JEANNEROT",
|
||||||
|
"email": "christophe@jeannerot.fr",
|
||||||
|
"roles": ["player", "admin"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Erreurs frequentes :
|
||||||
|
|
||||||
|
- `400 Bad Request` si `username` ou `password` sont absents.
|
||||||
|
- `401 Unauthorized` si les identifiants sont invalides.
|
||||||
|
- `5xx` ou autre code issu de Keycloak si l'authentification amont est indisponible.
|
||||||
|
|
||||||
|
### POST /api/auth/register
|
||||||
|
|
||||||
|
Cree un compte Keycloak, attribue le role `player`, connecte l'utilisateur et initialise son profil site.
|
||||||
|
|
||||||
|
Requete :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "kiki",
|
||||||
|
"email": "christophe@jeannerot.fr",
|
||||||
|
"password": "motdepasse123",
|
||||||
|
"confirmPassword": "motdepasse123",
|
||||||
|
"firstName": "Christophe",
|
||||||
|
"lastName": "JEANNEROT"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reponse `200 OK` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isAuthenticated": true,
|
||||||
|
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
|
||||||
|
"username": "kiki",
|
||||||
|
"name": "Christophe JEANNEROT",
|
||||||
|
"email": "christophe@jeannerot.fr",
|
||||||
|
"roles": ["player"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Erreurs frequentes :
|
||||||
|
|
||||||
|
- `400 Bad Request` si `username`, `email` ou `password` sont absents.
|
||||||
|
- `400 Bad Request` si `password` et `confirmPassword` different.
|
||||||
|
- `409 Conflict` si le nom d'utilisateur ou l'email existe deja.
|
||||||
|
|
||||||
|
### GET /api/auth/session
|
||||||
|
|
||||||
|
Retourne l'etat de session courant. Cette route est publique.
|
||||||
|
|
||||||
|
Si l'utilisateur est connecte :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isAuthenticated": true,
|
||||||
|
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
|
||||||
|
"username": "kiki",
|
||||||
|
"name": "Christophe JEANNEROT",
|
||||||
|
"email": "christophe@jeannerot.fr",
|
||||||
|
"roles": ["player", "admin"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Si l'utilisateur n'est pas connecte :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isAuthenticated": false,
|
||||||
|
"subject": null,
|
||||||
|
"username": null,
|
||||||
|
"name": null,
|
||||||
|
"email": null,
|
||||||
|
"roles": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/auth/logout
|
||||||
|
|
||||||
|
Ferme la session courante et vide le cookie d'authentification.
|
||||||
|
|
||||||
|
Reponse `200 OK` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isAuthenticated": false,
|
||||||
|
"subject": null,
|
||||||
|
"username": null,
|
||||||
|
"name": null,
|
||||||
|
"email": null,
|
||||||
|
"roles": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/auth/logout/browser
|
||||||
|
|
||||||
|
Equivalent navigateur de la deconnexion. Cette route supprime le cookie puis redirige vers `/index.html`.
|
||||||
|
|
||||||
|
## Profil du compte courant
|
||||||
|
|
||||||
|
Ces routes permettent a une application connectee de recuperer et modifier le profil site de l'utilisateur courant.
|
||||||
|
|
||||||
|
### GET /api/users/me
|
||||||
|
|
||||||
|
Necessite une session authentifiee.
|
||||||
|
|
||||||
|
Reponse `200 OK` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
|
||||||
|
"username": "kiki",
|
||||||
|
"email": "christophe@jeannerot.fr",
|
||||||
|
"displayName": "Christophe JEANNEROT",
|
||||||
|
"club": "ChessCubing Arena",
|
||||||
|
"city": "Vercel Villedieu le camp",
|
||||||
|
"preferredFormat": "Les deux",
|
||||||
|
"favoriteCube": "GAN 12",
|
||||||
|
"bio": "Joueur et organisateur.",
|
||||||
|
"createdUtc": "2026-04-14T18:04:55.0000000Z",
|
||||||
|
"updatedUtc": "2026-04-14T18:05:12.0000000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Comportement notable :
|
||||||
|
|
||||||
|
- Si le profil site n'existe pas encore, il est cree automatiquement.
|
||||||
|
- `401 Unauthorized` si aucun cookie valide n'est fourni.
|
||||||
|
|
||||||
|
### PUT /api/users/me
|
||||||
|
|
||||||
|
Met a jour uniquement le profil site de l'utilisateur courant.
|
||||||
|
|
||||||
|
Requete :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"displayName": "Christophe JEANNEROT",
|
||||||
|
"club": "ChessCubing Arena",
|
||||||
|
"city": "Vercel Villedieu le camp",
|
||||||
|
"preferredFormat": "Les deux",
|
||||||
|
"favoriteCube": "GAN 12",
|
||||||
|
"bio": "Joueur et organisateur."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reponse `200 OK` : meme structure que `GET /api/users/me`.
|
||||||
|
|
||||||
|
Contraintes de validation :
|
||||||
|
|
||||||
|
- `displayName`, `club`, `city`, `favoriteCube` : 120 caracteres max.
|
||||||
|
- `bio` : 1200 caracteres max.
|
||||||
|
- `preferredFormat` : `Twice`, `Time` ou `Les deux`.
|
||||||
|
|
||||||
|
Erreurs frequentes :
|
||||||
|
|
||||||
|
- `400 Bad Request` si la valeur de `preferredFormat` est invalide.
|
||||||
|
- `400 Bad Request` si une longueur maximale est depassee.
|
||||||
|
- `401 Unauthorized` sans session.
|
||||||
|
|
||||||
|
### GET /api/users/me/stats
|
||||||
|
|
||||||
|
Retourne les statistiques du compte courant et ses derniers matchs enregistres.
|
||||||
|
|
||||||
|
Reponse `200 OK` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
|
||||||
|
"currentElo": 1216,
|
||||||
|
"rankedGames": 1,
|
||||||
|
"casualGames": 2,
|
||||||
|
"wins": 2,
|
||||||
|
"losses": 1,
|
||||||
|
"stoppedGames": 0,
|
||||||
|
"whiteWins": 1,
|
||||||
|
"blackWins": 1,
|
||||||
|
"whiteLosses": 1,
|
||||||
|
"blackLosses": 0,
|
||||||
|
"totalMoves": 34,
|
||||||
|
"totalCubeRounds": 6,
|
||||||
|
"bestCubeTimeMs": 8420,
|
||||||
|
"averageCubeTimeMs": 11530,
|
||||||
|
"lastMatchUtc": "2026-04-15T11:00:00.0000000Z",
|
||||||
|
"recentMatches": [
|
||||||
|
{
|
||||||
|
"matchId": "c1f4e44f5f5f4fe4b8f2bf1f8a634f9e",
|
||||||
|
"completedUtc": "2026-04-15T11:00:00.0000000Z",
|
||||||
|
"result": "white",
|
||||||
|
"mode": "twice",
|
||||||
|
"preset": "fast",
|
||||||
|
"matchLabel": "Rencontre ChessCubing",
|
||||||
|
"playerColor": "white",
|
||||||
|
"playerName": "Christophe JEANNEROT",
|
||||||
|
"opponentName": "Alex Martin",
|
||||||
|
"opponentSubject": "sub-ami-1",
|
||||||
|
"isRanked": true,
|
||||||
|
"isWin": true,
|
||||||
|
"isLoss": false,
|
||||||
|
"playerMoves": 6,
|
||||||
|
"opponentMoves": 6,
|
||||||
|
"cubeRounds": 2,
|
||||||
|
"playerBestCubeTimeMs": 8420,
|
||||||
|
"playerAverageCubeTimeMs": 10120,
|
||||||
|
"eloBefore": 1200,
|
||||||
|
"eloAfter": 1216,
|
||||||
|
"eloDelta": 16
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/users/me/stats/matches
|
||||||
|
|
||||||
|
Enregistre la fin d'une partie pour les statistiques joueur.
|
||||||
|
|
||||||
|
Important :
|
||||||
|
|
||||||
|
- le compte connecte doit correspondre a l'un des deux joueurs identifies dans le payload
|
||||||
|
- l'Elo n'est mis a jour que si `whiteSubject` et `blackSubject` sont tous les deux renseignes, distincts, et que le resultat est `white` ou `black`
|
||||||
|
- si une meme partie est envoyee deux fois avec le meme `matchId`, l'enregistrement reste idempotent
|
||||||
|
|
||||||
|
Requete :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"matchId": "c1f4e44f5f5f4fe4b8f2bf1f8a634f9e",
|
||||||
|
"collaborationSessionId": "play-session-123",
|
||||||
|
"whiteSubject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
|
||||||
|
"whiteName": "Christophe JEANNEROT",
|
||||||
|
"blackSubject": "sub-ami-1",
|
||||||
|
"blackName": "Alex Martin",
|
||||||
|
"result": "white",
|
||||||
|
"mode": "twice",
|
||||||
|
"preset": "fast",
|
||||||
|
"matchLabel": "Rencontre ChessCubing",
|
||||||
|
"blockNumber": 2,
|
||||||
|
"whiteMoves": 6,
|
||||||
|
"blackMoves": 6,
|
||||||
|
"cubeRounds": [
|
||||||
|
{
|
||||||
|
"blockNumber": 1,
|
||||||
|
"number": 4,
|
||||||
|
"white": 8420,
|
||||||
|
"black": 11030
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reponse `200 OK` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recorded": true,
|
||||||
|
"isDuplicate": false,
|
||||||
|
"isRanked": true,
|
||||||
|
"whiteEloAfter": 1216,
|
||||||
|
"blackEloAfter": 1184
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recherche de joueurs et relations sociales
|
||||||
|
|
||||||
|
Ces routes necessitent toutes une session authentifiee.
|
||||||
|
|
||||||
|
### GET /api/social/overview
|
||||||
|
|
||||||
|
Retourne la vue sociale du compte courant :
|
||||||
|
|
||||||
|
- `friends`
|
||||||
|
- `receivedInvitations`
|
||||||
|
- `sentInvitations`
|
||||||
|
|
||||||
|
Reponse `200 OK` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"friends": [
|
||||||
|
{
|
||||||
|
"subject": "sub-ami-1",
|
||||||
|
"username": "alex",
|
||||||
|
"displayName": "Alex Martin",
|
||||||
|
"email": "alex@example.com",
|
||||||
|
"club": "Club A",
|
||||||
|
"city": "Paris",
|
||||||
|
"isOnline": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"receivedInvitations": [
|
||||||
|
{
|
||||||
|
"invitationId": 12,
|
||||||
|
"subject": "sub-ami-2",
|
||||||
|
"username": "lea",
|
||||||
|
"displayName": "Lea Durand",
|
||||||
|
"email": "lea@example.com",
|
||||||
|
"isOnline": false,
|
||||||
|
"createdUtc": "2026-04-15T08:15:00.0000000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sentInvitations": [
|
||||||
|
{
|
||||||
|
"invitationId": 18,
|
||||||
|
"subject": "sub-ami-3",
|
||||||
|
"username": "nina",
|
||||||
|
"displayName": "Nina Bernard",
|
||||||
|
"email": "nina@example.com",
|
||||||
|
"isOnline": true,
|
||||||
|
"createdUtc": "2026-04-15T08:18:00.0000000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/social/search?query={texte}
|
||||||
|
|
||||||
|
Recherche des joueurs connus du site par nom d'utilisateur, nom affiche, club, ville ou email selon l'index applicatif.
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -b cookies.txt \
|
||||||
|
"http://localhost:8080/api/social/search?query=chri"
|
||||||
|
```
|
||||||
|
|
||||||
|
Reponse `200 OK` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
|
||||||
|
"username": "kiki",
|
||||||
|
"displayName": "Christophe JEANNEROT",
|
||||||
|
"email": "christophe@jeannerot.fr",
|
||||||
|
"club": "ChessCubing Arena",
|
||||||
|
"city": "Vercel Villedieu le camp",
|
||||||
|
"isOnline": true,
|
||||||
|
"isFriend": false,
|
||||||
|
"hasSentInvitation": false,
|
||||||
|
"hasReceivedInvitation": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Comportement notable :
|
||||||
|
|
||||||
|
- Si `query` est vide ou blanche, la route renvoie `[]`.
|
||||||
|
- La recherche exige au moins 2 caracteres utiles.
|
||||||
|
- Le resultat est limite a 12 utilisateurs.
|
||||||
|
|
||||||
|
Erreurs frequentes :
|
||||||
|
|
||||||
|
- `400 Bad Request` si `query` contient moins de 2 caracteres.
|
||||||
|
- `401 Unauthorized` sans session.
|
||||||
|
|
||||||
|
### POST /api/social/invitations
|
||||||
|
|
||||||
|
Envoie une invitation d'ami.
|
||||||
|
|
||||||
|
Requete :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targetSubject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reponse `204 No Content`
|
||||||
|
|
||||||
|
Erreurs frequentes :
|
||||||
|
|
||||||
|
- `400 Bad Request` si on tente de s'inviter soi-meme.
|
||||||
|
- `400 Bad Request` si le joueur cible est introuvable.
|
||||||
|
- `400 Bad Request` si le joueur est deja ami.
|
||||||
|
- `400 Bad Request` si une invitation entre les deux comptes existe deja.
|
||||||
|
|
||||||
|
### POST /api/social/invitations/{invitationId}/accept
|
||||||
|
|
||||||
|
Accepte une invitation recue.
|
||||||
|
|
||||||
|
Reponse `204 No Content`
|
||||||
|
|
||||||
|
Erreur frequente :
|
||||||
|
|
||||||
|
- `400 Bad Request` si l'invitation est introuvable ou deja traitee.
|
||||||
|
|
||||||
|
### POST /api/social/invitations/{invitationId}/decline
|
||||||
|
|
||||||
|
Refuse une invitation recue.
|
||||||
|
|
||||||
|
Reponse `204 No Content`
|
||||||
|
|
||||||
|
Erreur frequente :
|
||||||
|
|
||||||
|
- `400 Bad Request` si l'invitation est introuvable ou deja traitee.
|
||||||
|
|
||||||
|
### DELETE /api/social/invitations/{invitationId}
|
||||||
|
|
||||||
|
Annule une invitation envoyee.
|
||||||
|
|
||||||
|
Reponse `204 No Content`
|
||||||
|
|
||||||
|
Erreur frequente :
|
||||||
|
|
||||||
|
- `400 Bad Request` si l'invitation est introuvable ou deja retiree.
|
||||||
|
|
||||||
|
### DELETE /api/social/friends/{friendSubject}
|
||||||
|
|
||||||
|
Supprime une relation d'amitie.
|
||||||
|
|
||||||
|
Reponse `204 No Content`
|
||||||
|
|
||||||
|
## Administration des utilisateurs
|
||||||
|
|
||||||
|
Ces routes sont reservees aux sessions portant le role `admin`.
|
||||||
|
|
||||||
|
Sans ce role :
|
||||||
|
|
||||||
|
- `401 Unauthorized` si l'utilisateur n'est pas connecte.
|
||||||
|
- `403 Forbidden` si l'utilisateur est connecte mais n'a pas le role `admin`.
|
||||||
|
|
||||||
|
### GET /api/admin/users
|
||||||
|
|
||||||
|
Retourne toute la liste des utilisateurs connus, en fusionnant :
|
||||||
|
|
||||||
|
- les comptes Keycloak
|
||||||
|
- les profils site MySQL
|
||||||
|
|
||||||
|
La reponse est triee par activite recente, puis par nom d'utilisateur.
|
||||||
|
|
||||||
|
Reponse `200 OK` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
|
||||||
|
"username": "kiki",
|
||||||
|
"email": "christophe@jeannerot.fr",
|
||||||
|
"identityDisplayName": "Christophe JEANNEROT",
|
||||||
|
"siteDisplayName": "Christophe JEANNEROT",
|
||||||
|
"isEnabled": true,
|
||||||
|
"isEmailVerified": false,
|
||||||
|
"hasSiteProfile": true,
|
||||||
|
"club": "ChessCubing Arena",
|
||||||
|
"city": "Vercel Villedieu le camp",
|
||||||
|
"preferredFormat": "Les deux",
|
||||||
|
"accountCreatedUtc": "2026-04-14T17:44:00.0000000Z",
|
||||||
|
"siteProfileUpdatedUtc": "2026-04-14T18:05:00.0000000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/admin/users/{subject}
|
||||||
|
|
||||||
|
Retourne le detail complet d'un utilisateur.
|
||||||
|
|
||||||
|
Reponse `200 OK` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subject": "2f7d0f1d-3ef6-4b5f-aab5-4cf6b61c0a28",
|
||||||
|
"username": "kiki",
|
||||||
|
"email": "christophe@jeannerot.fr",
|
||||||
|
"firstName": "Christophe",
|
||||||
|
"lastName": "JEANNEROT",
|
||||||
|
"identityDisplayName": "Christophe JEANNEROT",
|
||||||
|
"isEnabled": true,
|
||||||
|
"isEmailVerified": false,
|
||||||
|
"accountCreatedUtc": "2026-04-14T17:44:00.0000000Z",
|
||||||
|
"hasSiteProfile": true,
|
||||||
|
"displayName": "Christophe JEANNEROT",
|
||||||
|
"club": "ChessCubing Arena",
|
||||||
|
"city": "Vercel Villedieu le camp",
|
||||||
|
"preferredFormat": "Les deux",
|
||||||
|
"favoriteCube": "GAN 12",
|
||||||
|
"bio": "Joueur et organisateur.",
|
||||||
|
"siteProfileCreatedUtc": "2026-04-14T18:04:00.0000000Z",
|
||||||
|
"siteProfileUpdatedUtc": "2026-04-14T18:05:00.0000000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Erreurs frequentes :
|
||||||
|
|
||||||
|
- `404 Not Found` si l'utilisateur n'existe pas dans Keycloak.
|
||||||
|
|
||||||
|
### POST /api/admin/users
|
||||||
|
|
||||||
|
Cree un utilisateur, son mot de passe, puis son profil site.
|
||||||
|
|
||||||
|
Requete :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "nouveau",
|
||||||
|
"email": "nouveau@example.com",
|
||||||
|
"password": "motdepasse123",
|
||||||
|
"confirmPassword": "motdepasse123",
|
||||||
|
"firstName": "Nouveau",
|
||||||
|
"lastName": "Joueur",
|
||||||
|
"isEnabled": true,
|
||||||
|
"isEmailVerified": false,
|
||||||
|
"displayName": "Nouveau Joueur",
|
||||||
|
"club": "Club A",
|
||||||
|
"city": "Paris",
|
||||||
|
"preferredFormat": "Time",
|
||||||
|
"favoriteCube": "Moyu RS3M",
|
||||||
|
"bio": "Nouveau profil"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reponse `201 Created` :
|
||||||
|
|
||||||
|
- En-tete `Location: /api/admin/users/{subject}`
|
||||||
|
- Corps : meme structure que `GET /api/admin/users/{subject}`
|
||||||
|
|
||||||
|
Contraintes de validation :
|
||||||
|
|
||||||
|
- `username` obligatoire, max 120 caracteres.
|
||||||
|
- `email` facultatif mais valide si fourni, max 255 caracteres.
|
||||||
|
- `password` obligatoire, minimum 8 caracteres.
|
||||||
|
- `confirmPassword` doit correspondre a `password`.
|
||||||
|
- `firstName`, `lastName`, `displayName`, `club`, `city`, `favoriteCube` : 120 caracteres max.
|
||||||
|
- `bio` : 1200 caracteres max.
|
||||||
|
- `preferredFormat` : `Twice`, `Time` ou `Les deux`.
|
||||||
|
|
||||||
|
Erreurs frequentes :
|
||||||
|
|
||||||
|
- `400 Bad Request` sur validation metier.
|
||||||
|
- `409 Conflict` si le nom d'utilisateur ou l'email existe deja.
|
||||||
|
|
||||||
|
### PUT /api/admin/users/{subject}
|
||||||
|
|
||||||
|
Met a jour le compte Keycloak et le profil site.
|
||||||
|
|
||||||
|
Important :
|
||||||
|
|
||||||
|
- Le `username` n'est pas modifiable via cette route.
|
||||||
|
|
||||||
|
Requete :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "christophe@jeannerot.fr",
|
||||||
|
"firstName": "Christophe",
|
||||||
|
"lastName": "JEANNEROT",
|
||||||
|
"isEnabled": true,
|
||||||
|
"isEmailVerified": true,
|
||||||
|
"displayName": "Christophe JEANNEROT",
|
||||||
|
"club": "ChessCubing Arena",
|
||||||
|
"city": "Vercel Villedieu le camp",
|
||||||
|
"preferredFormat": "Les deux",
|
||||||
|
"favoriteCube": "GAN 12",
|
||||||
|
"bio": "Joueur et organisateur."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reponse `200 OK` : meme structure que `GET /api/admin/users/{subject}`.
|
||||||
|
|
||||||
|
Erreurs frequentes :
|
||||||
|
|
||||||
|
- `400 Bad Request` sur validation metier.
|
||||||
|
- `404 Not Found` si l'utilisateur est introuvable.
|
||||||
|
- `409 Conflict` si l'email est deja utilise par un autre compte.
|
||||||
|
|
||||||
|
### DELETE /api/admin/users/{subject}
|
||||||
|
|
||||||
|
Supprime :
|
||||||
|
|
||||||
|
- le compte Keycloak
|
||||||
|
- le profil site
|
||||||
|
- les relations sociales et invitations associees
|
||||||
|
|
||||||
|
Reponse `204 No Content`
|
||||||
|
|
||||||
|
Erreur frequente :
|
||||||
|
|
||||||
|
- `404 Not Found` si l'utilisateur est introuvable.
|
||||||
|
|
||||||
|
## Temps reel via SignalR
|
||||||
|
|
||||||
|
Le hub `/hubs/social` est protege par authentification et permet :
|
||||||
|
|
||||||
|
- la presence en ligne
|
||||||
|
- les invitations de partie entre amis
|
||||||
|
- la synchronisation d'une partie entre plusieurs devices
|
||||||
|
|
||||||
|
Les principaux messages/calls utilises sont :
|
||||||
|
|
||||||
|
- appel client -> serveur `RequestPresenceSnapshot`
|
||||||
|
- appel client -> serveur `SendPlayInvite(recipientSubject, recipientColor)`
|
||||||
|
- appel client -> serveur `RespondToPlayInvite(inviteId, accept)`
|
||||||
|
- appel client -> serveur `CancelPlayInvite(inviteId)`
|
||||||
|
- appel client -> serveur `JoinPlaySession(sessionId)`
|
||||||
|
- appel client -> serveur `LeavePlaySession(sessionId)`
|
||||||
|
- appel client -> serveur `PublishMatchState(sessionId, matchJson, route)`
|
||||||
|
- evenement serveur -> client `PresenceSnapshot`
|
||||||
|
- evenement serveur -> client `PresenceChanged`
|
||||||
|
- evenement serveur -> client `PlayInviteUpdated`
|
||||||
|
- evenement serveur -> client `PlayInviteAccepted`
|
||||||
|
- evenement serveur -> client `PlayInviteClosed`
|
||||||
|
- evenement serveur -> client `CollaborativeMatchStateUpdated`
|
||||||
|
|
||||||
|
Pour un integrateur qui veut seulement recuperer les informations utilisateur, les endpoints REST de ce document sont generalement suffisants. Le hub devient utile des qu'il faut suivre la presence, les invitations ou l'etat partage d'une partie en temps reel.
|
||||||
|
|
||||||
|
## Codes de reponse a prevoir
|
||||||
|
|
||||||
|
- `200 OK` : lecture ou action aboutie avec corps JSON.
|
||||||
|
- `201 Created` : creation d'utilisateur admin.
|
||||||
|
- `204 No Content` : action aboutie sans corps.
|
||||||
|
- `400 Bad Request` : erreur de validation ou session incomplete.
|
||||||
|
- `401 Unauthorized` : pas de session valide.
|
||||||
|
- `403 Forbidden` : session valide mais role insuffisant.
|
||||||
|
- `404 Not Found` : ressource introuvable, principalement cote admin.
|
||||||
|
- `409 Conflict` : doublon sur `username` ou `email`.
|
||||||
|
|
||||||
|
## Recommandations d'integration
|
||||||
|
|
||||||
|
- Stocker et rejouer le cookie de session si l'application externe fonctionne en backend.
|
||||||
|
- Eviter les appels directs depuis un frontend sur un autre domaine tant que CORS n'est pas configure.
|
||||||
|
- Utiliser `GET /api/auth/session` pour verifier rapidement l'etat de connexion et les roles.
|
||||||
|
- Utiliser `GET /api/users/me` pour recuperer le profil courant.
|
||||||
|
- Utiliser `GET /api/social/search` si l'objectif est de rechercher des joueurs connectes au site.
|
||||||
|
- Utiliser `GET /api/admin/users` uniquement pour une application d'administration portant le role `admin`.
|
||||||
@@ -1,7 +1,118 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
container_name: chesscubing-web
|
container_name: chesscubing-web
|
||||||
|
depends_on:
|
||||||
|
auth:
|
||||||
|
condition: service_started
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "${WEB_PORT:-8080}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
auth:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.auth
|
||||||
|
container_name: chesscubing-auth
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_URLS: http://+:8080
|
||||||
|
KEYCLOAK_BASE_URL: http://keycloak:8080/auth
|
||||||
|
KEYCLOAK_REALM: chesscubing
|
||||||
|
KEYCLOAK_CLIENT_ID: chesscubing-web
|
||||||
|
KEYCLOAK_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
||||||
|
SITE_DB_HOST: mysql
|
||||||
|
SITE_DB_PORT: 3306
|
||||||
|
SITE_DB_NAME: ${SITE_DB_NAME:-chesscubing_site}
|
||||||
|
SITE_DB_USER: ${SITE_DB_USER:-chesscubing}
|
||||||
|
SITE_DB_PASSWORD: ${SITE_DB_PASSWORD:-chesscubing}
|
||||||
|
depends_on:
|
||||||
|
keycloak-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:26.1.2
|
||||||
|
container_name: chesscubing-keycloak
|
||||||
|
command: ["start-dev", "--import-realm"]
|
||||||
|
environment:
|
||||||
|
KC_DB: postgres
|
||||||
|
KC_DB_URL_HOST: postgres
|
||||||
|
KC_DB_URL_PORT: 5432
|
||||||
|
KC_DB_URL_DATABASE: ${KEYCLOAK_DB_NAME:-keycloak}
|
||||||
|
KC_DB_USERNAME: ${KEYCLOAK_DB_USER:-keycloak}
|
||||||
|
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak}
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
||||||
|
KC_PROXY_HEADERS: xforwarded
|
||||||
|
KC_HOSTNAME: "${PUBLIC_BASE_URL:-http://localhost:8080}/auth"
|
||||||
|
KC_HTTP_RELATIVE_PATH: /auth
|
||||||
|
KC_HOSTNAME_STRICT: "false"
|
||||||
|
volumes:
|
||||||
|
- ./keycloak/realm:/opt/keycloak/data/import:ro
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
keycloak-init:
|
||||||
|
image: quay.io/keycloak/keycloak:26.1.2
|
||||||
|
container_name: chesscubing-keycloak-init
|
||||||
|
entrypoint: ["/bin/sh", "/opt/keycloak/data/scripts/init-config.sh"]
|
||||||
|
environment:
|
||||||
|
KEYCLOAK_URL: http://keycloak:8080/auth
|
||||||
|
KEYCLOAK_REALM: chesscubing
|
||||||
|
KEYCLOAK_CLIENT_ID: chesscubing-web
|
||||||
|
KEYCLOAK_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
||||||
|
volumes:
|
||||||
|
- ./keycloak/scripts:/opt/keycloak/data/scripts:ro
|
||||||
|
depends_on:
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: chesscubing-keycloak-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${KEYCLOAK_DB_NAME:-keycloak}
|
||||||
|
POSTGRES_USER: ${KEYCLOAK_DB_USER:-keycloak}
|
||||||
|
POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak}
|
||||||
|
volumes:
|
||||||
|
- keycloak-postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${KEYCLOAK_DB_USER:-keycloak} -d ${KEYCLOAK_DB_NAME:-keycloak}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: chesscubing-site-db
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: ${SITE_DB_NAME:-chesscubing_site}
|
||||||
|
MYSQL_USER: ${SITE_DB_USER:-chesscubing}
|
||||||
|
MYSQL_PASSWORD: ${SITE_DB_PASSWORD:-chesscubing}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${SITE_DB_ROOT_PASSWORD:-change-me}
|
||||||
|
volumes:
|
||||||
|
- site-mysql-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 8
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
keycloak-postgres:
|
||||||
|
site-mysql-data:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
<a class="button primary" href="application.html">Ouvrir l'application</a>
|
<a class="button primary" href="application.html">Ouvrir l'application</a>
|
||||||
<a class="button secondary" href="reglement.html">Lire le règlement</a>
|
<a class="button secondary" href="reglement.html">Lire le règlement</a>
|
||||||
<a class="button ghost" href="/ethan/">Ouvrir l'appli d'Ethan</a>
|
<a class="button ghost" href="/ethan/">Ouvrir l'appli d'Ethan</a>
|
||||||
|
<a class="button ghost" href="/brice/">Ouvrir l'appli de Brice</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ Variables d'environnement reconnues :
|
|||||||
CHESSCUBING_GIT_BRANCH
|
CHESSCUBING_GIT_BRANCH
|
||||||
CHESSCUBING_ETHAN_REPO_URL
|
CHESSCUBING_ETHAN_REPO_URL
|
||||||
CHESSCUBING_ETHAN_GIT_BRANCH
|
CHESSCUBING_ETHAN_GIT_BRANCH
|
||||||
|
CHESSCUBING_BRICE_REPO_URL
|
||||||
|
CHESSCUBING_BRICE_GIT_BRANCH
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +113,8 @@ ROOTFS_STORAGE="${CHESSCUBING_ROOTFS_STORAGE:-}"
|
|||||||
LXC_PASSWORD="${CHESSCUBING_LXC_PASSWORD:-}"
|
LXC_PASSWORD="${CHESSCUBING_LXC_PASSWORD:-}"
|
||||||
ETHAN_REPO_URL="${CHESSCUBING_ETHAN_REPO_URL:-https://git.jeannerot.fr/Mineloulou/Chesscubing.git}"
|
ETHAN_REPO_URL="${CHESSCUBING_ETHAN_REPO_URL:-https://git.jeannerot.fr/Mineloulou/Chesscubing.git}"
|
||||||
ETHAN_REPO_BRANCH="${CHESSCUBING_ETHAN_GIT_BRANCH:-main}"
|
ETHAN_REPO_BRANCH="${CHESSCUBING_ETHAN_GIT_BRANCH:-main}"
|
||||||
|
BRICE_REPO_URL="${CHESSCUBING_BRICE_REPO_URL:-https://git.jeannerot.fr/Lescratcheur/ChessCubing.git}"
|
||||||
|
BRICE_REPO_BRANCH="${CHESSCUBING_BRICE_GIT_BRANCH:-main}"
|
||||||
|
|
||||||
if [[ -z "$LOCAL_MODE" && -z "$PROXMOX_HOST" ]]; then
|
if [[ -z "$LOCAL_MODE" && -z "$PROXMOX_HOST" ]]; then
|
||||||
if have_cmd pct && have_cmd pveam; then
|
if have_cmd pct && have_cmd pveam; then
|
||||||
@@ -165,6 +169,8 @@ cmd=(
|
|||||||
--branch "$REPO_BRANCH"
|
--branch "$REPO_BRANCH"
|
||||||
--ethan-repo-url "$ETHAN_REPO_URL"
|
--ethan-repo-url "$ETHAN_REPO_URL"
|
||||||
--ethan-branch "$ETHAN_REPO_BRANCH"
|
--ethan-branch "$ETHAN_REPO_BRANCH"
|
||||||
|
--brice-repo-url "$BRICE_REPO_URL"
|
||||||
|
--brice-branch "$BRICE_REPO_BRANCH"
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ "$LOCAL_MODE" == "1" ]]; then
|
if [[ "$LOCAL_MODE" == "1" ]]; then
|
||||||
|
|||||||
62
keycloak/realm/chesscubing-realm.json
Normal file
62
keycloak/realm/chesscubing-realm.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"realm": "chesscubing",
|
||||||
|
"enabled": true,
|
||||||
|
"displayName": "ChessCubing",
|
||||||
|
"registrationAllowed": true,
|
||||||
|
"rememberMe": true,
|
||||||
|
"resetPasswordAllowed": true,
|
||||||
|
"loginWithEmailAllowed": true,
|
||||||
|
"duplicateEmailsAllowed": false,
|
||||||
|
"editUsernameAllowed": false,
|
||||||
|
"sslRequired": "external",
|
||||||
|
"roles": {
|
||||||
|
"realm": [
|
||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"description": "Administrateur ChessCubing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "organizer",
|
||||||
|
"description": "Organisateur de rencontre"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "player",
|
||||||
|
"description": "Joueur ChessCubing"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "chesscubing-web",
|
||||||
|
"name": "ChessCubing Web",
|
||||||
|
"description": "Client OIDC public pour l'application Blazor WebAssembly.",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": true,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"directAccessGrantsEnabled": true,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"frontchannelLogout": true,
|
||||||
|
"rootUrl": "http://localhost:8080/",
|
||||||
|
"baseUrl": "http://localhost:8080/",
|
||||||
|
"redirectUris": [
|
||||||
|
"http://localhost:8080/*"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"http://localhost:8080"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256",
|
||||||
|
"post.logout.redirect.uris": "http://localhost:8080/*"
|
||||||
|
},
|
||||||
|
"defaultClientScopes": [
|
||||||
|
"web-origins",
|
||||||
|
"acr",
|
||||||
|
"profile",
|
||||||
|
"roles",
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
43
keycloak/scripts/init-config.sh
Normal file
43
keycloak/scripts/init-config.sh
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
KEYCLOAK_URL="${KEYCLOAK_URL:-http://keycloak:8080/auth}"
|
||||||
|
KEYCLOAK_REALM="${KEYCLOAK_REALM:-chesscubing}"
|
||||||
|
KEYCLOAK_CLIENT_ID="${KEYCLOAK_CLIENT_ID:-chesscubing-web}"
|
||||||
|
KEYCLOAK_ADMIN_USERNAME="${KEYCLOAK_ADMIN_USERNAME:-admin}"
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:-admin}"
|
||||||
|
|
||||||
|
echo "Attente de Keycloak..."
|
||||||
|
until /opt/keycloak/bin/kcadm.sh config credentials \
|
||||||
|
--server "$KEYCLOAK_URL" \
|
||||||
|
--realm master \
|
||||||
|
--user "$KEYCLOAK_ADMIN_USERNAME" \
|
||||||
|
--password "$KEYCLOAK_ADMIN_PASSWORD" >/dev/null 2>&1; do
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
CLIENT_INTERNAL_ID="$(
|
||||||
|
/opt/keycloak/bin/kcadm.sh get clients \
|
||||||
|
-r "$KEYCLOAK_REALM" \
|
||||||
|
-q clientId="$KEYCLOAK_CLIENT_ID" \
|
||||||
|
--fields id \
|
||||||
|
--format csv \
|
||||||
|
--noquotes | tail -n 1
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ -z "$CLIENT_INTERNAL_ID" ]; then
|
||||||
|
echo "Client Keycloak introuvable: $KEYCLOAK_CLIENT_ID"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
/opt/keycloak/bin/kcadm.sh update "clients/$CLIENT_INTERNAL_ID" \
|
||||||
|
-r "$KEYCLOAK_REALM" \
|
||||||
|
-s directAccessGrantsEnabled=true \
|
||||||
|
-s standardFlowEnabled=true \
|
||||||
|
-s publicClient=true >/dev/null
|
||||||
|
|
||||||
|
/opt/keycloak/bin/kcadm.sh update "realms/$KEYCLOAK_REALM" \
|
||||||
|
-s registrationAllowed=true \
|
||||||
|
-s loginWithEmailAllowed=true >/dev/null
|
||||||
|
|
||||||
|
echo "Configuration Keycloak synchronisee."
|
||||||
40
nginx.conf
40
nginx.conf
@@ -13,6 +13,46 @@ server {
|
|||||||
try_files $uri $uri/ /ethan/index.html;
|
try_files $uri $uri/ /ethan/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /brice {
|
||||||
|
return 301 $scheme://$http_host/brice/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /brice/ {
|
||||||
|
try_files $uri $uri/ /brice/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /auth/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_pass http://keycloak:8080/auth/;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_pass http://auth:8080/api/;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hubs/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_pass http://auth:8080/hubs/;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,15 +25,22 @@ Options principales:
|
|||||||
--gateway Passerelle si IP statique
|
--gateway Passerelle si IP statique
|
||||||
--bridge Bridge reseau Proxmox (defaut: vmbr0)
|
--bridge Bridge reseau Proxmox (defaut: vmbr0)
|
||||||
--cores Nombre de vCPU du LXC (defaut: 2)
|
--cores Nombre de vCPU du LXC (defaut: 2)
|
||||||
--memory Memoire RAM en Mo (defaut: 1024)
|
--memory Memoire RAM en Mo (defaut: 4096)
|
||||||
--swap Swap en Mo (defaut: 512)
|
--swap Swap en Mo (defaut: 1024)
|
||||||
--disk-gb Taille disque du LXC en Go (defaut: 6)
|
--disk-gb Taille disque du LXC en Go (defaut: 16)
|
||||||
--template-storage Stockage Proxmox pour les templates
|
--template-storage Stockage Proxmox pour les templates
|
||||||
--rootfs-storage Stockage Proxmox pour le disque LXC
|
--rootfs-storage Stockage Proxmox pour le disque LXC
|
||||||
--repo-url Depot Git a deployer
|
--repo-url Depot Git a deployer
|
||||||
--branch Branche Git a deployer (defaut: main)
|
--branch Branche Git a deployer (defaut: main)
|
||||||
--ethan-repo-url Depot Git de l'application Ethan
|
--ethan-repo-url Depot Git de l'application Ethan
|
||||||
--ethan-branch Branche Git de l'application Ethan (defaut: main)
|
--ethan-branch Branche Git de l'application Ethan (defaut: main)
|
||||||
|
--brice-repo-url Depot Git de l'application Brice
|
||||||
|
--brice-branch Branche Git de l'application Brice (defaut: main)
|
||||||
|
--public-base-url URL publique du site (ex: http://jeu.example.com)
|
||||||
|
--web-port Port HTTP expose dans le LXC (defaut: 80)
|
||||||
|
--keycloak-admin-user Utilisateur admin Keycloak (defaut: admin)
|
||||||
|
--keycloak-admin-password
|
||||||
|
Mot de passe admin Keycloak. Genere si absent
|
||||||
--lxc-password Mot de passe root du LXC. Genere si absent
|
--lxc-password Mot de passe root du LXC. Genere si absent
|
||||||
-h, --help Affiche cette aide
|
-h, --help Affiche cette aide
|
||||||
|
|
||||||
@@ -66,15 +73,21 @@ LXC_IP="dhcp"
|
|||||||
LXC_GATEWAY=""
|
LXC_GATEWAY=""
|
||||||
LXC_BRIDGE="vmbr0"
|
LXC_BRIDGE="vmbr0"
|
||||||
LXC_CORES="2"
|
LXC_CORES="2"
|
||||||
LXC_MEMORY="1024"
|
LXC_MEMORY="4096"
|
||||||
LXC_SWAP="512"
|
LXC_SWAP="1024"
|
||||||
LXC_DISK_GB="6"
|
LXC_DISK_GB="16"
|
||||||
TEMPLATE_STORAGE=""
|
TEMPLATE_STORAGE=""
|
||||||
ROOTFS_STORAGE=""
|
ROOTFS_STORAGE=""
|
||||||
REPO_URL="https://git.jeannerot.fr/christophe/chesscubing.git"
|
REPO_URL="https://git.jeannerot.fr/christophe/chesscubing.git"
|
||||||
REPO_BRANCH="main"
|
REPO_BRANCH="main"
|
||||||
ETHAN_REPO_URL="https://git.jeannerot.fr/Mineloulou/Chesscubing.git"
|
ETHAN_REPO_URL="https://git.jeannerot.fr/Mineloulou/Chesscubing.git"
|
||||||
ETHAN_REPO_BRANCH="main"
|
ETHAN_REPO_BRANCH="main"
|
||||||
|
BRICE_REPO_URL="https://git.jeannerot.fr/Lescratcheur/ChessCubing.git"
|
||||||
|
BRICE_REPO_BRANCH="main"
|
||||||
|
PUBLIC_BASE_URL=""
|
||||||
|
WEB_PORT="80"
|
||||||
|
KEYCLOAK_ADMIN_USER="admin"
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=""
|
||||||
LXC_PASSWORD=""
|
LXC_PASSWORD=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -159,6 +172,30 @@ while [[ $# -gt 0 ]]; do
|
|||||||
ETHAN_REPO_BRANCH="${2:-}"
|
ETHAN_REPO_BRANCH="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--brice-repo-url)
|
||||||
|
BRICE_REPO_URL="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--brice-branch)
|
||||||
|
BRICE_REPO_BRANCH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--public-base-url)
|
||||||
|
PUBLIC_BASE_URL="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--web-port)
|
||||||
|
WEB_PORT="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--keycloak-admin-user)
|
||||||
|
KEYCLOAK_ADMIN_USER="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--keycloak-admin-password)
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--lxc-password)
|
--lxc-password)
|
||||||
LXC_PASSWORD="${2:-}"
|
LXC_PASSWORD="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -206,7 +243,13 @@ repo_url="$3"
|
|||||||
repo_branch="$4"
|
repo_branch="$4"
|
||||||
ethan_repo_url="$5"
|
ethan_repo_url="$5"
|
||||||
ethan_repo_branch="$6"
|
ethan_repo_branch="$6"
|
||||||
lxc_password="$7"
|
brice_repo_url="$7"
|
||||||
|
brice_repo_branch="$8"
|
||||||
|
lxc_password="$9"
|
||||||
|
public_base_url="${10}"
|
||||||
|
web_port="${11}"
|
||||||
|
keycloak_admin_user="${12}"
|
||||||
|
keycloak_admin_password="${13}"
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
printf 'Erreur: %s\n' "$*" >&2
|
printf 'Erreur: %s\n' "$*" >&2
|
||||||
@@ -325,7 +368,8 @@ pct create "$ctid" "$template_ref" \
|
|||||||
--onboot 1 \
|
--onboot 1 \
|
||||||
--ostype debian \
|
--ostype debian \
|
||||||
--password "$lxc_password" \
|
--password "$lxc_password" \
|
||||||
--unprivileged 1
|
--unprivileged 1 \
|
||||||
|
--features nesting=1,keyctl=1
|
||||||
|
|
||||||
pct start "$ctid"
|
pct start "$ctid"
|
||||||
|
|
||||||
@@ -339,21 +383,7 @@ done
|
|||||||
|
|
||||||
pct exec "$ctid" -- true >/dev/null 2>&1 || die "Le LXC n'est pas joignable apres le demarrage."
|
pct exec "$ctid" -- true >/dev/null 2>&1 || die "Le LXC n'est pas joignable apres le demarrage."
|
||||||
|
|
||||||
printf 'Installation de nginx, git et rsync dans le conteneur...\n'
|
ct_exec "install -d -m 0755 /opt/chesscubing/repo /opt/chesscubing/ethan-repo /opt/chesscubing/brice-repo /opt/chesscubing/deploy /opt/chesscubing/config"
|
||||||
ct_exec "apt-get update && apt-get install -y ca-certificates git nginx rsync"
|
|
||||||
|
|
||||||
ct_exec "install -d -m 0755 /opt/chesscubing/repo /opt/chesscubing/ethan-repo /var/www/chesscubing/current"
|
|
||||||
|
|
||||||
printf 'Clonage du depot %s...\n' "$repo_url"
|
|
||||||
ct_exec "if [ ! -d /opt/chesscubing/repo/.git ]; then \
|
|
||||||
rm -rf /opt/chesscubing/repo/* /opt/chesscubing/repo/.[!.]* /opt/chesscubing/repo/..?* 2>/dev/null || true; \
|
|
||||||
git clone --branch '$repo_branch' --single-branch '$repo_url' /opt/chesscubing/repo; \
|
|
||||||
else \
|
|
||||||
cd /opt/chesscubing/repo && \
|
|
||||||
git fetch origin '$repo_branch' && \
|
|
||||||
if git show-ref --verify --quiet 'refs/heads/$repo_branch'; then git checkout '$repo_branch'; else git checkout -b '$repo_branch' --track 'origin/$repo_branch'; fi && \
|
|
||||||
git pull --ff-only origin '$repo_branch'; \
|
|
||||||
fi"
|
|
||||||
|
|
||||||
ct_exec "cat > /usr/local/bin/update-chesscubing <<'SCRIPT'
|
ct_exec "cat > /usr/local/bin/update-chesscubing <<'SCRIPT'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
@@ -363,10 +393,56 @@ trap 'printf \"Erreur: echec de la commande [%s] a la ligne %s.\\n\" \"\$BASH_CO
|
|||||||
|
|
||||||
main_repo_dir='/opt/chesscubing/repo'
|
main_repo_dir='/opt/chesscubing/repo'
|
||||||
ethan_repo_dir='/opt/chesscubing/ethan-repo'
|
ethan_repo_dir='/opt/chesscubing/ethan-repo'
|
||||||
web_root='/var/www/chesscubing/current'
|
brice_repo_dir='/opt/chesscubing/brice-repo'
|
||||||
|
deploy_dir='/opt/chesscubing/deploy'
|
||||||
|
config_dir='/opt/chesscubing/config'
|
||||||
|
env_file=\"\$config_dir/chesscubing.env\"
|
||||||
main_branch=\"\${1:-${repo_branch}}\"
|
main_branch=\"\${1:-${repo_branch}}\"
|
||||||
|
public_base_url_override=\"\${2:-}\"
|
||||||
|
web_port_override=\"\${3:-}\"
|
||||||
|
keycloak_admin_user_override=\"\${4:-}\"
|
||||||
|
keycloak_admin_password_override=\"\${5:-}\"
|
||||||
|
main_repo_url='${repo_url}'
|
||||||
ethan_repo_url='${ethan_repo_url}'
|
ethan_repo_url='${ethan_repo_url}'
|
||||||
ethan_branch='${ethan_repo_branch}'
|
ethan_branch='${ethan_repo_branch}'
|
||||||
|
brice_repo_url='${brice_repo_url}'
|
||||||
|
brice_branch='${brice_repo_branch}'
|
||||||
|
|
||||||
|
random_secret() {
|
||||||
|
od -An -N24 -tx1 /dev/urandom | tr -d ' \n'
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_base_packages() {
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y ca-certificates curl gpg git rsync
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_docker_stack() {
|
||||||
|
ensure_base_packages
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
|
||||||
|
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f /etc/apt/sources.list.d/docker.list ]]; then
|
||||||
|
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian %s stable\n' \
|
||||||
|
\"\$(dpkg --print-architecture)\" \
|
||||||
|
\"\$(. /etc/os-release && printf '%s' \"\$VERSION_CODENAME\")\" > /etc/apt/sources.list.d/docker.list
|
||||||
|
fi
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl enable docker >/dev/null 2>&1 || true
|
||||||
|
if ! systemctl is-active --quiet docker; then
|
||||||
|
systemctl start docker
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
sync_git_repo() {
|
sync_git_repo() {
|
||||||
local repo_dir=\"\$1\"
|
local repo_dir=\"\$1\"
|
||||||
@@ -401,86 +477,216 @@ sync_git_repo() {
|
|||||||
git clone --branch \"\$branch\" --single-branch \"\$repo_url\" \"\$repo_dir\"
|
git clone --branch \"\$branch\" --single-branch \"\$repo_url\" \"\$repo_dir\"
|
||||||
}
|
}
|
||||||
|
|
||||||
publish_static_tree() {
|
get_env_var() {
|
||||||
local source_dir=\"\$1\"
|
local key=\"\$1\"
|
||||||
local destination_dir=\"\$2\"
|
|
||||||
|
|
||||||
install -d -m 0755 \"\$destination_dir\"
|
[[ -f \"\$env_file\" ]] || return 0
|
||||||
|
awk -F= -v wanted=\"\$key\" '\$1 == wanted { print substr(\$0, length(wanted) + 2); exit }' \"\$env_file\"
|
||||||
rsync -a --delete \
|
|
||||||
--include='*/' \
|
|
||||||
--include='*.html' \
|
|
||||||
--include='*.css' \
|
|
||||||
--include='*.js' \
|
|
||||||
--include='*.mjs' \
|
|
||||||
--include='*.png' \
|
|
||||||
--include='*.jpg' \
|
|
||||||
--include='*.jpeg' \
|
|
||||||
--include='*.svg' \
|
|
||||||
--include='*.webp' \
|
|
||||||
--include='*.ico' \
|
|
||||||
--include='*.pdf' \
|
|
||||||
--include='*.webmanifest' \
|
|
||||||
--exclude='*' \
|
|
||||||
\"\$source_dir/\" \"\$destination_dir/\"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sync_git_repo \"\$main_repo_dir\" '${repo_url}' \"\$main_branch\" 'principal'
|
set_env_var() {
|
||||||
|
local key=\"\$1\"
|
||||||
|
local value=\"\$2\"
|
||||||
|
|
||||||
|
touch \"\$env_file\"
|
||||||
|
if grep -q \"^\${key}=\" \"\$env_file\" 2>/dev/null; then
|
||||||
|
sed -i \"s|^\${key}=.*|\${key}=\${value}|\" \"\$env_file\"
|
||||||
|
else
|
||||||
|
printf '%s=%s\n' \"\$key\" \"\$value\" >> \"\$env_file\"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_env_default() {
|
||||||
|
local key=\"\$1\"
|
||||||
|
local fallback=\"\$2\"
|
||||||
|
local value
|
||||||
|
|
||||||
|
value=\"\$(get_env_var \"\$key\")\"
|
||||||
|
if [[ -z \"\$value\" ]]; then
|
||||||
|
value=\"\$fallback\"
|
||||||
|
fi
|
||||||
|
|
||||||
|
set_env_var \"\$key\" \"\$value\"
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_public_base_url() {
|
||||||
|
local value=\"\$1\"
|
||||||
|
printf '%s\n' \"\${value%/}\"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_default_public_base_url() {
|
||||||
|
local port=\"\$1\"
|
||||||
|
local detected_ip
|
||||||
|
|
||||||
|
detected_ip=\"\$(hostname -I | awk '{print \$1}')\"
|
||||||
|
[[ -n \"\$detected_ip\" ]] || return 1
|
||||||
|
|
||||||
|
if [[ \"\$port\" == \"80\" ]]; then
|
||||||
|
printf 'http://%s\n' \"\$detected_ip\"
|
||||||
|
else
|
||||||
|
printf 'http://%s:%s\n' \"\$detected_ip\" \"\$port\"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_env_file() {
|
||||||
|
local current_value
|
||||||
|
local effective_web_port
|
||||||
|
local effective_public_base_url
|
||||||
|
local effective_keycloak_admin_user
|
||||||
|
local effective_keycloak_admin_password
|
||||||
|
|
||||||
|
effective_web_port=\"\$web_port_override\"
|
||||||
|
if [[ -z \"\$effective_web_port\" ]]; then
|
||||||
|
effective_web_port=\"\$(get_env_var WEB_PORT)\"
|
||||||
|
fi
|
||||||
|
if [[ -z \"\$effective_web_port\" ]]; then
|
||||||
|
effective_web_port='80'
|
||||||
|
fi
|
||||||
|
set_env_var WEB_PORT \"\$effective_web_port\"
|
||||||
|
|
||||||
|
effective_public_base_url=\"\$public_base_url_override\"
|
||||||
|
if [[ -z \"\$effective_public_base_url\" ]]; then
|
||||||
|
effective_public_base_url=\"\$(get_env_var PUBLIC_BASE_URL)\"
|
||||||
|
fi
|
||||||
|
if [[ -z \"\$effective_public_base_url\" ]]; then
|
||||||
|
effective_public_base_url=\"\$(build_default_public_base_url \"\$effective_web_port\" || true)\"
|
||||||
|
fi
|
||||||
|
if [[ -z \"\$effective_public_base_url\" ]]; then
|
||||||
|
if [[ \"\$effective_web_port\" == \"80\" ]]; then
|
||||||
|
effective_public_base_url='http://localhost'
|
||||||
|
else
|
||||||
|
effective_public_base_url=\"http://localhost:\$effective_web_port\"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
set_env_var PUBLIC_BASE_URL \"\$(normalize_public_base_url \"\$effective_public_base_url\")\"
|
||||||
|
|
||||||
|
effective_keycloak_admin_user=\"\$keycloak_admin_user_override\"
|
||||||
|
if [[ -z \"\$effective_keycloak_admin_user\" ]]; then
|
||||||
|
effective_keycloak_admin_user=\"\$(get_env_var KEYCLOAK_ADMIN_USER)\"
|
||||||
|
fi
|
||||||
|
if [[ -z \"\$effective_keycloak_admin_user\" ]]; then
|
||||||
|
effective_keycloak_admin_user='admin'
|
||||||
|
fi
|
||||||
|
set_env_var KEYCLOAK_ADMIN_USER \"\$effective_keycloak_admin_user\"
|
||||||
|
|
||||||
|
effective_keycloak_admin_password=\"\$keycloak_admin_password_override\"
|
||||||
|
if [[ -z \"\$effective_keycloak_admin_password\" ]]; then
|
||||||
|
effective_keycloak_admin_password=\"\$(get_env_var KEYCLOAK_ADMIN_PASSWORD)\"
|
||||||
|
fi
|
||||||
|
if [[ -z \"\$effective_keycloak_admin_password\" ]]; then
|
||||||
|
effective_keycloak_admin_password=\"\$(random_secret)\"
|
||||||
|
fi
|
||||||
|
set_env_var KEYCLOAK_ADMIN_PASSWORD \"\$effective_keycloak_admin_password\"
|
||||||
|
|
||||||
|
ensure_env_default KEYCLOAK_DB_NAME keycloak
|
||||||
|
ensure_env_default KEYCLOAK_DB_USER keycloak
|
||||||
|
current_value=\"\$(get_env_var KEYCLOAK_DB_PASSWORD)\"
|
||||||
|
if [[ -z \"\$current_value\" ]]; then
|
||||||
|
current_value=\"\$(random_secret)\"
|
||||||
|
fi
|
||||||
|
set_env_var KEYCLOAK_DB_PASSWORD \"\$current_value\"
|
||||||
|
|
||||||
|
ensure_env_default SITE_DB_NAME chesscubing_site
|
||||||
|
ensure_env_default SITE_DB_USER chesscubing
|
||||||
|
current_value=\"\$(get_env_var SITE_DB_PASSWORD)\"
|
||||||
|
if [[ -z \"\$current_value\" ]]; then
|
||||||
|
current_value=\"\$(random_secret)\"
|
||||||
|
fi
|
||||||
|
set_env_var SITE_DB_PASSWORD \"\$current_value\"
|
||||||
|
|
||||||
|
current_value=\"\$(get_env_var SITE_DB_ROOT_PASSWORD)\"
|
||||||
|
if [[ -z \"\$current_value\" ]]; then
|
||||||
|
current_value=\"\$(random_secret)\"
|
||||||
|
fi
|
||||||
|
set_env_var SITE_DB_ROOT_PASSWORD \"\$current_value\"
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_deploy_tree() {
|
||||||
|
install -d -m 0755 \"\$deploy_dir\" \"\$config_dir\"
|
||||||
|
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='bin/' \
|
||||||
|
--exclude='obj/' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
\"\$main_repo_dir/\" \"\$deploy_dir/\"
|
||||||
|
|
||||||
|
if [[ -d \"\$ethan_repo_dir/.git\" ]]; then
|
||||||
|
rm -rf \"\$deploy_dir/ethan\"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
\"\$ethan_repo_dir/\" \"\$deploy_dir/ethan/\"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d \"\$brice_repo_dir/.git\" ]]; then
|
||||||
|
rm -rf \"\$deploy_dir/brice\"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
\"\$brice_repo_dir/\" \"\$deploy_dir/brice/\"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_legacy_nginx() {
|
||||||
|
systemctl disable --now nginx >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_disk_space() {
|
||||||
|
cd \"\$deploy_dir\"
|
||||||
|
docker builder prune -af || true
|
||||||
|
docker image prune -f || true
|
||||||
|
apt-get clean || true
|
||||||
|
rm -rf /var/lib/apt/lists/* || true
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_free_space_mb() {
|
||||||
|
local required_mb=\"\$1\"
|
||||||
|
local available_mb
|
||||||
|
|
||||||
|
available_mb=\"\$(df -Pm / | awk 'NR == 2 { print \$4 }')\"
|
||||||
|
if [[ -z \"\$available_mb\" ]]; then
|
||||||
|
echo \"Impossible de mesurer l'espace disque libre dans le LXC.\" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( available_mb < required_mb )); then
|
||||||
|
echo \"Espace disque insuffisant dans le LXC: \${available_mb} Mo libres, \${required_mb} Mo recommandes avant la reconstruction Docker.\" >&2
|
||||||
|
echo \"Agrandis le disque du LXC puis relance la mise a jour.\" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_stack() {
|
||||||
|
cp \"\$env_file\" \"\$deploy_dir/.env\"
|
||||||
|
|
||||||
|
prepare_disk_space
|
||||||
|
ensure_free_space_mb 6144
|
||||||
|
|
||||||
|
cd \"\$deploy_dir\"
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose ps
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_docker_stack
|
||||||
|
sync_git_repo \"\$main_repo_dir\" \"\$main_repo_url\" \"\$main_branch\" 'principal'
|
||||||
sync_git_repo \"\$ethan_repo_dir\" \"\$ethan_repo_url\" \"\$ethan_branch\" 'Ethan'
|
sync_git_repo \"\$ethan_repo_dir\" \"\$ethan_repo_url\" \"\$ethan_branch\" 'Ethan'
|
||||||
|
sync_git_repo \"\$brice_repo_dir\" \"\$brice_repo_url\" \"\$brice_branch\" 'Brice'
|
||||||
asset_version=\"\$(git -C \"\$main_repo_dir\" rev-parse --short HEAD)-\$(git -C \"\$ethan_repo_dir\" rev-parse --short HEAD)\"
|
sync_deploy_tree
|
||||||
|
configure_env_file
|
||||||
install -d -m 0755 \"\$web_root\"
|
disable_legacy_nginx
|
||||||
|
deploy_stack
|
||||||
publish_static_tree \"\$main_repo_dir\" \"\$web_root\"
|
|
||||||
publish_static_tree \"\$ethan_repo_dir\" \"\$web_root/ethan\"
|
|
||||||
|
|
||||||
while IFS= read -r -d '' html_file; do
|
|
||||||
LC_ALL=C LANG=C ASSET_VERSION=\"\$asset_version\" perl -0pi -e 's{((?:href|src)=\")(?!https?://|data:|//)([^\"?]+?\.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest))(?:\?[^\"]*)?(\")}{\$1 . \$2 . \"?v=\" . \$ENV{ASSET_VERSION} . \$3}ge' \"\$html_file\"
|
|
||||||
done < <(find \"\$web_root\" -type f -name '*.html' -print0)
|
|
||||||
|
|
||||||
chown -R www-data:www-data \"\$web_root\"
|
|
||||||
|
|
||||||
nginx -t
|
|
||||||
systemctl reload nginx
|
|
||||||
SCRIPT
|
SCRIPT
|
||||||
chmod +x /usr/local/bin/update-chesscubing"
|
chmod +x /usr/local/bin/update-chesscubing"
|
||||||
|
|
||||||
ct_exec "cat > /etc/nginx/sites-available/chesscubing.conf <<'NGINX'
|
printf 'Deploiement de la stack Docker complete dans le LXC...\n'
|
||||||
server {
|
ct_exec "/usr/local/bin/update-chesscubing '$repo_branch' '$public_base_url' '$web_port' '$keycloak_admin_user' '$keycloak_admin_password'"
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /var/www/chesscubing/current;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location = /ethan {
|
|
||||||
return 301 \$scheme://\$http_host/ethan/;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /ethan/ {
|
|
||||||
try_files \$uri \$uri/ /ethan/index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files \$uri \$uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest)$ {
|
|
||||||
expires -1;
|
|
||||||
add_header Cache-Control 'no-cache, no-store, must-revalidate';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NGINX
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
|
||||||
ln -sf /etc/nginx/sites-available/chesscubing.conf /etc/nginx/sites-enabled/chesscubing.conf"
|
|
||||||
|
|
||||||
printf 'Publication du site dans le LXC...\n'
|
|
||||||
ct_exec "/usr/local/bin/update-chesscubing '$repo_branch'"
|
|
||||||
ct_exec "systemctl enable nginx >/dev/null && systemctl restart nginx"
|
|
||||||
|
|
||||||
container_ip="$(pct exec "$ctid" -- bash -lc "hostname -I | awk '{print \$1}'" 2>/dev/null | tr -d '\r' || true)"
|
container_ip="$(pct exec "$ctid" -- bash -lc "hostname -I | awk '{print \$1}'" 2>/dev/null | tr -d '\r' || true)"
|
||||||
|
public_url="$(pct exec "$ctid" -- bash -lc "awk -F= '/^PUBLIC_BASE_URL=/{print substr(\$0, 17); exit}' /opt/chesscubing/config/chesscubing.env" 2>/dev/null | tr -d '\r' || true)"
|
||||||
|
effective_keycloak_admin_user="$(pct exec "$ctid" -- bash -lc "awk -F= '/^KEYCLOAK_ADMIN_USER=/{print substr(\$0, 21); exit}' /opt/chesscubing/config/chesscubing.env" 2>/dev/null | tr -d '\r' || true)"
|
||||||
|
effective_keycloak_admin_password="$(pct exec "$ctid" -- bash -lc "awk -F= '/^KEYCLOAK_ADMIN_PASSWORD=/{print substr(\$0, 25); exit}' /opt/chesscubing/config/chesscubing.env" 2>/dev/null | tr -d '\r' || true)"
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
@@ -488,7 +694,10 @@ Installation terminee.
|
|||||||
- CTID: $ctid
|
- CTID: $ctid
|
||||||
- Nom du LXC: $lxc_hostname
|
- Nom du LXC: $lxc_hostname
|
||||||
- Mot de passe root du LXC: $lxc_password
|
- Mot de passe root du LXC: $lxc_password
|
||||||
- URL probable: http://${container_ip:-<ip_du_lxc>}
|
- IP detectee du LXC: ${container_ip:-<ip_du_lxc>}
|
||||||
|
- URL publique configuree: ${public_url:-http://${container_ip:-<ip_du_lxc>}}
|
||||||
|
- Admin Keycloak: ${effective_keycloak_admin_user:-admin}
|
||||||
|
- Mot de passe admin Keycloak: ${effective_keycloak_admin_password:-<voir /opt/chesscubing/config/chesscubing.env>}
|
||||||
|
|
||||||
Pour mettre a jour l'application plus tard:
|
Pour mettre a jour l'application plus tard:
|
||||||
./scripts/update-proxmox-lxc.sh --proxmox-host <ip-proxmox> --proxmox-user <user> --proxmox-password '<motdepasse>' --ctid $ctid
|
./scripts/update-proxmox-lxc.sh --proxmox-host <ip-proxmox> --proxmox-user <user> --proxmox-password '<motdepasse>' --ctid $ctid
|
||||||
@@ -513,7 +722,13 @@ if [[ "$LOCAL_MODE" == "1" ]]; then
|
|||||||
"$REPO_BRANCH" \
|
"$REPO_BRANCH" \
|
||||||
"$ETHAN_REPO_URL" \
|
"$ETHAN_REPO_URL" \
|
||||||
"$ETHAN_REPO_BRANCH" \
|
"$ETHAN_REPO_BRANCH" \
|
||||||
"$LXC_PASSWORD"
|
"$BRICE_REPO_URL" \
|
||||||
|
"$BRICE_REPO_BRANCH" \
|
||||||
|
"$LXC_PASSWORD" \
|
||||||
|
"$PUBLIC_BASE_URL" \
|
||||||
|
"$WEB_PORT" \
|
||||||
|
"$KEYCLOAK_ADMIN_USER" \
|
||||||
|
"$KEYCLOAK_ADMIN_PASSWORD"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -553,4 +768,10 @@ sshpass -p "$PROXMOX_PASSWORD" \
|
|||||||
"$REPO_BRANCH" \
|
"$REPO_BRANCH" \
|
||||||
"$ETHAN_REPO_URL" \
|
"$ETHAN_REPO_URL" \
|
||||||
"$ETHAN_REPO_BRANCH" \
|
"$ETHAN_REPO_BRANCH" \
|
||||||
"$LXC_PASSWORD" < "$payload_script"
|
"$BRICE_REPO_URL" \
|
||||||
|
"$BRICE_REPO_BRANCH" \
|
||||||
|
"$LXC_PASSWORD" \
|
||||||
|
"$PUBLIC_BASE_URL" \
|
||||||
|
"$WEB_PORT" \
|
||||||
|
"$KEYCLOAK_ADMIN_USER" \
|
||||||
|
"$KEYCLOAK_ADMIN_PASSWORD" < "$payload_script"
|
||||||
|
|||||||
@@ -22,9 +22,18 @@ Options principales:
|
|||||||
--local Execute directement sur l'hote Proxmox local
|
--local Execute directement sur l'hote Proxmox local
|
||||||
--ctid CTID du LXC a mettre a jour
|
--ctid CTID du LXC a mettre a jour
|
||||||
--hostname Nom du LXC si le CTID n'est pas fourni (defaut: chesscubing-web)
|
--hostname Nom du LXC si le CTID n'est pas fourni (defaut: chesscubing-web)
|
||||||
|
--disk-gb Taille disque cible du LXC en Go si un agrandissement est necessaire
|
||||||
|
--repo-url Depot Git principal a deployer
|
||||||
--branch Branche Git a deployer (defaut: main)
|
--branch Branche Git a deployer (defaut: main)
|
||||||
--ethan-repo-url Depot Git de l'application Ethan
|
--ethan-repo-url Depot Git de l'application Ethan
|
||||||
--ethan-branch Branche Git de l'application Ethan (defaut: main)
|
--ethan-branch Branche Git de l'application Ethan (defaut: main)
|
||||||
|
--brice-repo-url Depot Git de l'application Brice
|
||||||
|
--brice-branch Branche Git de l'application Brice (defaut: main)
|
||||||
|
--public-base-url URL publique du site (ex: http://jeu.example.com)
|
||||||
|
--web-port Port HTTP expose dans le LXC (defaut: conserve ou 80)
|
||||||
|
--keycloak-admin-user Utilisateur admin Keycloak a forcer
|
||||||
|
--keycloak-admin-password
|
||||||
|
Mot de passe admin Keycloak a forcer
|
||||||
-h, --help Affiche cette aide
|
-h, --help Affiche cette aide
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -46,9 +55,17 @@ LOCAL_MODE="0"
|
|||||||
|
|
||||||
CTID=""
|
CTID=""
|
||||||
LXC_HOSTNAME="chesscubing-web"
|
LXC_HOSTNAME="chesscubing-web"
|
||||||
|
TARGET_DISK_GB=""
|
||||||
|
REPO_URL="https://git.jeannerot.fr/christophe/chesscubing.git"
|
||||||
REPO_BRANCH="main"
|
REPO_BRANCH="main"
|
||||||
ETHAN_REPO_URL="https://git.jeannerot.fr/Mineloulou/Chesscubing.git"
|
ETHAN_REPO_URL="https://git.jeannerot.fr/Mineloulou/Chesscubing.git"
|
||||||
ETHAN_REPO_BRANCH="main"
|
ETHAN_REPO_BRANCH="main"
|
||||||
|
BRICE_REPO_URL="https://git.jeannerot.fr/Lescratcheur/ChessCubing.git"
|
||||||
|
BRICE_REPO_BRANCH="main"
|
||||||
|
PUBLIC_BASE_URL=""
|
||||||
|
WEB_PORT=""
|
||||||
|
KEYCLOAK_ADMIN_USER=""
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -80,6 +97,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
LXC_HOSTNAME="${2:-}"
|
LXC_HOSTNAME="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--disk-gb)
|
||||||
|
TARGET_DISK_GB="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--repo-url)
|
||||||
|
REPO_URL="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--branch)
|
--branch)
|
||||||
REPO_BRANCH="${2:-}"
|
REPO_BRANCH="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -92,6 +117,30 @@ while [[ $# -gt 0 ]]; do
|
|||||||
ETHAN_REPO_BRANCH="${2:-}"
|
ETHAN_REPO_BRANCH="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--brice-repo-url)
|
||||||
|
BRICE_REPO_URL="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--brice-branch)
|
||||||
|
BRICE_REPO_BRANCH="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--public-base-url)
|
||||||
|
PUBLIC_BASE_URL="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--web-port)
|
||||||
|
WEB_PORT="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--keycloak-admin-user)
|
||||||
|
KEYCLOAK_ADMIN_USER="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--keycloak-admin-password)
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h | --help)
|
-h | --help)
|
||||||
usage
|
usage
|
||||||
exit 0
|
exit 0
|
||||||
@@ -121,15 +170,70 @@ trap 'printf "Erreur: echec de la commande [%s] a la ligne %s.\n" "$BASH_COMMAND
|
|||||||
|
|
||||||
ctid="$1"
|
ctid="$1"
|
||||||
lxc_hostname="$2"
|
lxc_hostname="$2"
|
||||||
repo_branch="$3"
|
repo_url="$3"
|
||||||
ethan_repo_url="$4"
|
repo_branch="$4"
|
||||||
ethan_repo_branch="$5"
|
ethan_repo_url="$5"
|
||||||
|
ethan_repo_branch="$6"
|
||||||
|
brice_repo_url="$7"
|
||||||
|
brice_repo_branch="$8"
|
||||||
|
public_base_url="$9"
|
||||||
|
web_port="${10}"
|
||||||
|
keycloak_admin_user="${11}"
|
||||||
|
keycloak_admin_password="${12}"
|
||||||
|
target_disk_gb="${13}"
|
||||||
|
|
||||||
die() {
|
die() {
|
||||||
printf 'Erreur: %s\n' "$*" >&2
|
printf 'Erreur: %s\n' "$*" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
size_to_mb() {
|
||||||
|
local raw="${1^^}"
|
||||||
|
|
||||||
|
case "$raw" in
|
||||||
|
*T) echo $(( ${raw%T} * 1024 * 1024 )) ;;
|
||||||
|
*G) echo $(( ${raw%G} * 1024 )) ;;
|
||||||
|
*M) echo $(( ${raw%M} )) ;;
|
||||||
|
*K) echo $(( ${raw%K} / 1024 )) ;;
|
||||||
|
*) echo "$raw" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
get_current_rootfs_mb() {
|
||||||
|
local size
|
||||||
|
|
||||||
|
size="$(pct config "$ctid" | awk -F'[:,=]' '/^rootfs:/ { for (i = 1; i <= NF; i++) { if ($i == "size") { print $(i + 1); exit } } }')"
|
||||||
|
[[ -n "$size" ]] || return 1
|
||||||
|
|
||||||
|
size_to_mb "$size"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_rootfs_capacity() {
|
||||||
|
local requested_gb="$1"
|
||||||
|
local current_mb
|
||||||
|
local requested_mb
|
||||||
|
local delta_mb
|
||||||
|
local delta_gb
|
||||||
|
local current_gb
|
||||||
|
|
||||||
|
[[ -n "$requested_gb" ]] || return 0
|
||||||
|
[[ "$requested_gb" =~ ^[0-9]+$ ]] || die "La valeur de --disk-gb doit etre un entier en Go."
|
||||||
|
|
||||||
|
current_mb="$(get_current_rootfs_mb)" || die "Impossible de lire la taille actuelle du disque rootfs du LXC."
|
||||||
|
requested_mb=$(( requested_gb * 1024 ))
|
||||||
|
|
||||||
|
if (( current_mb >= requested_mb )); then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
delta_mb=$(( requested_mb - current_mb ))
|
||||||
|
delta_gb=$(( (delta_mb + 1023) / 1024 ))
|
||||||
|
current_gb=$(( (current_mb + 1023) / 1024 ))
|
||||||
|
|
||||||
|
printf 'Agrandissement du disque rootfs du LXC: %sG -> %sG (+%sG).\n' "$current_gb" "$requested_gb" "$delta_gb"
|
||||||
|
pct resize "$ctid" rootfs "+${delta_gb}G" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
find_ctid_by_hostname() {
|
find_ctid_by_hostname() {
|
||||||
local wanted="$1"
|
local wanted="$1"
|
||||||
local candidate=""
|
local candidate=""
|
||||||
@@ -164,15 +268,30 @@ if [[ -n "$detected_hostname" ]]; then
|
|||||||
lxc_hostname="$detected_hostname"
|
lxc_hostname="$detected_hostname"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! pct status "$ctid" | grep -q "running"; then
|
ensure_rootfs_capacity "$target_disk_gb"
|
||||||
pct start "$ctid"
|
|
||||||
sleep 5
|
if pct status "$ctid" | grep -q "running"; then
|
||||||
|
pct stop "$ctid"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
pct set "$ctid" --features nesting=1,keyctl=1 >/dev/null
|
||||||
|
pct start "$ctid"
|
||||||
|
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
if pct exec "$ctid" -- true >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
pct exec "$ctid" -- true >/dev/null 2>&1 || die "Le LXC n'est pas joignable apres le redemarrage."
|
||||||
|
|
||||||
ct_exec() {
|
ct_exec() {
|
||||||
pct exec "$ctid" -- bash -lc "$1"
|
pct exec "$ctid" -- bash -lc "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ct_exec "install -d -m 0755 /opt/chesscubing/repo /opt/chesscubing/ethan-repo /opt/chesscubing/brice-repo /opt/chesscubing/deploy /opt/chesscubing/config"
|
||||||
|
|
||||||
ct_exec "cat > /usr/local/bin/update-chesscubing <<'SCRIPT'
|
ct_exec "cat > /usr/local/bin/update-chesscubing <<'SCRIPT'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
@@ -181,10 +300,56 @@ trap 'printf \"Erreur: echec de la commande [%s] a la ligne %s.\\n\" \"\$BASH_CO
|
|||||||
|
|
||||||
main_repo_dir='/opt/chesscubing/repo'
|
main_repo_dir='/opt/chesscubing/repo'
|
||||||
ethan_repo_dir='/opt/chesscubing/ethan-repo'
|
ethan_repo_dir='/opt/chesscubing/ethan-repo'
|
||||||
web_root='/var/www/chesscubing/current'
|
brice_repo_dir='/opt/chesscubing/brice-repo'
|
||||||
|
deploy_dir='/opt/chesscubing/deploy'
|
||||||
|
config_dir='/opt/chesscubing/config'
|
||||||
|
env_file=\"\$config_dir/chesscubing.env\"
|
||||||
main_branch=\"\${1:-${repo_branch}}\"
|
main_branch=\"\${1:-${repo_branch}}\"
|
||||||
|
public_base_url_override=\"\${2:-}\"
|
||||||
|
web_port_override=\"\${3:-}\"
|
||||||
|
keycloak_admin_user_override=\"\${4:-}\"
|
||||||
|
keycloak_admin_password_override=\"\${5:-}\"
|
||||||
|
main_repo_url='${repo_url}'
|
||||||
ethan_repo_url='${ethan_repo_url}'
|
ethan_repo_url='${ethan_repo_url}'
|
||||||
ethan_branch='${ethan_repo_branch}'
|
ethan_branch='${ethan_repo_branch}'
|
||||||
|
brice_repo_url='${brice_repo_url}'
|
||||||
|
brice_branch='${brice_repo_branch}'
|
||||||
|
|
||||||
|
random_secret() {
|
||||||
|
od -An -N24 -tx1 /dev/urandom | tr -d ' \n'
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_base_packages() {
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y ca-certificates curl gpg git rsync
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_docker_stack() {
|
||||||
|
ensure_base_packages
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
|
||||||
|
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f /etc/apt/sources.list.d/docker.list ]]; then
|
||||||
|
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian %s stable\n' \
|
||||||
|
\"\$(dpkg --print-architecture)\" \
|
||||||
|
\"\$(. /etc/os-release && printf '%s' \"\$VERSION_CODENAME\")\" > /etc/apt/sources.list.d/docker.list
|
||||||
|
fi
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl enable docker >/dev/null 2>&1 || true
|
||||||
|
if ! systemctl is-active --quiet docker; then
|
||||||
|
systemctl start docker
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
sync_git_repo() {
|
sync_git_repo() {
|
||||||
local repo_dir=\"\$1\"
|
local repo_dir=\"\$1\"
|
||||||
@@ -219,91 +384,221 @@ sync_git_repo() {
|
|||||||
git clone --branch \"\$branch\" --single-branch \"\$repo_url\" \"\$repo_dir\"
|
git clone --branch \"\$branch\" --single-branch \"\$repo_url\" \"\$repo_dir\"
|
||||||
}
|
}
|
||||||
|
|
||||||
publish_static_tree() {
|
get_env_var() {
|
||||||
local source_dir=\"\$1\"
|
local key=\"\$1\"
|
||||||
local destination_dir=\"\$2\"
|
|
||||||
|
|
||||||
install -d -m 0755 \"\$destination_dir\"
|
[[ -f \"\$env_file\" ]] || return 0
|
||||||
|
awk -F= -v wanted=\"\$key\" '\$1 == wanted { print substr(\$0, length(wanted) + 2); exit }' \"\$env_file\"
|
||||||
rsync -a --delete \
|
|
||||||
--include='*/' \
|
|
||||||
--include='*.html' \
|
|
||||||
--include='*.css' \
|
|
||||||
--include='*.js' \
|
|
||||||
--include='*.mjs' \
|
|
||||||
--include='*.png' \
|
|
||||||
--include='*.jpg' \
|
|
||||||
--include='*.jpeg' \
|
|
||||||
--include='*.svg' \
|
|
||||||
--include='*.webp' \
|
|
||||||
--include='*.ico' \
|
|
||||||
--include='*.pdf' \
|
|
||||||
--include='*.webmanifest' \
|
|
||||||
--exclude='*' \
|
|
||||||
\"\$source_dir/\" \"\$destination_dir/\"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sync_git_repo \"\$main_repo_dir\" '' \"\$main_branch\" 'principal'
|
set_env_var() {
|
||||||
|
local key=\"\$1\"
|
||||||
|
local value=\"\$2\"
|
||||||
|
|
||||||
|
touch \"\$env_file\"
|
||||||
|
if grep -q \"^\${key}=\" \"\$env_file\" 2>/dev/null; then
|
||||||
|
sed -i \"s|^\${key}=.*|\${key}=\${value}|\" \"\$env_file\"
|
||||||
|
else
|
||||||
|
printf '%s=%s\n' \"\$key\" \"\$value\" >> \"\$env_file\"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_env_default() {
|
||||||
|
local key=\"\$1\"
|
||||||
|
local fallback=\"\$2\"
|
||||||
|
local value
|
||||||
|
|
||||||
|
value=\"\$(get_env_var \"\$key\")\"
|
||||||
|
if [[ -z \"\$value\" ]]; then
|
||||||
|
value=\"\$fallback\"
|
||||||
|
fi
|
||||||
|
|
||||||
|
set_env_var \"\$key\" \"\$value\"
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_public_base_url() {
|
||||||
|
local value=\"\$1\"
|
||||||
|
printf '%s\n' \"\${value%/}\"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_default_public_base_url() {
|
||||||
|
local port=\"\$1\"
|
||||||
|
local detected_ip
|
||||||
|
|
||||||
|
detected_ip=\"\$(hostname -I | awk '{print \$1}')\"
|
||||||
|
[[ -n \"\$detected_ip\" ]] || return 1
|
||||||
|
|
||||||
|
if [[ \"\$port\" == \"80\" ]]; then
|
||||||
|
printf 'http://%s\n' \"\$detected_ip\"
|
||||||
|
else
|
||||||
|
printf 'http://%s:%s\n' \"\$detected_ip\" \"\$port\"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_env_file() {
|
||||||
|
local current_value
|
||||||
|
local effective_web_port
|
||||||
|
local effective_public_base_url
|
||||||
|
local effective_keycloak_admin_user
|
||||||
|
local effective_keycloak_admin_password
|
||||||
|
|
||||||
|
effective_web_port=\"\$web_port_override\"
|
||||||
|
if [[ -z \"\$effective_web_port\" ]]; then
|
||||||
|
effective_web_port=\"\$(get_env_var WEB_PORT)\"
|
||||||
|
fi
|
||||||
|
if [[ -z \"\$effective_web_port\" ]]; then
|
||||||
|
effective_web_port='80'
|
||||||
|
fi
|
||||||
|
set_env_var WEB_PORT \"\$effective_web_port\"
|
||||||
|
|
||||||
|
effective_public_base_url=\"\$public_base_url_override\"
|
||||||
|
if [[ -z \"\$effective_public_base_url\" ]]; then
|
||||||
|
effective_public_base_url=\"\$(get_env_var PUBLIC_BASE_URL)\"
|
||||||
|
fi
|
||||||
|
if [[ -z \"\$effective_public_base_url\" ]]; then
|
||||||
|
effective_public_base_url=\"\$(build_default_public_base_url \"\$effective_web_port\" || true)\"
|
||||||
|
fi
|
||||||
|
if [[ -z \"\$effective_public_base_url\" ]]; then
|
||||||
|
if [[ \"\$effective_web_port\" == \"80\" ]]; then
|
||||||
|
effective_public_base_url='http://localhost'
|
||||||
|
else
|
||||||
|
effective_public_base_url=\"http://localhost:\$effective_web_port\"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
set_env_var PUBLIC_BASE_URL \"\$(normalize_public_base_url \"\$effective_public_base_url\")\"
|
||||||
|
|
||||||
|
effective_keycloak_admin_user=\"\$keycloak_admin_user_override\"
|
||||||
|
if [[ -z \"\$effective_keycloak_admin_user\" ]]; then
|
||||||
|
effective_keycloak_admin_user=\"\$(get_env_var KEYCLOAK_ADMIN_USER)\"
|
||||||
|
fi
|
||||||
|
if [[ -z \"\$effective_keycloak_admin_user\" ]]; then
|
||||||
|
effective_keycloak_admin_user='admin'
|
||||||
|
fi
|
||||||
|
set_env_var KEYCLOAK_ADMIN_USER \"\$effective_keycloak_admin_user\"
|
||||||
|
|
||||||
|
effective_keycloak_admin_password=\"\$keycloak_admin_password_override\"
|
||||||
|
if [[ -z \"\$effective_keycloak_admin_password\" ]]; then
|
||||||
|
effective_keycloak_admin_password=\"\$(get_env_var KEYCLOAK_ADMIN_PASSWORD)\"
|
||||||
|
fi
|
||||||
|
if [[ -z \"\$effective_keycloak_admin_password\" ]]; then
|
||||||
|
effective_keycloak_admin_password=\"\$(random_secret)\"
|
||||||
|
fi
|
||||||
|
set_env_var KEYCLOAK_ADMIN_PASSWORD \"\$effective_keycloak_admin_password\"
|
||||||
|
|
||||||
|
ensure_env_default KEYCLOAK_DB_NAME keycloak
|
||||||
|
ensure_env_default KEYCLOAK_DB_USER keycloak
|
||||||
|
current_value=\"\$(get_env_var KEYCLOAK_DB_PASSWORD)\"
|
||||||
|
if [[ -z \"\$current_value\" ]]; then
|
||||||
|
current_value=\"\$(random_secret)\"
|
||||||
|
fi
|
||||||
|
set_env_var KEYCLOAK_DB_PASSWORD \"\$current_value\"
|
||||||
|
|
||||||
|
ensure_env_default SITE_DB_NAME chesscubing_site
|
||||||
|
ensure_env_default SITE_DB_USER chesscubing
|
||||||
|
current_value=\"\$(get_env_var SITE_DB_PASSWORD)\"
|
||||||
|
if [[ -z \"\$current_value\" ]]; then
|
||||||
|
current_value=\"\$(random_secret)\"
|
||||||
|
fi
|
||||||
|
set_env_var SITE_DB_PASSWORD \"\$current_value\"
|
||||||
|
|
||||||
|
current_value=\"\$(get_env_var SITE_DB_ROOT_PASSWORD)\"
|
||||||
|
if [[ -z \"\$current_value\" ]]; then
|
||||||
|
current_value=\"\$(random_secret)\"
|
||||||
|
fi
|
||||||
|
set_env_var SITE_DB_ROOT_PASSWORD \"\$current_value\"
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_deploy_tree() {
|
||||||
|
install -d -m 0755 \"\$deploy_dir\" \"\$config_dir\"
|
||||||
|
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='bin/' \
|
||||||
|
--exclude='obj/' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
\"\$main_repo_dir/\" \"\$deploy_dir/\"
|
||||||
|
|
||||||
|
if [[ -d \"\$ethan_repo_dir/.git\" ]]; then
|
||||||
|
rm -rf \"\$deploy_dir/ethan\"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
\"\$ethan_repo_dir/\" \"\$deploy_dir/ethan/\"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d \"\$brice_repo_dir/.git\" ]]; then
|
||||||
|
rm -rf \"\$deploy_dir/brice\"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
\"\$brice_repo_dir/\" \"\$deploy_dir/brice/\"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_legacy_nginx() {
|
||||||
|
systemctl disable --now nginx >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_disk_space() {
|
||||||
|
cd \"\$deploy_dir\"
|
||||||
|
docker builder prune -af || true
|
||||||
|
docker image prune -f || true
|
||||||
|
apt-get clean || true
|
||||||
|
rm -rf /var/lib/apt/lists/* || true
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_free_space_mb() {
|
||||||
|
local required_mb=\"\$1\"
|
||||||
|
local available_mb
|
||||||
|
|
||||||
|
available_mb=\"\$(df -Pm / | awk 'NR == 2 { print \$4 }')\"
|
||||||
|
if [[ -z \"\$available_mb\" ]]; then
|
||||||
|
echo \"Impossible de mesurer l'espace disque libre dans le LXC.\" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( available_mb < required_mb )); then
|
||||||
|
echo \"Espace disque insuffisant dans le LXC: \${available_mb} Mo libres, \${required_mb} Mo recommandes avant la reconstruction Docker.\" >&2
|
||||||
|
echo \"Relance le script avec --disk-gb 16 ou une valeur plus elevee si besoin.\" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_stack() {
|
||||||
|
cp \"\$env_file\" \"\$deploy_dir/.env\"
|
||||||
|
|
||||||
|
prepare_disk_space
|
||||||
|
ensure_free_space_mb 6144
|
||||||
|
|
||||||
|
cd \"\$deploy_dir\"
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose ps
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_docker_stack
|
||||||
|
sync_git_repo \"\$main_repo_dir\" \"\$main_repo_url\" \"\$main_branch\" 'principal'
|
||||||
sync_git_repo \"\$ethan_repo_dir\" \"\$ethan_repo_url\" \"\$ethan_branch\" 'Ethan'
|
sync_git_repo \"\$ethan_repo_dir\" \"\$ethan_repo_url\" \"\$ethan_branch\" 'Ethan'
|
||||||
|
sync_git_repo \"\$brice_repo_dir\" \"\$brice_repo_url\" \"\$brice_branch\" 'Brice'
|
||||||
asset_version=\"\$(git -C \"\$main_repo_dir\" rev-parse --short HEAD)-\$(git -C \"\$ethan_repo_dir\" rev-parse --short HEAD)\"
|
sync_deploy_tree
|
||||||
|
configure_env_file
|
||||||
install -d -m 0755 \"\$web_root\"
|
disable_legacy_nginx
|
||||||
|
deploy_stack
|
||||||
publish_static_tree \"\$main_repo_dir\" \"\$web_root\"
|
|
||||||
publish_static_tree \"\$ethan_repo_dir\" \"\$web_root/ethan\"
|
|
||||||
|
|
||||||
while IFS= read -r -d '' html_file; do
|
|
||||||
LC_ALL=C LANG=C ASSET_VERSION=\"\$asset_version\" perl -0pi -e 's{((?:href|src)=\")(?!https?://|data:|//)([^\"?]+?\.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest))(?:\?[^\"]*)?(\")}{\$1 . \$2 . \"?v=\" . \$ENV{ASSET_VERSION} . \$3}ge' \"\$html_file\"
|
|
||||||
done < <(find \"\$web_root\" -type f -name '*.html' -print0)
|
|
||||||
|
|
||||||
chown -R www-data:www-data \"\$web_root\"
|
|
||||||
|
|
||||||
nginx -t
|
|
||||||
systemctl reload nginx
|
|
||||||
SCRIPT
|
SCRIPT
|
||||||
chmod +x /usr/local/bin/update-chesscubing"
|
chmod +x /usr/local/bin/update-chesscubing"
|
||||||
|
|
||||||
ct_exec "cat > /etc/nginx/sites-available/chesscubing.conf <<'NGINX'
|
ct_exec "/usr/local/bin/update-chesscubing '$repo_branch' '$public_base_url' '$web_port' '$keycloak_admin_user' '$keycloak_admin_password'"
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /var/www/chesscubing/current;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location = /ethan {
|
|
||||||
return 301 \$scheme://\$http_host/ethan/;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /ethan/ {
|
|
||||||
try_files \$uri \$uri/ /ethan/index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files \$uri \$uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(?:css|js|mjs|png|jpg|jpeg|svg|webp|ico|pdf|webmanifest)$ {
|
|
||||||
expires -1;
|
|
||||||
add_header Cache-Control 'no-cache, no-store, must-revalidate';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NGINX
|
|
||||||
rm -f /etc/nginx/sites-enabled/default
|
|
||||||
ln -sf /etc/nginx/sites-available/chesscubing.conf /etc/nginx/sites-enabled/chesscubing.conf"
|
|
||||||
|
|
||||||
pct exec "$ctid" -- /usr/local/bin/update-chesscubing "$repo_branch"
|
|
||||||
|
|
||||||
container_ip="$(pct exec "$ctid" -- bash -lc "hostname -I | awk '{print \$1}'" 2>/dev/null | tr -d '\r' || true)"
|
container_ip="$(pct exec "$ctid" -- bash -lc "hostname -I | awk '{print \$1}'" 2>/dev/null | tr -d '\r' || true)"
|
||||||
|
public_url="$(pct exec "$ctid" -- bash -lc "awk -F= '/^PUBLIC_BASE_URL=/{print substr(\$0, 17); exit}' /opt/chesscubing/config/chesscubing.env" 2>/dev/null | tr -d '\r' || true)"
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
Mise a jour terminee.
|
Mise a jour terminee.
|
||||||
- CTID: $ctid
|
- CTID: $ctid
|
||||||
- Nom du LXC: $lxc_hostname
|
- Nom du LXC: $lxc_hostname
|
||||||
- URL probable: http://${container_ip:-<ip_du_lxc>}
|
- IP detectee du LXC: ${container_ip:-<ip_du_lxc>}
|
||||||
|
- URL publique configuree: ${public_url:-http://${container_ip:-<ip_du_lxc>}}
|
||||||
EOF
|
EOF
|
||||||
REMOTE
|
REMOTE
|
||||||
|
|
||||||
@@ -312,9 +607,17 @@ if [[ "$LOCAL_MODE" == "1" ]]; then
|
|||||||
bash "$payload_script" \
|
bash "$payload_script" \
|
||||||
"$CTID" \
|
"$CTID" \
|
||||||
"$LXC_HOSTNAME" \
|
"$LXC_HOSTNAME" \
|
||||||
|
"$REPO_URL" \
|
||||||
"$REPO_BRANCH" \
|
"$REPO_BRANCH" \
|
||||||
"$ETHAN_REPO_URL" \
|
"$ETHAN_REPO_URL" \
|
||||||
"$ETHAN_REPO_BRANCH"
|
"$ETHAN_REPO_BRANCH" \
|
||||||
|
"$BRICE_REPO_URL" \
|
||||||
|
"$BRICE_REPO_BRANCH" \
|
||||||
|
"$PUBLIC_BASE_URL" \
|
||||||
|
"$WEB_PORT" \
|
||||||
|
"$KEYCLOAK_ADMIN_USER" \
|
||||||
|
"$KEYCLOAK_ADMIN_PASSWORD" \
|
||||||
|
"$TARGET_DISK_GB"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -341,6 +644,14 @@ sshpass -p "$PROXMOX_PASSWORD" \
|
|||||||
bash -s -- \
|
bash -s -- \
|
||||||
"$CTID" \
|
"$CTID" \
|
||||||
"$LXC_HOSTNAME" \
|
"$LXC_HOSTNAME" \
|
||||||
|
"$REPO_URL" \
|
||||||
"$REPO_BRANCH" \
|
"$REPO_BRANCH" \
|
||||||
"$ETHAN_REPO_URL" \
|
"$ETHAN_REPO_URL" \
|
||||||
"$ETHAN_REPO_BRANCH" < "$payload_script"
|
"$ETHAN_REPO_BRANCH" \
|
||||||
|
"$BRICE_REPO_URL" \
|
||||||
|
"$BRICE_REPO_BRANCH" \
|
||||||
|
"$PUBLIC_BASE_URL" \
|
||||||
|
"$WEB_PORT" \
|
||||||
|
"$KEYCLOAK_ADMIN_USER" \
|
||||||
|
"$KEYCLOAK_ADMIN_PASSWORD" \
|
||||||
|
"$TARGET_DISK_GB" < "$payload_script"
|
||||||
|
|||||||
1257
styles.css
1257
styles.css
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,8 @@ Variables d'environnement reconnues :
|
|||||||
CHESSCUBING_GIT_BRANCH
|
CHESSCUBING_GIT_BRANCH
|
||||||
CHESSCUBING_ETHAN_REPO_URL
|
CHESSCUBING_ETHAN_REPO_URL
|
||||||
CHESSCUBING_ETHAN_GIT_BRANCH
|
CHESSCUBING_ETHAN_GIT_BRANCH
|
||||||
|
CHESSCUBING_BRICE_REPO_URL
|
||||||
|
CHESSCUBING_BRICE_GIT_BRANCH
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +93,8 @@ CTID="${CHESSCUBING_CTID:-}"
|
|||||||
LXC_HOSTNAME="${CHESSCUBING_LXC_HOSTNAME:-chesscubing-web}"
|
LXC_HOSTNAME="${CHESSCUBING_LXC_HOSTNAME:-chesscubing-web}"
|
||||||
ETHAN_REPO_URL="${CHESSCUBING_ETHAN_REPO_URL:-https://git.jeannerot.fr/Mineloulou/Chesscubing.git}"
|
ETHAN_REPO_URL="${CHESSCUBING_ETHAN_REPO_URL:-https://git.jeannerot.fr/Mineloulou/Chesscubing.git}"
|
||||||
ETHAN_REPO_BRANCH="${CHESSCUBING_ETHAN_GIT_BRANCH:-main}"
|
ETHAN_REPO_BRANCH="${CHESSCUBING_ETHAN_GIT_BRANCH:-main}"
|
||||||
|
BRICE_REPO_URL="${CHESSCUBING_BRICE_REPO_URL:-https://git.jeannerot.fr/Lescratcheur/ChessCubing.git}"
|
||||||
|
BRICE_REPO_BRANCH="${CHESSCUBING_BRICE_GIT_BRANCH:-main}"
|
||||||
|
|
||||||
if [[ -z "$LOCAL_MODE" && -z "$PROXMOX_HOST" ]]; then
|
if [[ -z "$LOCAL_MODE" && -z "$PROXMOX_HOST" ]]; then
|
||||||
if have_cmd pct && have_cmd pveam; then
|
if have_cmd pct && have_cmd pveam; then
|
||||||
@@ -130,6 +134,8 @@ cmd=(
|
|||||||
--branch "$REPO_BRANCH"
|
--branch "$REPO_BRANCH"
|
||||||
--ethan-repo-url "$ETHAN_REPO_URL"
|
--ethan-repo-url "$ETHAN_REPO_URL"
|
||||||
--ethan-branch "$ETHAN_REPO_BRANCH"
|
--ethan-branch "$ETHAN_REPO_BRANCH"
|
||||||
|
--brice-repo-url "$BRICE_REPO_URL"
|
||||||
|
--brice-branch "$BRICE_REPO_BRANCH"
|
||||||
)
|
)
|
||||||
|
|
||||||
if [[ "$LOCAL_MODE" == "1" ]]; then
|
if [[ "$LOCAL_MODE" == "1" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user