feat(web): add public master profiles
PR Checks / test-and-build (pull_request) Successful in 12m32s

Add sanitized public GM profiles with publication controls, public /gm/{slug} pages, and links from public game surfaces.

Bump version -> 3.5.0
This commit is contained in:
2026-05-29 00:08:14 +03:00
parent d81564c308
commit 0c1d3abd7e
21 changed files with 980 additions and 39 deletions
@@ -0,0 +1,136 @@
@page "/gm/{Slug}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation
<PageTitle>@PageTitleText</PageTitle>
@if (loaded && profile is null)
{
<HeadContent>
<meta name="robots" content="noindex, nofollow" />
</HeadContent>
<section class="public-hero public-hero-compact">
<span class="status-badge status-neutral">Недоступно</span>
<h1>Профиль мастера не найден</h1>
<p>Мастер скрыл профиль или этот короткий адрес больше не используется.</p>
</section>
}
else if (!loaded)
{
<section class="public-hero public-hero-compact">
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
<div class="skeleton skeleton-text" style="width: 75%;"></div>
</section>
}
else if (profile is not null)
{
<HeadContent>
<meta name="description" content="@($"Публичный профиль мастера {profile.DisplayName} в GM-Relay.")" />
</HeadContent>
<section class="public-hero public-hero-compact master-profile-hero">
<span class="status-badge status-success">Мастер</span>
<h1>@profile.DisplayName</h1>
@if (!string.IsNullOrWhiteSpace(profile.Bio))
{
<p>@profile.Bio</p>
}
<div class="public-share-row">
<span>Ссылка профиля</span>
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
</div>
</section>
@if (profile.Clubs.Count > 0)
{
<section class="glass-card master-profile-section">
<h2>Клубы</h2>
<div class="master-profile-club-list">
@foreach (var club in profile.Clubs)
{
<a class="status-badge status-info" href="@($"/club/{club.Slug}")">@club.Name</a>
}
</div>
</section>
}
@if (profile.Sessions.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Опубликованных игр пока нет</h2>
<p>Когда мастер откроет игры для каталога, они появятся здесь.</p>
</div>
}
else
{
<div class="public-session-list">
@foreach (var session in profile.Sessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.GroupName</span>
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Открыть</a>
</article>
}
</div>
}
}
@code {
[Parameter] public string? Slug { get; set; }
private GmRelay.Web.Services.PublicMasterProfile? profile;
private bool loaded;
private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay";
private string PublicMasterProfileUrl =>
profile is null
? Navigation.ToAbsoluteUri($"/gm/{Slug}").ToString()
: Navigation.ToAbsoluteUri($"/gm/{profile.Slug}").ToString();
protected override async Task OnParametersSetAsync()
{
loaded = false;
profile = string.IsNullOrWhiteSpace(Slug)
? null
: await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim());
loaded = true;
}
private static string FormatSeats(WebPublicSession session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
return session.WaitlistedPlayerCount > 0
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private static string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private static string TranslateStatus(string status) => status switch
{
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждем подтверждения",
SessionStatus.Confirmed => "Подтверждено",
_ => status
};
}