Files
GmRelayBot/src/GmRelay.Web/Components/Pages/GroupDetails.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

1337 lines
60 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/group/{GroupId:guid}"
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using GmRelay.Web.Services.Portfolio
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@inject AuthorizedPortfolioService PortfolioService
@inject AuthorizedMembershipService MembershipService
@inject AuthenticationStateProvider AuthStateProvider
@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>
</div>
@if (groupManagement is not null)
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Управление группой</h3>
<p>@groupManagement.Group.Name · @FormatRole(CurrentUserRole)</p>
</div>
<span class="status-badge status-info">@FormatRole(CurrentUserRole)</span>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
<a href="/groupstats/@GroupId" class="btn-gm btn-gm-outline">📊 Статистика</a>
@foreach (var manager in groupManagement.Managers)
{
<span class="status-badge @(manager.Role == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatManager(manager)
</span>
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
{
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == ManagerKey(manager))" @onclick="() => RemoveCoGm(manager)">
@(removingCoGmId == ManagerKey(manager) ? "⏳ Удаляем..." : "Убрать")
</button>
}
}
</div>
@if (groupManagement.CurrentUserIsOwner)
{
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">@CoGmIdLabel</label>
<InputText @bind-Value="coGmModel.ExternalUserId" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Имя</label>
<InputText @bind-Value="coGmModel.DisplayName" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Username</label>
<InputText @bind-Value="coGmModel.ExternalUsername" class="gm-form-control" />
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
@(isAddingCoGm ? "⏳ Добавляем..." : " Добавить co-GM")
</button>
</EditForm>
}
</div>
}
@if (publicSettings is not null)
{
<div class="glass-card animate-slide-up public-settings-panel" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Публичная страница клуба</h3>
<p>@publicSettings.PublicSessionCount опубликованных игр без состава игроков и приватных ссылок</p>
</div>
<span class="status-badge @(publicSettings.PublicScheduleEnabled ? "status-success" : "status-neutral")">
@(publicSettings.PublicScheduleEnabled ? "Включена" : "Выключена")
</span>
</div>
<EditForm Model="@publicSettingsModel" OnValidSubmit="SavePublicSettings">
<div class="batch-bulk-fields">
<div class="gm-form-group public-toggle-field">
<label class="gm-checkbox-label">
<InputCheckbox @bind-Value="publicSettingsModel.PublicScheduleEnabled" />
<span>Включить публичное расписание</span>
</label>
<div class="gm-form-hint">Если выключено, публичная страница и ссылки на сессии недоступны.</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Короткий адрес</label>
<InputText @bind-Value="publicSettingsModel.PublicSlug" class="gm-form-control" />
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-club`.</div>
</div>
</div>
<div class="public-settings-actions">
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingPublicSettings">
@(savingPublicSettings ? "Сохраняем..." : "Сохранить публикацию")
</button>
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
{
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
Открыть публичную страницу
</a>
}
</div>
</EditForm>
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
{
<div class="public-link-row">
<span>Ссылка клуба</span>
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
</div>
}
</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;">
⚠️ @errorMessage
</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
✅ @successMessage
</div>
}
@if (portfolioGames is not null)
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Проведённые приключения</h3>
<p>Черновики и опубликованные приключения для каталога мастера.</p>
</div>
<button type="button" class="btn-gm btn-gm-success" disabled="@isCreatingDraft" @onclick="CreateDraft">
@(isCreatingDraft ? "⏳ Создаём..." : " Создать")
</button>
</div>
@if (portfolioGames.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Приключений пока нет</div>
<p class="empty-state-text">Создайте первый черновик и добавьте проведённые сессии.</p>
</div>
}
else
{
<div class="portfolio-management-list">
@foreach (var game in portfolioGames)
{
<div class="portfolio-management-row">
<div class="portfolio-management-info">
<a href="/portfolio/manage/@game.Id" class="portfolio-management-title">@game.Title</a>
<span class="status-badge @(game.IsPublic ? "status-success" : "status-neutral")">
@(game.IsPublic ? "Опубликовано" : "Черновик")
</span>
</div>
<div class="portfolio-management-meta">
<span class="status-badge status-info">@game.SessionCount игр</span>
<span class="status-badge status-info">@game.MasterCount мастеров</span>
@if (game.PendingReviewCount > 0)
{
<span class="status-badge status-warning">@game.PendingReviewCount на модерации</span>
}
</div>
<div class="portfolio-management-actions">
<a href="/portfolio/manage/@game.Id" class="btn-gm btn-gm-outline">✏️ Изменить</a>
</div>
</div>
}
</div>
}
<div style="margin-top: 0.75rem;">
<a href="/group/@GroupId/completed" class="btn-gm btn-gm-outline">📜 Все проведённые сессии</a>
</div>
</div>
}
@if (campaignTemplates is not null)
{
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Применить шаблон</h3>
<p>@campaignTemplateModels.Count доступных для этой группы</p>
</div>
<a href="/templates" class="btn-gm btn-gm-outline">⚙️ Управлять шаблонами</a>
</div>
@if (campaignTemplateModels.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Шаблонов пока нет</div>
<p class="empty-state-text">Создайте шаблоны в отдельной вкладке, а здесь запускайте из них новые batch-расписания.</p>
</div>
}
else
{
<div class="campaign-template-list">
@foreach (var template in campaignTemplateModels)
{
<div class="campaign-template-row">
<div class="campaign-template-info">
<h3>@template.Name</h3>
<p>@FormatTemplateSummary(template)</p>
</div>
<div class="campaign-template-actions">
<input type="datetime-local" @bind="template.FirstScheduledAtLocal" class="gm-form-control" />
<button type="button" class="btn-gm btn-gm-success" disabled="@IsTemplateBusy(template)" @onclick="() => CreateBatchFromTemplate(template)">
@(processingTemplateId == template.Id ? "⏳ Создаём..." : "📅 Создать batch")
</button>
</div>
</div>
}
</div>
}
</div>
}
@if (sessions == null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 50%;"></div>
</div>
}
else if (sessions.Count == 0)
{
<div class="glass-card">
<div class="empty-state">
<div class="empty-state-icon">🎯</div>
<div class="empty-state-title">Нет предстоящих сессий</div>
<p class="empty-state-text">Для этой группы пока не запланировано игровых сессий.</p>
</div>
</div>
}
else
{
<div class="batch-bulk-grid animate-slide-up">
@foreach (var batch in batchModels)
{
<div class="batch-bulk-card">
<div class="batch-bulk-header">
<div>
<h3>@batch.Title</h3>
<p>@FormatBatchSummary(batch)</p>
</div>
<span class="status-badge status-info">Batch</span>
</div>
<EditForm Model="@batch" OnValidSubmit="@(() => UpdateBatchDetails(batch))">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Общее название</label>
<InputText @bind-Value="batch.Title" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Общая ссылка</label>
<InputText @bind-Value="batch.JoinLink" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Уведомления игрокам</label>
<select @bind="batch.NotificationMode" class="gm-form-control">
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
</select>
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@IsBatchBusy(batch)">
@(IsBatchBusy(batch) ? "⏳ Обновляем..." : "💾 Обновить batch")
</button>
</EditForm>
<div class="batch-bulk-divider"></div>
<EditForm Model="@batch" OnValidSubmit="@(() => RescheduleBatch(batch))">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Первая дата пачки (МСК, UTC+3)</label>
<input type="datetime-local" @bind="batch.FirstScheduledAtLocal" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Шаг между играми, дней</label>
<InputNumber @bind-Value="batch.IntervalDays" class="gm-form-control" min="1" />
</div>
</div>
<button type="submit" class="btn-gm btn-gm-outline" disabled="@IsBatchBusy(batch)">
@(IsBatchBusy(batch) ? "⏳ Переносим..." : "📅 Перенести пачку")
</button>
</EditForm>
<div class="batch-publish-row">
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
@FormatBatchPublication(batch)
</span>
<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">
<select @bind="batch.CloneInterval" class="gm-form-control">
<option value="week">Следующая неделя</option>
<option value="month">Следующий месяц</option>
</select>
<button type="button" class="btn-gm btn-gm-success" disabled="@IsBatchBusy(batch)" @onclick="() => CloneBatch(batch)">
@(IsBatchBusy(batch) ? "⏳ Клонируем..." : "🧬 Клонировать")
</button>
</div>
</div>
}
</div>
@* Desktop table *@
<div class="glass-card session-table-desktop session-table-desktop-card animate-slide-up">
<table class="gm-table">
<thead>
<tr>
<th>Название</th>
<th>Время (МСК)</th>
<th>Места</th>
<th>Статус</th>
<th>Ссылка</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
@foreach (var session in sessions)
{
var isExpanded = expandedSessions.Contains(session.Id);
<tr>
<td style="color: var(--text-primary); font-weight: 500;">
<button type="button" class="btn-gm btn-gm-link" @onclick="() => ToggleParticipants(session.Id)">
@(isExpanded ? "▼" : "▶") @session.Title
</button>
</td>
<td>@session.ScheduledAt.FormatMoscow()</td>
<td>@FormatSeats(session)</td>
<td>
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</td>
<td>
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer" class="session-join-link">
Подключиться ↗
</a>
</td>
<td>
<div class="session-table-actions">
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
<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>
}
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
✏️ Изменить
</a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline">📜 История</a>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
</button>
}
</div>
</td>
</tr>
@if (isExpanded)
{
<tr>
<td colspan="6" style="padding: 0; border: none;">
<div class="participant-panel">
@if (loadingParticipantsSessionId == session.Id)
{
<div class="skeleton skeleton-text" style="width: 60%;"></div>
}
else if (participantsCache.TryGetValue(session.Id, out var participants))
{
@if (participants.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Нет участников</div>
</div>
}
else
{
<div class="participant-list">
@foreach (var p in participants)
{
<div class="participant-row">
<div class="participant-info">
<span class="participant-name">@p.DisplayName</span>
<span class="participant-username">@FormatParticipantUsername(p)</span>
<span class="status-badge @GetParticipantStatusClass(p)">@TranslateParticipantStatus(p)</span>
</div>
@if (!p.IsGm)
{
<button type="button" class="btn-gm btn-gm-danger" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(kickingParticipantId == p.Id)" @onclick="() => KickParticipant(session.Id, p.Id)">
@(kickingParticipantId == p.Id ? "⏳..." : "🚪 Исключить")
</button>
}
</div>
}
</div>
}
}
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
@* Mobile cards *@
<div class="session-card-mobile stagger-children">
@foreach (var session in sessions)
{
var isExpanded = expandedSessions.Contains(session.Id);
<div class="session-card">
<div class="session-card-header">
<button type="button" class="btn-gm btn-gm-link" style="text-align: left; padding: 0;" @onclick="() => ToggleParticipants(session.Id)">
@(isExpanded ? "▼" : "▶") @session.Title
</button>
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
</div>
<div class="session-card-body">
<div class="session-card-row">
<span>🕐 Время</span>
<span style="color: var(--text-primary);">@session.ScheduledAt.FormatMoscow()</span>
</div>
<div class="session-card-row">
<span>👥 Места</span>
<span style="color: var(--text-primary);">@FormatSeats(session)</span>
</div>
<div class="session-card-row">
<span>🔗 Ссылка</span>
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer">Подключиться ↗</a>
</div>
</div>
<div class="session-card-actions">
<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>
}
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
✏️ Изменить
</a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">📜 История</a>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
</button>
}
</div>
@if (isExpanded)
{
<div class="participant-panel" style="margin-top: 0.75rem;">
@if (loadingParticipantsSessionId == session.Id)
{
<div class="skeleton skeleton-text" style="width: 60%;"></div>
}
else if (participantsCache.TryGetValue(session.Id, out var participants))
{
@if (participants.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Нет участников</div>
</div>
}
else
{
<div class="participant-list">
@foreach (var p in participants)
{
<div class="participant-row">
<div class="participant-info">
<span class="participant-name">@p.DisplayName</span>
<span class="participant-username">@FormatParticipantUsername(p)</span>
<span class="status-badge @GetParticipantStatusClass(p)">@TranslateParticipantStatus(p)</span>
</div>
@if (!p.IsGm)
{
<button type="button" class="btn-gm btn-gm-danger" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(kickingParticipantId == p.Id)" @onclick="() => KickParticipant(session.Id, p.Id)">
@(kickingParticipantId == p.Id ? "⏳..." : "🚪 Исключить")
</button>
}
</div>
}
</div>
}
}
</div>
}
</div>
}
</div>
}
</div>
@code {
[Parameter] public Guid GroupId { get; set; }
private List<WebSession>? sessions;
private List<WebCampaignTemplate>? campaignTemplates;
private WebGroupManagement? groupManagement;
private WebPublicGroupSettings? publicSettings;
private IReadOnlyList<PortfolioGameSummary>? portfolioGames;
private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private int pendingApplicationsCount;
private Guid? promotingSessionId;
private Guid? processingBatchId;
private Guid? processingTemplateId;
private Guid? publishingBatchId;
private Guid? publishingSessionId;
private string? removingCoGmId;
private bool isAddingCoGm;
private bool isCreatingDraft;
private bool savingPublicSettings;
private string? currentPlatform;
private string? externalUserId;
private string? errorMessage;
private string? successMessage;
private CoGmEditModel coGmModel = new();
private PublicSettingsEditModel publicSettingsModel = new();
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
private HashSet<Guid> expandedSessions = new();
private Guid? kickingParticipantId;
private Guid? loadingParticipantsSessionId;
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId))
{
Navigation.NavigateTo("/access-denied");
return;
}
currentPlatform = platform;
await LoadSessions();
}
private async Task LoadSessions()
{
groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
sessions = await SessionService.GetUpcomingSessionsForCurrentUserAsync(GroupId);
if (sessions is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
publicSettings = await SessionService.GetPublicGroupSettingsForCurrentUserAsync(GroupId);
if (publicSettings is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
if (campaignTemplates is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
pendingApplicationsCount = await MembershipService.GetPendingApplicationsCountForCurrentGmAsync(GroupId);
RebuildBatchModels();
RebuildCampaignTemplateModels();
RebuildPublicSettingsModel();
}
private async Task CreateDraft()
{
errorMessage = null;
successMessage = null;
isCreatingDraft = true;
try
{
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(GroupId, null);
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось создать черновик: " + ex.Message;
}
finally
{
isCreatingDraft = false;
}
}
private async Task SavePublicSettings()
{
errorMessage = null;
successMessage = null;
savingPublicSettings = true;
try
{
await SessionService.UpdatePublicGroupSettingsForCurrentUserAsync(
GroupId,
publicSettingsModel.PublicSlug,
publicSettingsModel.PublicScheduleEnabled);
successMessage = "Настройки публичной страницы обновлены.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичную страницу: " + ex.Message;
}
finally
{
savingPublicSettings = false;
}
}
private async Task SetBatchPublicationMode(BatchBulkEditModel batch, PublicationMode mode)
{
errorMessage = null;
successMessage = null;
publishingBatchId = batch.BatchId;
try
{
await SessionService.SetBatchPublicationModeForCurrentUserAsync(batch.BatchId, mode);
successMessage = mode switch
{
PublicationMode.Catalog => "Batch опубликован в общем каталоге.",
PublicationMode.ClubOnly => "Batch доступен только участникам клуба.",
PublicationMode.Both => "Batch опубликован в каталоге и доступен участникам клуба.",
_ => "Batch скрыт из публичного расписания."
};
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичность batch: " + ex.Message;
}
finally
{
publishingBatchId = null;
}
}
private async Task SetSessionPublicationMode(Guid sessionId, PublicationMode mode)
{
errorMessage = null;
successMessage = null;
publishingSessionId = sessionId;
try
{
await SessionService.SetSessionPublicationModeForCurrentUserAsync(sessionId, mode);
successMessage = mode switch
{
PublicationMode.Catalog => "Сессия опубликована в общем каталоге.",
PublicationMode.ClubOnly => "Сессия доступна только участникам клуба.",
PublicationMode.Both => "Сессия опубликована в каталоге и доступна участникам клуба.",
_ => "Сессия скрыта из публичного расписания."
};
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичность сессии: " + ex.Message;
}
finally
{
publishingSessionId = null;
}
}
private async Task AddCoGm()
{
errorMessage = null;
successMessage = null;
var coGmExternalUserId = coGmModel.ExternalUserId.Trim();
if (coGmExternalUserId.Length == 0)
{
errorMessage = $"{CoGmIdLabel} должен быть заполнен.";
return;
}
if (!IsValidPlatformUserId(CoGmPlatform, coGmExternalUserId))
{
errorMessage = $"{CoGmIdLabel} должен быть положительным числом.";
return;
}
isAddingCoGm = true;
try
{
await SessionService.AddCoGmForOwnerAsync(
GroupId,
CoGmPlatform,
coGmExternalUserId,
coGmModel.DisplayName,
coGmModel.ExternalUsername);
coGmModel = new();
successMessage = "Co-GM добавлен.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось добавить co-GM: " + ex.Message;
}
finally
{
isAddingCoGm = false;
}
}
private async Task RemoveCoGm(WebGroupManager manager)
{
errorMessage = null;
successMessage = null;
removingCoGmId = ManagerKey(manager);
var platform = ManagerPlatform(manager);
var coGmExternalUserId = ManagerExternalUserId(manager);
try
{
await SessionService.RemoveCoGmForOwnerAsync(GroupId, platform, coGmExternalUserId);
successMessage = "Co-GM удалён.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить co-GM: " + ex.Message;
}
finally
{
removingCoGmId = null;
}
}
private async Task PromoteWaitlisted(Guid sessionId)
{
errorMessage = null;
successMessage = null;
promotingSessionId = sessionId;
try
{
await SessionService.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId);
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
promotingSessionId = null;
}
}
private async Task ToggleParticipants(Guid sessionId)
{
if (expandedSessions.Contains(sessionId))
{
expandedSessions.Remove(sessionId);
return;
}
expandedSessions.Add(sessionId);
if (!participantsCache.ContainsKey(sessionId))
{
loadingParticipantsSessionId = sessionId;
try
{
var participants = await SessionService.GetSessionParticipantsForCurrentUserAsync(sessionId);
participantsCache[sessionId] = participants ?? [];
}
catch (Exception ex)
{
errorMessage = "Не удалось загрузить участников: " + ex.Message;
expandedSessions.Remove(sessionId);
}
finally
{
loadingParticipantsSessionId = null;
}
}
}
private async Task KickParticipant(Guid sessionId, Guid participantId)
{
errorMessage = null;
successMessage = null;
kickingParticipantId = participantId;
try
{
await SessionService.RemovePlayerFromSessionForCurrentUserAsync(sessionId, participantId);
participantsCache.Remove(sessionId);
successMessage = "Игрок исключён.";
await LoadSessions();
if (expandedSessions.Contains(sessionId))
{
await ToggleParticipants(sessionId);
}
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
kickingParticipantId = null;
}
}
private static string FormatParticipantUsername(WebParticipant p)
{
var username = string.IsNullOrWhiteSpace(p.TelegramUsername)
? p.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: "@" + p.TelegramUsername;
return $"{username} · {FormatParticipantRsvp(p.RsvpStatus)}";
}
private static string FormatParticipantRsvp(string rsvp) => rsvp switch
{
RsvpStatus.Pending => "⏳ не ответил",
RsvpStatus.Confirmed => "✅ подтвердил",
RsvpStatus.Declined => "❌ отказался",
_ => rsvp
};
private static string GetParticipantStatusClass(WebParticipant p)
{
if (p.IsGm) return "status-success";
return p.RegistrationStatus switch
{
"Active" => "status-info",
"Waitlisted" => "status-warning",
_ => "status-neutral"
};
}
private static string TranslateParticipantStatus(WebParticipant p)
{
if (p.IsGm) return "ГМ";
return p.RegistrationStatus switch
{
"Active" => "Основной состав",
"Waitlisted" => "Ожидание",
_ => p.RegistrationStatus
};
}
private async Task UpdateBatchDetails(BatchBulkEditModel batch)
{
errorMessage = null;
successMessage = null;
if (!ValidateBatchDetails(batch))
{
errorMessage = "Название и ссылка для batch не должны быть пустыми.";
return;
}
processingBatchId = batch.BatchId;
try
{
await SessionService.UpdateBatchDetailsForCurrentUserAsync(batch.BatchId, batch.Title, batch.JoinLink);
await SessionService.UpdateBatchNotificationModeForCurrentUserAsync(
batch.BatchId,
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
successMessage = "Настройки batch обновлены.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить пачку: " + ex.Message;
}
finally
{
processingBatchId = null;
}
}
private async Task RescheduleBatch(BatchBulkEditModel batch)
{
errorMessage = null;
successMessage = null;
if (batch.IntervalDays <= 0)
{
errorMessage = "Шаг между играми должен быть больше 0 дней.";
return;
}
processingBatchId = batch.BatchId;
try
{
var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.RescheduleBatchForCurrentUserAsync(batch.BatchId, utcTime, batch.IntervalDays);
successMessage = "Расписание пачки обновлено.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось перенести пачку: " + ex.Message;
}
finally
{
processingBatchId = null;
}
}
private async Task CloneBatch(BatchBulkEditModel batch)
{
errorMessage = null;
successMessage = null;
processingBatchId = batch.BatchId;
try
{
var interval = batch.CloneInterval == "month"
? BatchCloneInterval.NextMonth
: BatchCloneInterval.NextWeek;
var clonedBatch = await SessionService.CloneBatchForCurrentUserAsync(batch.BatchId, interval);
successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось клонировать пачку: " + ex.Message;
}
finally
{
processingBatchId = null;
}
}
private async Task CreateBatchFromTemplate(CampaignTemplateUsageModel template)
{
errorMessage = null;
successMessage = null;
processingTemplateId = template.Id;
try
{
var utcTime = new DateTimeOffset(template.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
if (utcTime <= DateTime.UtcNow)
{
errorMessage = "Первая дата batch должна быть в будущем.";
return;
}
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForCurrentUserAsync(template.Id, utcTime);
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось создать batch из шаблона: " + ex.Message;
}
finally
{
processingTemplateId = null;
}
}
private void RebuildBatchModels()
{
batchModels = sessions?
.GroupBy(session => session.BatchId)
.Select(group =>
{
var orderedSessions = group.OrderBy(session => session.ScheduledAt).ToList();
var firstSession = orderedSessions[0];
var lastSession = orderedSessions[^1];
return new BatchBulkEditModel
{
BatchId = group.Key,
Title = firstSession.Title,
JoinLink = firstSession.JoinLink,
NotificationMode = firstSession.NotificationMode,
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
IntervalDays = InferIntervalDays(orderedSessions),
SessionCount = orderedSessions.Count,
PublicSessionCount = orderedSessions.Count(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)
.ToList() ?? [];
}
private void RebuildCampaignTemplateModels()
{
var defaultStart = DateTime.UtcNow.AddDays(7).ToMoscow();
campaignTemplateModels = campaignTemplates?
.OrderByDescending(template => template.UpdatedAt)
.ThenBy(template => template.Name)
.Select(template => new CampaignTemplateUsageModel
{
Id = template.Id,
Name = template.Name,
Title = template.Title,
JoinLink = template.JoinLink,
SessionCount = template.SessionCount,
IntervalDays = template.IntervalDays,
MaxPlayers = template.MaxPlayers,
NotificationMode = template.NotificationMode,
FirstScheduledAtLocal = defaultStart
})
.ToList() ?? [];
}
private void RebuildPublicSettingsModel()
{
if (publicSettings is null)
{
return;
}
publicSettingsModel = new PublicSettingsEditModel
{
PublicScheduleEnabled = publicSettings.PublicScheduleEnabled,
PublicSlug = publicSettings.PublicSlug ?? ""
};
}
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
{
batch.Title = batch.Title.Trim();
batch.JoinLink = batch.JoinLink.Trim();
return batch.Title.Length > 0 && batch.JoinLink.Length > 0;
}
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private bool IsBatchPublishBusy(BatchBulkEditModel batch) => publishingBatchId == batch.BatchId;
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
private string? PublicClubUrl =>
string.IsNullOrWhiteSpace(publicSettings?.PublicSlug)
? null
: Navigation.ToAbsoluteUri($"/club/{publicSettings.PublicSlug}").ToString();
private string PublicSessionUrl(Guid sessionId) =>
Navigation.ToAbsoluteUri($"/s/{sessionId}").ToString();
private static string FormatPublicationStatus(WebSession session) =>
session.IsPublic ? "Опубликована" : "Скрыта";
private static string GetPublicationStatusClass(WebSession session) =>
session.IsPublic ? "status-success" : "status-neutral";
private static string FormatBatchPublication(BatchBulkEditModel batch) =>
batch.PublicSessionCount == 0
? "Все игры скрыты"
: batch.PublicSessionCount == batch.SessionCount
? "Все игры опубликованы"
: $"{batch.PublicSessionCount}/{batch.SessionCount} опубликовано";
private string CoGmPlatform =>
string.IsNullOrWhiteSpace(groupManagement?.Group.Platform)
? "Telegram"
: groupManagement.Group.Platform;
private string CoGmIdLabel => $"{CoGmPlatform} ID co-GM";
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager =>
string.Equals(ManagerPlatform(manager), currentPlatform, StringComparison.OrdinalIgnoreCase) &&
ManagerExternalUserId(manager) == externalUserId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private string FormatManager(WebGroupManager manager)
{
var username = string.IsNullOrWhiteSpace(manager.ExternalUsername)
? manager.TelegramUsername
: manager.ExternalUsername;
var identity = string.IsNullOrWhiteSpace(username)
? $"{ManagerPlatform(manager)} {ManagerExternalUserId(manager)}"
: "@" + username.TrimStart('@');
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {identity}";
}
private string ManagerPlatform(WebGroupManager manager) =>
string.IsNullOrWhiteSpace(manager.Platform) ? CoGmPlatform : manager.Platform;
private static string ManagerExternalUserId(WebGroupManager manager) =>
string.IsNullOrWhiteSpace(manager.ExternalUserId)
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: manager.ExternalUserId;
private string ManagerKey(WebGroupManager manager) =>
$"{ManagerPlatform(manager)}:{ManagerExternalUserId(manager)}";
private static bool IsValidPlatformUserId(string platform, string externalUserId) =>
string.Equals(platform, "Telegram", StringComparison.OrdinalIgnoreCase)
? long.TryParse(externalUserId, out var telegramId) && telegramId > 0
: !string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ||
(ulong.TryParse(externalUserId, out var platformId) && platformId > 0);
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
{
if (orderedSessions.Count < 2)
{
return 7;
}
var days = (orderedSessions[1].ScheduledAt - orderedSessions[0].ScheduledAt).TotalDays;
return Math.Max(1, (int)Math.Round(days, MidpointRounding.AwayFromZero));
}
private static bool CanPromote(WebSession session) =>
session.WaitlistedPlayerCount > 0 &&
(!session.MaxPlayers.HasValue || session.ActivePlayerCount < session.MaxPlayers.Value);
private static string FormatSeats(WebSession session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: session.ActivePlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
return session.WaitlistedPlayerCount > 0
? $"{seats} · ожидание {session.WaitlistedPlayerCount}"
: 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)}";
private static string FormatTemplateSummary(CampaignTemplateUsageModel template)
{
var seats = template.MaxPlayers.HasValue
? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест"
: "без лимита";
return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}";
}
private static string FormatNotificationMode(string notificationMode) =>
SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch
{
SessionNotificationMode.GroupOnly => "только группа",
_ => "группа и личка"
};
private static string FormatLocalMoscow(DateTime localMoscow) =>
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
private string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
SessionStatus.Cancelled => "status-danger",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private string TranslateStatus(string status) => status switch
{
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждём подтверждения",
SessionStatus.Confirmed => "Подтверждено",
SessionStatus.Cancelled => "Отменено",
_ => status
};
private sealed class BatchBulkEditModel
{
public Guid BatchId { get; init; }
public string Title { get; set; } = "";
public string JoinLink { get; set; } = "";
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now;
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
public int IntervalDays { get; set; } = 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";
}
private sealed class CampaignTemplateUsageModel
{
public Guid Id { get; init; }
public string Name { get; init; } = "";
public string Title { get; init; } = "";
public string JoinLink { get; init; } = "";
public int SessionCount { get; init; }
public int IntervalDays { get; init; }
public int? MaxPlayers { get; init; }
public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now;
}
private sealed class CoGmEditModel
{
public string ExternalUserId { get; set; } = "";
public string DisplayName { get; set; } = "";
public string? ExternalUsername { get; set; }
}
private sealed class PublicSettingsEditModel
{
public bool PublicScheduleEnabled { get; set; }
public string? PublicSlug { get; set; }
}
}