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>
1337 lines
60 KiB
Plaintext
1337 lines
60 KiB
Plaintext
@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; }
|
||
}
|
||
}
|