6cb2fbe610
PR Checks / test-and-build (pull_request) Successful in 7m28s
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>
166 lines
6.2 KiB
Plaintext
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
|
|
};
|
|
}
|