Ajoute les amis et les invitations temps reel

This commit is contained in:
2026-04-15 23:08:48 +02:00
parent 9aae4cadc0
commit 8ea6ef8424
18 changed files with 3136 additions and 25 deletions

View File

@@ -7,6 +7,7 @@
@implements IDisposable
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject HttpClient Http
@inject SocialRealtimeService Realtime
<PageTitle>ChessCubing Arena | Utilisateur</PageTitle>
<PageBody BodyClass="home-body" />
@@ -18,12 +19,11 @@
<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>
<h1>Profil joueur, amis et invitations</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.
Cette page regroupe le profil du site, la gestion des amis ChessCubing,
ainsi que les invitations recues ou envoyees. Les confirmations de partie
sont ensuite synchronisees en direct avec SignalR.
</p>
<div class="hero-actions">
<a class="button secondary" href="application.html">Ouvrir l'application</a>
@@ -33,11 +33,11 @@
<aside class="hero-preview">
<div class="preview-card">
<p class="micro-label">Persistance</p>
<strong>Profil du site stocke en MySQL</strong>
<p class="micro-label">Temps reel</p>
<strong>Presence et invitations synchronisees</strong>
<p>
Username et email restent lies au compte authentifie, pendant que
le profil ChessCubing ajoute les donnees utiles au site.
Les amis connectes apparaissent en direct, et les invitations de
partie peuvent etre confirmees depuis un autre device.
</p>
</div>
<div class="preview-banner">
@@ -55,11 +55,12 @@
<div class="section-heading">
<div>
<p class="eyebrow">Connexion requise</p>
<h2>Connecte-toi pour gerer ton profil</h2>
<h2>Connecte-toi pour gerer ton profil et tes amis</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.
en haut de page, puis reviens ici pour enregistrer tes informations
et inviter d'autres joueurs.
</p>
</div>
@@ -86,15 +87,15 @@
{
<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>
<h2>Recuperation de l'espace utilisateur</h2>
<p class="section-copy">Le serveur recharge le profil, les amis et les invitations de 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>
<h2>Impossible de charger l'espace utilisateur</h2>
<p class="profile-feedback error">@LoadError</p>
<div class="hero-actions">
<button class="button secondary" type="button" @onclick="LoadProfileAsync">Reessayer</button>
@@ -217,6 +218,235 @@
</div>
</EditForm>
</section>
<section class="panel panel-wide user-social-panel">
<div class="section-heading user-profile-heading">
<div>
<p class="eyebrow">Reseau joueur</p>
<h2>Amis et invitations</h2>
</div>
<div class="hero-actions">
<button class="button ghost small icon-button" type="button" title="Rafraichir" aria-label="Rafraichir" @onclick="ReloadSocialAsync">
<span class="material-icons action-icon" aria-hidden="true">refresh</span>
</button>
</div>
</div>
<p class="user-profile-note">Invite des joueurs du site, suis les invitations en cours et retrouve rapidement les amis connectes.</p>
@if (!string.IsNullOrWhiteSpace(SocialLoadError))
{
<p class="profile-feedback error">@SocialLoadError</p>
}
@if (!string.IsNullOrWhiteSpace(SocialActionError))
{
<p class="profile-feedback error">@SocialActionError</p>
}
@if (!string.IsNullOrWhiteSpace(SocialActionMessage))
{
<p class="profile-feedback success">@SocialActionMessage</p>
}
<form class="social-search-form" @onsubmit="HandleSearchSubmit" @onsubmit:preventDefault="true">
<label class="field">
<span>Rechercher un joueur</span>
<input @bind="SearchQuery" @bind:event="oninput" type="text" maxlength="80" placeholder="Nom, pseudo, club, ville..." />
</label>
<div class="social-search-actions">
<button class="button secondary small" type="submit" disabled="@IsSearching">
@(IsSearching ? "Recherche..." : "Chercher")
</button>
</div>
</form>
@if (!string.IsNullOrWhiteSpace(SearchError))
{
<p class="profile-feedback error">@SearchError</p>
}
@if (SearchResults.Length > 0)
{
<div class="social-search-results">
@foreach (var result in SearchResults)
{
<article class="social-item">
<div class="social-item-meta">
<div class="social-item-title-row">
<strong>@result.DisplayName</strong>
<span class="@BuildPresenceClass(result.Subject, result.IsOnline)">@BuildPresenceLabel(result.Subject, result.IsOnline)</span>
</div>
<span class="social-item-subtitle">@result.Username</span>
@if (!string.IsNullOrWhiteSpace(result.Club) || !string.IsNullOrWhiteSpace(result.City))
{
<span class="social-item-caption">@JoinNonEmpty(result.Club, result.City)</span>
}
</div>
<div class="social-item-actions">
@if (result.IsFriend)
{
<span class="mini-chip admin-chip-neutral">Deja ami</span>
}
else if (FindReceivedInvitation(result.Subject) is { } receivedInvitation)
{
<button class="button secondary small" type="button" @onclick="() => AcceptInvitationAsync(receivedInvitation.InvitationId)">
Accepter
</button>
<button class="button ghost small" type="button" @onclick="() => DeclineInvitationAsync(receivedInvitation.InvitationId)">
Refuser
</button>
}
else if (FindSentInvitation(result.Subject) is { } sentInvitation)
{
<span class="mini-chip admin-chip-outline">Invitation envoyee</span>
<button class="button ghost small" type="button" @onclick="() => CancelInvitationAsync(sentInvitation.InvitationId)">
Annuler
</button>
}
else
{
<button class="button secondary small" type="button" @onclick="() => SendInvitationAsync(result.Subject)">
Inviter
</button>
}
</div>
</article>
}
</div>
}
<div class="social-columns">
<section class="social-card">
<div class="social-card-head">
<strong>Amis</strong>
<span class="mini-chip admin-chip-outline">@FriendCountLabel</span>
</div>
@if (IsSocialLoading)
{
<p class="social-empty">Chargement des amis...</p>
}
else if (SocialOverview?.Friends.Length > 0)
{
<div class="social-list">
@foreach (var friend in SocialOverview.Friends.OrderByDescending(friend => ResolveOnlineStatus(friend.Subject, friend.IsOnline)).ThenBy(friend => friend.DisplayName, StringComparer.OrdinalIgnoreCase))
{
<article class="social-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>
@if (!string.IsNullOrWhiteSpace(friend.Club) || !string.IsNullOrWhiteSpace(friend.City))
{
<span class="social-item-caption">@JoinNonEmpty(friend.Club, friend.City)</span>
}
</div>
<div class="social-item-actions">
<button class="button ghost small" type="button" @onclick="() => RemoveFriendAsync(friend.Subject)">
Retirer
</button>
</div>
</article>
}
</div>
}
else
{
<p class="social-empty">Aucun ami pour le moment. Utilise la recherche ci-dessus pour commencer.</p>
}
</section>
<section class="social-card">
<div class="social-card-head">
<strong>Invitations recues</strong>
<span class="mini-chip admin-chip-outline">@ReceivedCountLabel</span>
</div>
@if (IsSocialLoading)
{
<p class="social-empty">Chargement des invitations...</p>
}
else if (SocialOverview?.ReceivedInvitations.Length > 0)
{
<div class="social-list">
@foreach (var invitation in SocialOverview.ReceivedInvitations.OrderByDescending(invitation => invitation.CreatedUtc))
{
<article class="social-item">
<div class="social-item-meta">
<div class="social-item-title-row">
<strong>@invitation.DisplayName</strong>
<span class="@BuildPresenceClass(invitation.Subject, invitation.IsOnline)">@BuildPresenceLabel(invitation.Subject, invitation.IsOnline)</span>
</div>
<span class="social-item-subtitle">@invitation.Username</span>
<span class="social-item-caption">Recue le @FormatDate(invitation.CreatedUtc)</span>
</div>
<div class="social-item-actions">
<button class="button secondary small" type="button" @onclick="() => AcceptInvitationAsync(invitation.InvitationId)">
Accepter
</button>
<button class="button ghost small" type="button" @onclick="() => DeclineInvitationAsync(invitation.InvitationId)">
Refuser
</button>
</div>
</article>
}
</div>
}
else
{
<p class="social-empty">Aucune invitation recue pour l'instant.</p>
}
</section>
<section class="social-card">
<div class="social-card-head">
<strong>Invitations envoyees</strong>
<span class="mini-chip admin-chip-outline">@SentCountLabel</span>
</div>
@if (IsSocialLoading)
{
<p class="social-empty">Chargement des invitations...</p>
}
else if (SocialOverview?.SentInvitations.Length > 0)
{
<div class="social-list">
@foreach (var invitation in SocialOverview.SentInvitations.OrderByDescending(invitation => invitation.CreatedUtc))
{
<article class="social-item">
<div class="social-item-meta">
<div class="social-item-title-row">
<strong>@invitation.DisplayName</strong>
<span class="@BuildPresenceClass(invitation.Subject, invitation.IsOnline)">@BuildPresenceLabel(invitation.Subject, invitation.IsOnline)</span>
</div>
<span class="social-item-subtitle">@invitation.Username</span>
<span class="social-item-caption">Envoyee le @FormatDate(invitation.CreatedUtc)</span>
</div>
<div class="social-item-actions">
<button class="button ghost small" type="button" @onclick="() => CancelInvitationAsync(invitation.InvitationId)">
Annuler
</button>
</div>
</article>
}
</div>
}
else
{
<p class="social-empty">Aucune invitation envoyee pour l'instant.</p>
}
</section>
</div>
</section>
}
</main>
</div>
@@ -225,12 +455,22 @@
private readonly UserProfileFormModel Form = new();
private UserProfileResponse? Profile;
private SocialOverviewResponse? SocialOverview;
private SocialSearchUserResponse[] SearchResults = [];
private bool IsAuthenticated;
private bool IsLoading = true;
private bool IsSaving;
private bool IsSocialLoading;
private bool IsSearching;
private int _knownSocialVersion;
private string? LoadError;
private string? SaveError;
private string? SaveMessage;
private string? SocialLoadError;
private string? SocialActionError;
private string? SocialActionMessage;
private string? SearchError;
private string SearchQuery = string.Empty;
private string HeroStatusTitle
=> !IsAuthenticated
@@ -241,20 +481,55 @@
private string HeroStatusDescription
=> !IsAuthenticated
? "Le profil du site apparait des qu'un compte joueur est connecte."
? "Le profil du site et le reseau d'amis apparaissent 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.";
? "Le serveur verifie la fiche utilisateur et les relations sociales associees a ce compte."
: $"Compte lie a {Profile?.Username ?? "l'utilisateur connecte"} avec synchronisation sociale en direct.";
private string FriendCountLabel => $"{SocialOverview?.Friends.Length ?? 0} ami(s)";
private string ReceivedCountLabel => $"{SocialOverview?.ReceivedInvitations.Length ?? 0} recue(s)";
private string SentCountLabel => $"{SocialOverview?.SentInvitations.Length ?? 0} envoyee(s)";
protected override async Task OnInitializedAsync()
{
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
Realtime.Changed += HandleRealtimeChanged;
await Realtime.EnsureStartedAsync();
await LoadProfileAsync();
}
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
=> _ = InvokeAsync(LoadProfileAsync);
private void HandleRealtimeChanged()
=> _ = InvokeAsync(HandleRealtimeChangedAsync);
private async Task HandleRealtimeChangedAsync()
{
if (!IsAuthenticated)
{
StateHasChanged();
return;
}
if (_knownSocialVersion != Realtime.SocialVersion)
{
_knownSocialVersion = Realtime.SocialVersion;
await LoadSocialOverviewAsync();
if (!string.IsNullOrWhiteSpace(SearchQuery))
{
await SearchUsersAsync();
}
return;
}
StateHasChanged();
}
private async Task LoadProfileAsync()
{
LoadError = null;
@@ -268,6 +543,7 @@
if (authState.User.Identity?.IsAuthenticated != true)
{
ResetProfileState();
ResetSocialState();
return;
}
@@ -277,6 +553,7 @@
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
ResetProfileState();
ResetSocialState();
return;
}
@@ -284,6 +561,7 @@
{
LoadError = await ReadErrorAsync(response, "Le profil utilisateur n'a pas pu etre charge.");
Profile = null;
ResetSocialState();
return;
}
@@ -291,20 +569,24 @@
if (Profile is null)
{
LoadError = "Le serveur a retourne une reponse vide.";
ResetSocialState();
return;
}
FillForm(Profile);
await LoadSocialOverviewAsync();
}
catch (HttpRequestException)
{
LoadError = "Le service utilisateur est temporairement indisponible.";
Profile = null;
ResetSocialState();
}
catch (TaskCanceledException)
{
LoadError = "La reponse du service utilisateur a pris trop de temps.";
Profile = null;
ResetSocialState();
}
finally
{
@@ -313,6 +595,43 @@
}
}
private async Task LoadSocialOverviewAsync()
{
SocialLoadError = null;
IsSocialLoading = true;
try
{
var response = await Http.GetAsync("api/social/overview");
if (!response.IsSuccessStatusCode)
{
SocialLoadError = await ReadErrorAsync(response, "Le reseau social n'a pas pu etre charge.");
SocialOverview = null;
SearchResults = [];
return;
}
SocialOverview = await response.Content.ReadFromJsonAsync<SocialOverviewResponse>();
SocialOverview ??= new SocialOverviewResponse();
_knownSocialVersion = Realtime.SocialVersion;
}
catch (HttpRequestException)
{
SocialLoadError = "Le service social est temporairement indisponible.";
SocialOverview = null;
}
catch (TaskCanceledException)
{
SocialLoadError = "Le chargement du reseau social a pris trop de temps.";
SocialOverview = null;
}
finally
{
IsSocialLoading = false;
StateHasChanged();
}
}
private async Task SaveProfileAsync()
{
if (IsSaving || !IsAuthenticated)
@@ -367,6 +686,165 @@
}
}
private Task HandleSearchSubmit()
=> SearchUsersAsync();
private async Task SearchUsersAsync()
{
SearchError = null;
SocialActionError = null;
SocialActionMessage = null;
var normalizedQuery = SearchQuery.Trim();
if (string.IsNullOrWhiteSpace(normalizedQuery))
{
SearchResults = [];
StateHasChanged();
return;
}
IsSearching = true;
try
{
var response = await Http.GetAsync($"api/social/search?query={Uri.EscapeDataString(normalizedQuery)}");
if (!response.IsSuccessStatusCode)
{
SearchError = await ReadErrorAsync(response, "La recherche de joueurs a echoue.");
SearchResults = [];
return;
}
SearchResults = await response.Content.ReadFromJsonAsync<SocialSearchUserResponse[]>() ?? [];
}
catch (HttpRequestException)
{
SearchError = "Le service social est temporairement indisponible.";
SearchResults = [];
}
catch (TaskCanceledException)
{
SearchError = "La recherche a pris trop de temps.";
SearchResults = [];
}
finally
{
IsSearching = false;
StateHasChanged();
}
}
private async Task ReloadSocialAsync()
{
await LoadSocialOverviewAsync();
if (!string.IsNullOrWhiteSpace(SearchQuery))
{
await SearchUsersAsync();
}
}
private async Task SendInvitationAsync(string subject)
{
await RunSocialActionAsync(
async () =>
{
var payload = new SendFriendInvitationRequest { TargetSubject = subject };
var response = await Http.PostAsJsonAsync("api/social/invitations", payload);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await ReadErrorAsync(response, "L'invitation n'a pas pu etre envoyee."));
}
},
"Invitation d'ami envoyee.");
}
private async Task AcceptInvitationAsync(long invitationId)
{
await RunSocialActionAsync(
async () =>
{
var response = await Http.PostAsync($"api/social/invitations/{invitationId}/accept", content: null);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await ReadErrorAsync(response, "L'invitation n'a pas pu etre acceptee."));
}
},
"Invitation acceptee. Le joueur a ete ajoute aux amis.");
}
private async Task DeclineInvitationAsync(long invitationId)
{
await RunSocialActionAsync(
async () =>
{
var response = await Http.PostAsync($"api/social/invitations/{invitationId}/decline", content: null);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await ReadErrorAsync(response, "L'invitation n'a pas pu etre refusee."));
}
},
"Invitation refusee.");
}
private async Task CancelInvitationAsync(long invitationId)
{
await RunSocialActionAsync(
async () =>
{
var response = await Http.DeleteAsync($"api/social/invitations/{invitationId}");
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await ReadErrorAsync(response, "L'invitation n'a pas pu etre annulee."));
}
},
"Invitation annulee.");
}
private async Task RemoveFriendAsync(string friendSubject)
{
await RunSocialActionAsync(
async () =>
{
var response = await Http.DeleteAsync($"api/social/friends/{Uri.EscapeDataString(friendSubject)}");
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await ReadErrorAsync(response, "L'ami n'a pas pu etre retire."));
}
},
"Relation d'amitie retiree.");
}
private async Task RunSocialActionAsync(Func<Task> action, string successMessage)
{
SocialActionError = null;
SocialActionMessage = null;
try
{
await action();
SocialActionMessage = successMessage;
await LoadSocialOverviewAsync();
if (!string.IsNullOrWhiteSpace(SearchQuery))
{
await SearchUsersAsync();
}
}
catch (InvalidOperationException exception)
{
SocialActionError = exception.Message;
}
catch (HttpRequestException)
{
SocialActionError = "Le service social est temporairement indisponible.";
}
catch (TaskCanceledException)
{
SocialActionError = "L'action sociale a pris trop de temps.";
}
}
private void ResetProfileState()
{
IsAuthenticated = false;
@@ -374,6 +852,18 @@
Form.Reset();
}
private void ResetSocialState()
{
SocialOverview = null;
SearchResults = [];
SearchQuery = string.Empty;
SearchError = null;
SocialLoadError = null;
SocialActionError = null;
SocialActionMessage = null;
_knownSocialVersion = Realtime.SocialVersion;
}
private void FillForm(UserProfileResponse profile)
{
Form.DisplayName = profile.DisplayName;
@@ -384,6 +874,28 @@
Form.Bio = profile.Bio;
}
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 SocialInvitationResponse? FindReceivedInvitation(string subject)
=> SocialOverview?.ReceivedInvitations.FirstOrDefault(invitation => string.Equals(invitation.Subject, subject, StringComparison.Ordinal));
private SocialInvitationResponse? FindSentInvitation(string subject)
=> SocialOverview?.SentInvitations.FirstOrDefault(invitation => string.Equals(invitation.Subject, subject, StringComparison.Ordinal));
private static string JoinNonEmpty(params string?[] values)
=> string.Join(" • ", values.Where(value => !string.IsNullOrWhiteSpace(value)));
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, string fallbackMessage)
{
try
@@ -409,7 +921,10 @@
=> value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR"));
public void Dispose()
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
{
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
Realtime.Changed -= HandleRealtimeChanged;
}
private sealed class ApiErrorMessage
{