Files
GmRelayBot/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor
T
Toutsu 6cb2fbe610
PR Checks / test-and-build (pull_request) Successful in 7m28s
feat(web): add private club showcases with membership flow (v3.7.0)
Implements Issue #110: game masters can now publish sessions
exclusively to a club's private showcase, gated behind a
member application and approval flow. Adds a 4-state
publication_mode (None/Catalog/ClubOnly/Both) replacing the
binary is_public, plus a club_memberships table with
Pending/Active/Rejected/Left lifecycle and partial unique
index ensuring a single Active row per (group, player).

Highlights
- V030 migration: club_memberships, publication_mode, drop
  is_public, recreate partial indexes, portfolio_games gains
  publication_mode.
- PublicationMode enum + extensions in GmRelay.Shared.
- ISessionStore gains 12 membership/showcase methods;
  AuthorizedMembershipService owns the membership flow with
  GM-only approve/reject authorization.
- PublicClub / PublicMasterProfile / PublicSession: member-
  aware queries (ClubOnly visible only to Active members).
- New pages: MyClubMemberships (/profile/memberships) and
  ClubApplications (/group/{id}/applications).
- GroupDetails and EditSession switch from a bool toggle to
  a 4-state publication_mode selector.
- NavMenu adds Moji kluby, PublicLayout adds Kluby.

Tests: 4 new test files (PublicationMode, ClubMemberships,
AuthorizedMembershipService, ClubShowcaseSource) + updates
to PublicClubPages, AuthorizedSessionService/Portfolio
service FakeSessionStore, CampaignTemplatesNavigation.
493 tests pass.

Bump version 3.6.0 -> 3.7.0 across Directory.Build.props,
compose.yaml, deploy.yml, NavMenu.razor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:09:22 +03:00

166 lines
6.2 KiB
Plaintext

@page "/gm/{Slug}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject IPortfolioStore PortfolioStore
@inject NavigationManager Navigation
@inject IHttpContextAccessor HttpContextAccessor
@using GmRelay.Web.Components.Portfolio
@using GmRelay.Web.Services.Portfolio
<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>
}
@if (portfolioGames.Count > 0)
{
<section class="glass-card portfolio-section">
<h2>Портфолио</h2>
<p>Завершённые игры мастера, открытые для публичного просмотра.</p>
<PortfolioCardGrid Games="portfolioGames" />
</section>
}
}
@code {
[Parameter] public string? Slug { get; set; }
private GmRelay.Web.Services.PublicMasterProfile? profile;
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
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;
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
Guid? viewerPlayerId = null;
var user = HttpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId))
{
var platform = user.FindFirst("Platform")?.Value;
viewerPlayerId = !string.IsNullOrWhiteSpace(platform)
? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId)
: null;
}
profile = trimmedSlug is null
? null
: await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug, viewerPlayerId);
portfolioGames = trimmedSlug is null
? []
: await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug);
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
};
}