Ajoute une page utilisateur et une persistance MySQL
This commit is contained in:
475
ChessCubing.App/Pages/UserPage.razor
Normal file
475
ChessCubing.App/Pages/UserPage.razor
Normal file
@@ -0,0 +1,475 @@
|
||||
@page "/utilisateur"
|
||||
@page "/utilisateur.html"
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Net
|
||||
@using System.Net.Http.Json
|
||||
@using ChessCubing.App.Models.Users
|
||||
@implements IDisposable
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject HttpClient Http
|
||||
|
||||
<PageTitle>ChessCubing Arena | Utilisateur</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 user-hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Espace utilisateur</p>
|
||||
<h1>Gerer les donnees du site pour chaque joueur</h1>
|
||||
<p class="lead">
|
||||
Cette page relie le compte connecte a une fiche utilisateur stockee
|
||||
cote serveur en MySQL. L'authentification reste geree par Keycloak,
|
||||
mais les informations metier du site sont maintenant pretes pour des
|
||||
evolutions futures.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button secondary" href="application.html">Ouvrir l'application</a>
|
||||
<a class="button ghost" href="reglement.html">Voir les formats</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="hero-preview">
|
||||
<div class="preview-card">
|
||||
<p class="micro-label">Persistance</p>
|
||||
<strong>Profil du site stocke en MySQL</strong>
|
||||
<p>
|
||||
Username et email restent lies au compte authentifie, pendant que
|
||||
le profil ChessCubing ajoute les donnees utiles au site.
|
||||
</p>
|
||||
</div>
|
||||
<div class="preview-banner">
|
||||
<span class="mini-chip">Etat du compte</span>
|
||||
<strong>@HeroStatusTitle</strong>
|
||||
<p>@HeroStatusDescription</p>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<main class="rules-grid">
|
||||
@if (!IsAuthenticated)
|
||||
{
|
||||
<section class="panel panel-wide cta-panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Connexion requise</p>
|
||||
<h2>Connecte-toi pour gerer ton profil</h2>
|
||||
</div>
|
||||
<p class="section-copy">
|
||||
Utilise les boutons Se connecter ou Creer un compte dans le menu
|
||||
en haut de page, puis reviens ici pour enregistrer tes informations.
|
||||
</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 presentation generale 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 pendant que le compte reste disponible dans le menu.</p>
|
||||
</a>
|
||||
<a class="source-card" href="reglement.html">
|
||||
<span class="micro-label">Reglement</span>
|
||||
<strong>Relire les formats</strong>
|
||||
<p>Retrouver rapidement les differences entre Twice et Time.</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else if (IsLoading)
|
||||
{
|
||||
<section class="panel panel-wide">
|
||||
<p class="eyebrow">Chargement</p>
|
||||
<h2>Recuperation du profil utilisateur</h2>
|
||||
<p class="section-copy">Le serveur recharge les donnees enregistrees pour ce compte.</p>
|
||||
</section>
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(LoadError))
|
||||
{
|
||||
<section class="panel panel-wide">
|
||||
<p class="eyebrow">Serveur</p>
|
||||
<h2>Impossible de charger le profil</h2>
|
||||
<p class="profile-feedback error">@LoadError</p>
|
||||
<div class="hero-actions">
|
||||
<button class="button secondary" type="button" @onclick="LoadProfileAsync">Reessayer</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else if (Profile is not null)
|
||||
{
|
||||
<section class="panel panel-half">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Compte relie</p>
|
||||
<h2>@Profile.DisplayName</h2>
|
||||
</div>
|
||||
<p class="section-copy">
|
||||
Le compte connecte pilote l'identite, et cette page ajoute les donnees metier du site.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-meta-grid">
|
||||
<article class="profile-meta-card">
|
||||
<span class="micro-label">Username</span>
|
||||
<strong>@Profile.Username</strong>
|
||||
</article>
|
||||
<article class="profile-meta-card">
|
||||
<span class="micro-label">Email</span>
|
||||
<strong>@(Profile.Email ?? "Non renseigne")</strong>
|
||||
</article>
|
||||
<article class="profile-meta-card">
|
||||
<span class="micro-label">Cree le</span>
|
||||
<strong>@FormatDate(Profile.CreatedUtc)</strong>
|
||||
</article>
|
||||
<article class="profile-meta-card">
|
||||
<span class="micro-label">Mis a jour</span>
|
||||
<strong>@FormatDate(Profile.UpdatedUtc)</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-half">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Resume du profil</p>
|
||||
<h2>Informations visibles pour le site</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="home-mini-grid two-columns profile-highlights">
|
||||
<article class="mini-panel">
|
||||
<span class="micro-label">Club</span>
|
||||
<strong>@(Profile.Club ?? "A definir")</strong>
|
||||
</article>
|
||||
<article class="mini-panel">
|
||||
<span class="micro-label">Ville</span>
|
||||
<strong>@(Profile.City ?? "A definir")</strong>
|
||||
</article>
|
||||
<article class="mini-panel">
|
||||
<span class="micro-label">Format prefere</span>
|
||||
<strong>@(Profile.PreferredFormat ?? "A definir")</strong>
|
||||
</article>
|
||||
<article class="mini-panel">
|
||||
<span class="micro-label">Cube favori</span>
|
||||
<strong>@(Profile.FavoriteCube ?? "A definir")</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="callout profile-callout">
|
||||
<span class="micro-label">Bio</span>
|
||||
<p>@(Profile.Bio ?? "Aucune bio enregistree pour le moment.")</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-wide">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Edition</p>
|
||||
<h2>Mettre a jour le profil du site</h2>
|
||||
</div>
|
||||
<p class="section-copy">
|
||||
Les champs ci-dessous sont stockes dans MySQL et pourront ensuite
|
||||
servir a enrichir les pages, les inscriptions ou les statistiques.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(SaveError))
|
||||
{
|
||||
<p class="profile-feedback error">@SaveError</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(SaveMessage))
|
||||
{
|
||||
<p class="profile-feedback success">@SaveMessage</p>
|
||||
}
|
||||
|
||||
<EditForm Model="@Form" OnValidSubmit="SaveProfileAsync">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="profile-form-grid">
|
||||
<label class="field">
|
||||
<span>Nom affiche</span>
|
||||
<InputText @bind-Value="Form.DisplayName" />
|
||||
<ValidationMessage For="@(() => Form.DisplayName)" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Club</span>
|
||||
<InputText @bind-Value="Form.Club" />
|
||||
<ValidationMessage For="@(() => Form.Club)" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Ville</span>
|
||||
<InputText @bind-Value="Form.City" />
|
||||
<ValidationMessage For="@(() => Form.City)" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Format prefere</span>
|
||||
<InputSelect @bind-Value="Form.PreferredFormat">
|
||||
<option value="">Aucune preference</option>
|
||||
<option value="Twice">Twice</option>
|
||||
<option value="Time">Time</option>
|
||||
<option value="Les deux">Les deux</option>
|
||||
</InputSelect>
|
||||
<ValidationMessage For="@(() => Form.PreferredFormat)" />
|
||||
</label>
|
||||
|
||||
<label class="field span-2">
|
||||
<span>Cube favori</span>
|
||||
<InputText @bind-Value="Form.FavoriteCube" />
|
||||
<ValidationMessage For="@(() => Form.FavoriteCube)" />
|
||||
</label>
|
||||
|
||||
<label class="field span-2">
|
||||
<span>Bio</span>
|
||||
<InputTextArea @bind-Value="Form.Bio" />
|
||||
<ValidationMessage For="@(() => Form.Bio)" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="profile-actions">
|
||||
<button class="button secondary" type="submit" disabled="@IsSaving">
|
||||
@(IsSaving ? "Enregistrement..." : "Enregistrer le profil")
|
||||
</button>
|
||||
<p class="section-copy">Les champs vides restent facultatifs et peuvent etre completes plus tard.</p>
|
||||
</div>
|
||||
</EditForm>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private readonly UserProfileFormModel Form = new();
|
||||
|
||||
private UserProfileResponse? Profile;
|
||||
private bool IsAuthenticated;
|
||||
private bool IsLoading = true;
|
||||
private bool IsSaving;
|
||||
private string? LoadError;
|
||||
private string? SaveError;
|
||||
private string? SaveMessage;
|
||||
|
||||
private string HeroStatusTitle
|
||||
=> !IsAuthenticated
|
||||
? "Connexion requise"
|
||||
: IsLoading
|
||||
? "Chargement du profil"
|
||||
: Profile?.DisplayName ?? "Profil utilisateur";
|
||||
|
||||
private string HeroStatusDescription
|
||||
=> !IsAuthenticated
|
||||
? "Le profil du site apparait des qu'un compte joueur est connecte."
|
||||
: IsLoading
|
||||
? "Le serveur verifie la fiche utilisateur associee a ce compte."
|
||||
: $"Compte lie a {Profile?.Username ?? "l'utilisateur connecte"} et stocke en base MySQL.";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
||||
await LoadProfileAsync();
|
||||
}
|
||||
|
||||
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
||||
=> _ = InvokeAsync(LoadProfileAsync);
|
||||
|
||||
private async Task LoadProfileAsync()
|
||||
{
|
||||
LoadError = null;
|
||||
SaveError = null;
|
||||
SaveMessage = null;
|
||||
IsLoading = true;
|
||||
|
||||
try
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
if (authState.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
ResetProfileState();
|
||||
return;
|
||||
}
|
||||
|
||||
IsAuthenticated = true;
|
||||
|
||||
var response = await Http.GetAsync("api/users/me");
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
ResetProfileState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
LoadError = await ReadErrorAsync(response, "Le profil utilisateur n'a pas pu etre charge.");
|
||||
Profile = null;
|
||||
return;
|
||||
}
|
||||
|
||||
Profile = await response.Content.ReadFromJsonAsync<UserProfileResponse>();
|
||||
if (Profile is null)
|
||||
{
|
||||
LoadError = "Le serveur a retourne une reponse vide.";
|
||||
return;
|
||||
}
|
||||
|
||||
FillForm(Profile);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
LoadError = "Le service utilisateur est temporairement indisponible.";
|
||||
Profile = null;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
LoadError = "La reponse du service utilisateur a pris trop de temps.";
|
||||
Profile = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveProfileAsync()
|
||||
{
|
||||
if (IsSaving || !IsAuthenticated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsSaving = true;
|
||||
SaveError = null;
|
||||
SaveMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var payload = new UpdateUserProfileRequest
|
||||
{
|
||||
DisplayName = Form.DisplayName,
|
||||
Club = Form.Club,
|
||||
City = Form.City,
|
||||
PreferredFormat = Form.PreferredFormat,
|
||||
FavoriteCube = Form.FavoriteCube,
|
||||
Bio = Form.Bio,
|
||||
};
|
||||
|
||||
var response = await Http.PutAsJsonAsync("api/users/me", payload);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
SaveError = await ReadErrorAsync(response, "L'enregistrement du profil a echoue.");
|
||||
return;
|
||||
}
|
||||
|
||||
Profile = await response.Content.ReadFromJsonAsync<UserProfileResponse>();
|
||||
if (Profile is null)
|
||||
{
|
||||
SaveError = "Le serveur a retourne une reponse vide apres la sauvegarde.";
|
||||
return;
|
||||
}
|
||||
|
||||
FillForm(Profile);
|
||||
SaveMessage = "Le profil utilisateur a bien ete enregistre.";
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
SaveError = "Le service utilisateur est temporairement indisponible.";
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
SaveError = "La sauvegarde a pris trop de temps.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetProfileState()
|
||||
{
|
||||
IsAuthenticated = false;
|
||||
Profile = null;
|
||||
Form.Reset();
|
||||
}
|
||||
|
||||
private void FillForm(UserProfileResponse profile)
|
||||
{
|
||||
Form.DisplayName = profile.DisplayName;
|
||||
Form.Club = profile.Club;
|
||||
Form.City = profile.City;
|
||||
Form.PreferredFormat = profile.PreferredFormat;
|
||||
Form.FavoriteCube = profile.FavoriteCube;
|
||||
Form.Bio = profile.Bio;
|
||||
}
|
||||
|
||||
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.Unauthorized => "La session a expire. Reconnecte-toi puis recharge la page.",
|
||||
_ => fallbackMessage,
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatDate(DateTime value)
|
||||
=> value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR"));
|
||||
|
||||
public void Dispose()
|
||||
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
||||
|
||||
private sealed class ApiErrorMessage
|
||||
{
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
private sealed class UserProfileFormModel
|
||||
{
|
||||
[MaxLength(120, ErrorMessage = "Le nom affiche doit rester sous 120 caracteres.")]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[MaxLength(120, ErrorMessage = "Le club doit rester sous 120 caracteres.")]
|
||||
public string? Club { get; set; }
|
||||
|
||||
[MaxLength(120, ErrorMessage = "La ville doit rester sous 120 caracteres.")]
|
||||
public string? City { get; set; }
|
||||
|
||||
[MaxLength(40, ErrorMessage = "Le format prefere doit rester sous 40 caracteres.")]
|
||||
public string? PreferredFormat { get; set; }
|
||||
|
||||
[MaxLength(120, ErrorMessage = "Le cube favori doit rester sous 120 caracteres.")]
|
||||
public string? FavoriteCube { get; set; }
|
||||
|
||||
[MaxLength(1200, ErrorMessage = "La bio doit rester sous 1200 caracteres.")]
|
||||
public string? Bio { get; set; }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
DisplayName = string.Empty;
|
||||
Club = string.Empty;
|
||||
City = string.Empty;
|
||||
PreferredFormat = string.Empty;
|
||||
FavoriteCube = string.Empty;
|
||||
Bio = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user