feat(web): add private club showcases with membership flow (v3.7.0)
PR Checks / test-and-build (pull_request) Successful in 7m28s
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>
This commit is contained in:
@@ -41,6 +41,15 @@
|
||||
</svg>
|
||||
Профиль
|
||||
</NavLink>
|
||||
|
||||
<NavLink class="nav-item" href="profile/memberships" @onclick="CloseMenu">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 21h18"/>
|
||||
<path d="M5 21V7l8-4v18"/>
|
||||
<path d="M19 21V11l-6-4"/>
|
||||
</svg>
|
||||
Мои клубы
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-footer">
|
||||
@@ -73,7 +82,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.6.0</div>
|
||||
<div class="nav-version">v3.7.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
<img src="/logo.png" alt="GM-Relay" />
|
||||
<span>GM-Relay</span>
|
||||
</a>
|
||||
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
|
||||
<div class="public-topbar-actions">
|
||||
<a class="btn-gm btn-gm-outline" href="/showcase">Клубы</a>
|
||||
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="public-content">
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
@page "/group/{GroupId:guid}/applications"
|
||||
@using GmRelay.Web.Services
|
||||
@using GmRelay.Shared.Domain
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedMembershipService MembershipService
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
@using System.Security.Claims
|
||||
|
||||
<PageTitle>Заявки участников — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li><a href="@($"/group/{GroupId}")">Группа</a></li>
|
||||
<li class="active">Заявки</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>📨 Заявки участников</h2>
|
||||
<p>Одобряйте или отклоняйте заявки на участие в клубе.</p>
|
||||
</div>
|
||||
|
||||
@if (accessDenied)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Нет доступа</h2>
|
||||
<p>Только owner или co-GM группы могут просматривать заявки.</p>
|
||||
</div>
|
||||
}
|
||||
else if (applications is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 70%; height: 2rem; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 90%;"></div>
|
||||
</div>
|
||||
}
|
||||
else if (applications.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Новых заявок нет</h2>
|
||||
<p>Когда игроки подадут заявку на участие в клубе, она появится здесь.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="application-list">
|
||||
@foreach (var app in applications)
|
||||
{
|
||||
<li class="glass-card application-item">
|
||||
<div class="application-info">
|
||||
<strong>@app.DisplayName</strong>
|
||||
<span class="status-badge status-neutral">@app.Platform</span>
|
||||
@if (!string.IsNullOrWhiteSpace(app.ExternalUsername))
|
||||
{
|
||||
<span class="application-meta">@app.ExternalUsername</span>
|
||||
}
|
||||
<span class="application-meta">@app.AppliedAt.ToString("dd.MM.yyyy HH:mm")</span>
|
||||
@if (!string.IsNullOrWhiteSpace(app.Message))
|
||||
{
|
||||
<p class="application-message">«@app.Message»</p>
|
||||
}
|
||||
</div>
|
||||
<div class="application-actions">
|
||||
<button type="button" class="btn-gm btn-gm-success" disabled="@(busyMembershipId is not null)" @onclick="() => Approve(app.MembershipId)">
|
||||
✅ Одобрить
|
||||
</button>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(busyMembershipId is not null)" @onclick="() => Reject(app.MembershipId)">
|
||||
❌ Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid GroupId { get; set; }
|
||||
|
||||
private List<WebPendingApplication>? applications;
|
||||
private bool accessDenied;
|
||||
private string? errorMessage;
|
||||
private Guid? busyMembershipId;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
accessDenied = false;
|
||||
try
|
||||
{
|
||||
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
accessDenied = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Approve(Guid membershipId)
|
||||
{
|
||||
errorMessage = null;
|
||||
busyMembershipId = membershipId;
|
||||
try
|
||||
{
|
||||
await MembershipService.ApproveForCurrentGmAsync(membershipId);
|
||||
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
accessDenied = true;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
busyMembershipId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Reject(Guid membershipId)
|
||||
{
|
||||
errorMessage = null;
|
||||
busyMembershipId = membershipId;
|
||||
try
|
||||
{
|
||||
await MembershipService.RejectForCurrentGmAsync(membershipId);
|
||||
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
accessDenied = true;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
busyMembershipId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,16 @@
|
||||
<div class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
|
||||
</div>
|
||||
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Режим публикации</label>
|
||||
<InputSelect @bind-Value="model.PublicationMode" class="gm-form-control">
|
||||
<option value="@PublicationModeExtensions.NoneValue">Скрыта</option>
|
||||
<option value="@PublicationModeExtensions.CatalogValue">В каталоге</option>
|
||||
<option value="@PublicationModeExtensions.ClubOnlyValue">Только для участников клуба</option>
|
||||
<option value="@PublicationModeExtensions.BothValue">Каталог + клуб</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
|
||||
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
|
||||
@@ -104,6 +114,7 @@
|
||||
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
||||
model.JoinLink = session.JoinLink;
|
||||
model.MaxPlayers = session.MaxPlayers;
|
||||
model.PublicationMode = session.PublicationMode;
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
@@ -123,6 +134,7 @@
|
||||
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
||||
|
||||
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
|
||||
await SessionService.SetSessionPublicationModeForCurrentUserAsync(SessionId, PublicationModeExtensions.FromDatabaseValue(model.PublicationMode));
|
||||
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
@@ -147,5 +159,6 @@
|
||||
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
|
||||
public string JoinLink { get; set; } = "";
|
||||
public int? MaxPlayers { get; set; }
|
||||
public string PublicationMode { get; set; } = PublicationModeExtensions.NoneValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedSessionService SessionService
|
||||
@inject AuthorizedPortfolioService PortfolioService
|
||||
@inject AuthorizedMembershipService MembershipService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -126,6 +127,14 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (pendingApplicationsCount > 0)
|
||||
{
|
||||
<a class="glass-card applications-card" href="@($"/group/{GroupId}/applications")">
|
||||
<span class="status-badge status-warning">📨 Заявки участников (@pendingApplicationsCount)</span>
|
||||
<span>Рассмотреть заявки на участие в клубе</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
@@ -313,11 +322,12 @@
|
||||
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
|
||||
@FormatBatchPublication(batch)
|
||||
</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
|
||||
@(IsBatchPublishBusy(batch)
|
||||
? "Обновляем..."
|
||||
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
|
||||
</button>
|
||||
<select class="gm-form-control" disabled="@IsBatchPublishBusy(batch)" @onchange="args => SetBatchPublicationMode(batch, ParseMode(args.Value))">
|
||||
<option value="None" selected="@(batch.PublicationMode == PublicationMode.None)">Скрыта</option>
|
||||
<option value="Catalog" selected="@(batch.PublicationMode == PublicationMode.Catalog)">Каталог</option>
|
||||
<option value="ClubOnly" selected="@(batch.PublicationMode == PublicationMode.ClubOnly)">Только участники</option>
|
||||
<option value="Both" selected="@(batch.PublicationMode == PublicationMode.Both)">Каталог + клуб</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="batch-clone-row">
|
||||
@@ -369,11 +379,12 @@
|
||||
<td>
|
||||
<div class="session-table-actions">
|
||||
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
|
||||
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
<select class="gm-form-control" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
|
||||
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
|
||||
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
|
||||
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
|
||||
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
|
||||
</select>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
|
||||
@@ -466,11 +477,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-card-actions">
|
||||
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
|
||||
@(publishingSessionId == session.Id
|
||||
? "Обновляем..."
|
||||
: session.IsPublic ? "Скрыть" : "Опубликовать")
|
||||
</button>
|
||||
<select class="gm-form-control" style="flex: 1; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
|
||||
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
|
||||
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
|
||||
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
|
||||
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
|
||||
</select>
|
||||
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
|
||||
{
|
||||
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
|
||||
@@ -540,6 +552,7 @@
|
||||
private IReadOnlyList<PortfolioGameSummary>? portfolioGames;
|
||||
private List<BatchBulkEditModel> batchModels = [];
|
||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||
private int pendingApplicationsCount;
|
||||
private Guid? promotingSessionId;
|
||||
private Guid? processingBatchId;
|
||||
private Guid? processingTemplateId;
|
||||
@@ -605,6 +618,8 @@
|
||||
|
||||
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
|
||||
|
||||
pendingApplicationsCount = await MembershipService.GetPendingApplicationsCountForCurrentGmAsync(GroupId);
|
||||
|
||||
RebuildBatchModels();
|
||||
RebuildCampaignTemplateModels();
|
||||
RebuildPublicSettingsModel();
|
||||
@@ -664,7 +679,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
|
||||
private async Task SetBatchPublicationMode(BatchBulkEditModel batch, PublicationMode mode)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
@@ -672,10 +687,14 @@
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Batch опубликован в публичном расписании."
|
||||
: "Batch скрыт из публичного расписания.";
|
||||
await SessionService.SetBatchPublicationModeForCurrentUserAsync(batch.BatchId, mode);
|
||||
successMessage = mode switch
|
||||
{
|
||||
PublicationMode.Catalog => "Batch опубликован в общем каталоге.",
|
||||
PublicationMode.ClubOnly => "Batch доступен только участникам клуба.",
|
||||
PublicationMode.Both => "Batch опубликован в каталоге и доступен участникам клуба.",
|
||||
_ => "Batch скрыт из публичного расписания."
|
||||
};
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
@@ -692,7 +711,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
|
||||
private async Task SetSessionPublicationMode(Guid sessionId, PublicationMode mode)
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
@@ -700,10 +719,14 @@
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
|
||||
successMessage = isPublic
|
||||
? "Сессия опубликована в публичном расписании."
|
||||
: "Сессия скрыта из публичного расписания.";
|
||||
await SessionService.SetSessionPublicationModeForCurrentUserAsync(sessionId, mode);
|
||||
successMessage = mode switch
|
||||
{
|
||||
PublicationMode.Catalog => "Сессия опубликована в общем каталоге.",
|
||||
PublicationMode.ClubOnly => "Сессия доступна только участникам клуба.",
|
||||
PublicationMode.Both => "Сессия опубликована в каталоге и доступна участникам клуба.",
|
||||
_ => "Сессия скрыта из публичного расписания."
|
||||
};
|
||||
await LoadSessions();
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
@@ -1073,7 +1096,13 @@
|
||||
IntervalDays = InferIntervalDays(orderedSessions),
|
||||
SessionCount = orderedSessions.Count,
|
||||
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
|
||||
AllSessionsPublic = orderedSessions.All(session => session.IsPublic)
|
||||
AllSessionsPublic = orderedSessions.All(session => session.IsPublic),
|
||||
PublicationMode = orderedSessions
|
||||
.Select(s => PublicationModeExtensions.FromDatabaseValue(s.PublicationMode))
|
||||
.GroupBy(m => m)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.First()
|
||||
.Key
|
||||
};
|
||||
})
|
||||
.OrderBy(batch => batch.FirstScheduledAtLocal)
|
||||
@@ -1220,6 +1249,9 @@
|
||||
: seats;
|
||||
}
|
||||
|
||||
private static PublicationMode ParseMode(object? value) =>
|
||||
Enum.TryParse<PublicationMode>(value?.ToString(), out var mode) ? mode : PublicationMode.None;
|
||||
|
||||
private static string FormatBatchSummary(BatchBulkEditModel batch) =>
|
||||
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
|
||||
|
||||
@@ -1272,6 +1304,7 @@
|
||||
public int SessionCount { get; init; }
|
||||
public int PublicSessionCount { get; init; }
|
||||
public bool AllSessionsPublic { get; init; }
|
||||
public PublicationMode PublicationMode { get; set; } = PublicationMode.None;
|
||||
public string CloneInterval { get; set; } = "week";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
@page "/profile/memberships"
|
||||
@using GmRelay.Web.Services
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize]
|
||||
@inject AuthorizedMembershipService MembershipService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Мои клубы — GM-Relay</PageTitle>
|
||||
|
||||
<div class="page-container">
|
||||
<ul class="gm-breadcrumb animate-fade-in">
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li class="active">Мои клубы</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-header animate-fade-in">
|
||||
<h2>🏛 Мои клубы</h2>
|
||||
<p>Заявки и активные участия в приватных клубных витринах.</p>
|
||||
</div>
|
||||
|
||||
@if (memberships is null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
<div class="skeleton skeleton-text" style="width: 60%; height: 2rem; margin-bottom: 1rem;"></div>
|
||||
<div class="skeleton skeleton-text" style="width: 80%;"></div>
|
||||
</div>
|
||||
}
|
||||
else if (memberships.Count == 0)
|
||||
{
|
||||
<div class="glass-card public-empty-state">
|
||||
<h2>Вы пока не подавали заявок</h2>
|
||||
<p>Откройте публичную витрину клуба и нажмите «Подать заявку», чтобы стать участником.</p>
|
||||
<a class="btn-gm btn-gm-primary" href="/showcase">К каталогу клубов</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (activeMemberships.Count > 0)
|
||||
{
|
||||
<section class="glass-card animate-slide-up">
|
||||
<h3>Активные участия</h3>
|
||||
<ul class="membership-list">
|
||||
@foreach (var membership in activeMemberships)
|
||||
{
|
||||
<li>
|
||||
<div class="membership-info">
|
||||
<a href="@($"/club/{membership.GroupSlug ?? membership.GroupId.ToString()}")" class="membership-name">
|
||||
@membership.GroupName
|
||||
</a>
|
||||
<span class="status-badge status-success">Участник</span>
|
||||
</div>
|
||||
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
|
||||
Покинуть клуб
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (pendingMemberships.Count > 0)
|
||||
{
|
||||
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
|
||||
<h3>Заявки на рассмотрении</h3>
|
||||
<ul class="membership-list">
|
||||
@foreach (var membership in pendingMemberships)
|
||||
{
|
||||
<li>
|
||||
<div class="membership-info">
|
||||
<span class="membership-name">@membership.GroupName</span>
|
||||
<span class="status-badge status-warning">Ожидает одобрения</span>
|
||||
</div>
|
||||
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
|
||||
Отозвать заявку
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (historyMemberships.Count > 0)
|
||||
{
|
||||
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
|
||||
<h3>История</h3>
|
||||
<ul class="membership-list">
|
||||
@foreach (var membership in historyMemberships)
|
||||
{
|
||||
<li>
|
||||
<div class="membership-info">
|
||||
<span class="membership-name">@membership.GroupName</span>
|
||||
<span class="status-badge @(membership.Status == "Rejected" ? "status-danger" : "status-neutral")">
|
||||
@(membership.Status == "Rejected" ? "Отклонена" : "Вы покинули клуб")
|
||||
</span>
|
||||
@if (membership.DecidedAt is not null)
|
||||
{
|
||||
<span class="membership-meta">@membership.DecidedAt.Value.ToString("dd.MM.yyyy")</span>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<WebMembership>? memberships;
|
||||
private List<WebMembership> activeMemberships = [];
|
||||
private List<WebMembership> pendingMemberships = [];
|
||||
private List<WebMembership> historyMemberships = [];
|
||||
private string? errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
errorMessage = null;
|
||||
memberships = await MembershipService.GetMineAsync();
|
||||
activeMemberships = memberships.Where(m => m.Status == "Active").ToList();
|
||||
pendingMemberships = memberships.Where(m => m.Status == "Pending").ToList();
|
||||
historyMemberships = memberships.Where(m => m.Status is "Rejected" or "Left").ToList();
|
||||
}
|
||||
|
||||
private async Task Leave(Guid membershipId)
|
||||
{
|
||||
errorMessage = null;
|
||||
try
|
||||
{
|
||||
await MembershipService.LeaveClubForCurrentUserAsync(membershipId);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
@inject ISessionStore SessionStore
|
||||
@inject IPortfolioStore PortfolioStore
|
||||
@inject NavigationManager Navigation
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
@inject AuthorizedMembershipService MembershipService
|
||||
@using System.Security.Claims
|
||||
@using GmRelay.Web.Components.Portfolio
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
@@ -61,22 +64,79 @@ else if (club is not null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in club.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.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
var publicSessions = club.Sessions.Where(s => !s.IsMembersOnly).ToList();
|
||||
var membersOnlySessions = club.Sessions.Where(s => s.IsMembersOnly).ToList();
|
||||
|
||||
@if (publicSessions.Count > 0)
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in publicSessions)
|
||||
{
|
||||
<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.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (membersOnlySessions.Count > 0)
|
||||
{
|
||||
<section class="glass-card members-only-section">
|
||||
<h2>Игры для участников клуба</h2>
|
||||
@if (viewerIsActiveMember)
|
||||
{
|
||||
<div class="public-session-list">
|
||||
@foreach (var session in membersOnlySessions)
|
||||
{
|
||||
<article class="public-session-card">
|
||||
<div class="public-session-main">
|
||||
<span class="status-badge status-warning">Только для участников</span>
|
||||
<h2>@session.Title</h2>
|
||||
<div class="public-session-meta">
|
||||
<span>@session.ScheduledAt.FormatMoscow()</span>
|
||||
<span>@FormatSeats(session)</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Эти сессии доступны только одобренным участникам клуба.</p>
|
||||
@if (viewerPlayerId is null)
|
||||
{
|
||||
<a class="btn-gm btn-gm-primary" href="/login?returnUrl=/club/@Slug">Войти как участник</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<details class="application-form">
|
||||
<summary class="btn-gm btn-gm-primary">Подать заявку</summary>
|
||||
<EditForm Model="@this" OnValidSubmit="TrySubmitApplicationAsync">
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label" for="applicationMessage">Сообщение мастеру (необязательно)</label>
|
||||
<textarea id="applicationMessage" class="gm-form-control" maxlength="1000" @bind="applicationMessage" rows="3"></textarea>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(applicationError))
|
||||
{
|
||||
<p class="form-error">@applicationError</p>
|
||||
}
|
||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSubmittingApplication">Отправить</button>
|
||||
</EditForm>
|
||||
</details>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (portfolioGames.Count > 0)
|
||||
@@ -95,6 +155,39 @@ else if (club is not null)
|
||||
private WebPublicClub? club;
|
||||
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
|
||||
private bool loaded;
|
||||
private Guid? viewerPlayerId;
|
||||
private bool viewerIsActiveMember;
|
||||
private string? applicationError;
|
||||
private string? applicationMessage;
|
||||
private bool isSubmittingApplication;
|
||||
|
||||
private async Task TrySubmitApplicationAsync()
|
||||
{
|
||||
applicationError = null;
|
||||
if (club is null)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(applicationMessage))
|
||||
{
|
||||
applicationError = "Введите сообщение или оставьте поле пустым.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
isSubmittingApplication = true;
|
||||
await MembershipService.ApplyForCurrentUserAsync(club.GroupId, applicationMessage);
|
||||
applicationMessage = null;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
applicationError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSubmittingApplication = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
|
||||
|
||||
@@ -107,12 +200,41 @@ else if (club is not null)
|
||||
{
|
||||
loaded = false;
|
||||
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
|
||||
applicationError = null;
|
||||
applicationMessage = null;
|
||||
|
||||
// Resolve viewer identity (player id) for member-aware access.
|
||||
var user = HttpContextAccessor.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId))
|
||||
{
|
||||
// We don't have platform here, but AuthorizedSessionService resolves via claims; use SessionStore directly
|
||||
// by reading both claims. Simpler: only resolve when both Platform and externalUserId are present.
|
||||
var platform = user.FindFirst("Platform")?.Value;
|
||||
viewerPlayerId = !string.IsNullOrWhiteSpace(platform)
|
||||
? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId)
|
||||
: null;
|
||||
}
|
||||
else
|
||||
{
|
||||
viewerPlayerId = null;
|
||||
}
|
||||
|
||||
club = trimmedSlug is null
|
||||
? null
|
||||
: await SessionStore.GetPublicClubBySlugAsync(trimmedSlug);
|
||||
: await SessionStore.GetPublicClubBySlugAsync(trimmedSlug, viewerPlayerId);
|
||||
portfolioGames = trimmedSlug is null
|
||||
? []
|
||||
: await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug);
|
||||
|
||||
if (club is not null && viewerPlayerId is not null)
|
||||
{
|
||||
viewerIsActiveMember = await SessionStore.IsActiveClubMemberAsync(club.GroupId, viewerPlayerId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
viewerIsActiveMember = false;
|
||||
}
|
||||
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@inject ISessionStore SessionStore
|
||||
@inject IPortfolioStore PortfolioStore
|
||||
@inject NavigationManager Navigation
|
||||
@inject IHttpContextAccessor HttpContextAccessor
|
||||
@using GmRelay.Web.Components.Portfolio
|
||||
@using GmRelay.Web.Services.Portfolio
|
||||
|
||||
@@ -115,9 +116,20 @@ else if (profile is not null)
|
||||
{
|
||||
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);
|
||||
: await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug, viewerPlayerId);
|
||||
portfolioGames = trimmedSlug is null
|
||||
? []
|
||||
: await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug);
|
||||
|
||||
Reference in New Issue
Block a user