feat(web): add public master profiles
PR Checks / test-and-build (pull_request) Successful in 12m32s
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:
@@ -73,7 +73,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.4.0</div>
|
||||
<div class="nav-version">v3.5.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using Microsoft.Extensions.Configuration
|
||||
@attribute [Authorize]
|
||||
@inject ISessionStore SessionStore
|
||||
@inject AuthorizedSessionService AuthorizedSessionService
|
||||
@inject IConfiguration Configuration
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -12,6 +13,65 @@
|
||||
<div class="profile-container">
|
||||
<h1 class="page-title">Профиль</h1>
|
||||
|
||||
@if (masterProfile is not null)
|
||||
{
|
||||
<div class="profile-card master-profile-card">
|
||||
<div class="profile-card-header">
|
||||
<div>
|
||||
<h2 class="section-title">Публичный профиль мастера</h2>
|
||||
<p class="muted-text">Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p>
|
||||
</div>
|
||||
<span class="identity-badge">@(masterProfile.IsPublic ? "Публичный" : "Скрыт")</span>
|
||||
</div>
|
||||
|
||||
<EditForm Model="@masterProfileModel" OnValidSubmit="SaveMasterProfile">
|
||||
<div class="gm-form-group public-toggle-field">
|
||||
<label class="gm-checkbox-label">
|
||||
<InputCheckbox @bind-Value="masterProfileModel.IsPublic" />
|
||||
<span>Опубликовать профиль</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="profile-form-grid">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Имя в публичном профиле</label>
|
||||
<InputText @bind-Value="masterProfileModel.DisplayName" class="gm-form-control" />
|
||||
</div>
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Короткий адрес</label>
|
||||
<InputText @bind-Value="masterProfileModel.PublicSlug" class="gm-form-control" />
|
||||
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-gm`.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Описание</label>
|
||||
<InputTextArea @bind-Value="masterProfileModel.Bio" class="gm-form-control master-profile-bio" />
|
||||
</div>
|
||||
|
||||
<div class="public-settings-actions">
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingMasterProfile">
|
||||
@(savingMasterProfile ? "Сохраняем..." : "Сохранить профиль")
|
||||
</button>
|
||||
@if (PublicMasterProfileUrl is not null)
|
||||
{
|
||||
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
|
||||
Открыть публичный профиль
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@if (PublicMasterProfileUrl is not null)
|
||||
{
|
||||
<div class="public-link-row">
|
||||
<span>Ссылка профиля</span>
|
||||
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (identities is null)
|
||||
{
|
||||
<p class="loading-text">Загрузка...</p>
|
||||
@@ -92,11 +152,14 @@
|
||||
|
||||
@code {
|
||||
private List<LinkedIdentity>? identities;
|
||||
private MasterProfileSettings? masterProfile;
|
||||
private string? currentPlatform;
|
||||
private string? currentExternalUserId;
|
||||
private bool isUnlinking;
|
||||
private bool savingMasterProfile;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private MasterProfileEditModel masterProfileModel = new();
|
||||
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
||||
@@ -131,6 +194,7 @@
|
||||
}
|
||||
|
||||
await LoadIdentities();
|
||||
await LoadMasterProfile();
|
||||
}
|
||||
|
||||
private async Task LoadIdentities()
|
||||
@@ -152,6 +216,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadMasterProfile()
|
||||
{
|
||||
try
|
||||
{
|
||||
masterProfile = await AuthorizedSessionService.GetMasterProfileSettingsForCurrentUserAsync();
|
||||
if (masterProfile is not null)
|
||||
{
|
||||
masterProfileModel = new MasterProfileEditModel
|
||||
{
|
||||
DisplayName = masterProfile.DisplayName,
|
||||
PublicSlug = masterProfile.PublicSlug ?? string.Empty,
|
||||
IsPublic = masterProfile.IsPublic,
|
||||
Bio = masterProfile.Bio ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Не удалось загрузить профиль мастера: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private string? PublicMasterProfileUrl =>
|
||||
masterProfile?.IsPublic == true && !string.IsNullOrWhiteSpace(masterProfile.PublicSlug)
|
||||
? Navigation.ToAbsoluteUri($"/gm/{masterProfile.PublicSlug}").ToString()
|
||||
: null;
|
||||
|
||||
private async Task SaveMasterProfile()
|
||||
{
|
||||
savingMasterProfile = true;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await AuthorizedSessionService.UpdateMasterProfileSettingsForCurrentUserAsync(
|
||||
masterProfileModel.PublicSlug,
|
||||
masterProfileModel.IsPublic,
|
||||
masterProfileModel.DisplayName,
|
||||
masterProfileModel.Bio);
|
||||
|
||||
successMessage = "Публичный профиль мастера обновлён.";
|
||||
await LoadMasterProfile();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Не удалось сохранить профиль мастера: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingMasterProfile = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasLinkedPlatform(string platform)
|
||||
{
|
||||
return identities?.Any(i => i.Platform == platform) ?? false;
|
||||
@@ -188,4 +306,12 @@
|
||||
isUnlinking = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MasterProfileEditModel
|
||||
{
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string PublicSlug { get; set; } = string.Empty;
|
||||
public bool IsPublic { get; set; }
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@ else if (club is not null)
|
||||
<span>Ссылка клуба</span>
|
||||
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(club.MasterProfileSlug))
|
||||
{
|
||||
<div class="public-share-row">
|
||||
<span>Мастер</span>
|
||||
<a href="@MasterProfilePath(club.MasterProfileSlug)" target="_blank" rel="noopener noreferrer">
|
||||
@(club.MasterDisplayName ?? "Профиль мастера")
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (club.Sessions.Count == 0)
|
||||
@@ -92,6 +101,8 @@ else if (club is not null)
|
||||
|
||||
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
|
||||
|
||||
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||
|
||||
private static string FormatSeats(WebPublicSession session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,13 @@ else if (session is not null)
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
<h1>@session.Title</h1>
|
||||
<p>@session.GroupName</p>
|
||||
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||
{
|
||||
<div class="public-master-link">
|
||||
<span>Мастер</span>
|
||||
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
|
||||
</div>
|
||||
}
|
||||
<div class="session-badges">
|
||||
@if (!string.IsNullOrWhiteSpace(session.System))
|
||||
{
|
||||
@@ -101,6 +108,10 @@ else if (session is not null)
|
||||
{
|
||||
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||
{
|
||||
<a class="btn-gm btn-gm-outline" href="@MasterProfilePath(session.MasterProfileSlug)">Мастер</a>
|
||||
}
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
|
||||
@if (session.AllowDirectRegistration)
|
||||
{
|
||||
@@ -129,6 +140,8 @@ else if (session is not null)
|
||||
|
||||
private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString();
|
||||
|
||||
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loaded = false;
|
||||
|
||||
@@ -130,6 +130,12 @@ else
|
||||
<div class="showcase-card-club">
|
||||
<span>@session.GroupName</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
||||
{
|
||||
<div class="showcase-card-master">
|
||||
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
|
||||
</div>
|
||||
}
|
||||
<div class="showcase-card-actions">
|
||||
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Подробнее</a>
|
||||
@if (session.AllowDirectRegistration)
|
||||
@@ -254,7 +260,8 @@ else
|
||||
|
||||
.showcase-card-meta,
|
||||
.showcase-card-seats,
|
||||
.showcase-card-club {
|
||||
.showcase-card-club,
|
||||
.showcase-card-master {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Jura', sans-serif;
|
||||
@@ -264,6 +271,12 @@ else
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.showcase-card-master a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.showcase-card-actions {
|
||||
margin-top: auto;
|
||||
padding-top: 0.75rem;
|
||||
@@ -428,6 +441,8 @@ else
|
||||
return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч";
|
||||
}
|
||||
|
||||
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
||||
|
||||
private static string TranslateFormat(string format) => format switch
|
||||
{
|
||||
"Online" => "Онлайн",
|
||||
|
||||
Reference in New Issue
Block a user