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>
165 lines
7.2 KiB
Plaintext
165 lines
7.2 KiB
Plaintext
@page "/session/edit/{SessionId:guid}"
|
||
@using GmRelay.Web.Services
|
||
@using GmRelay.Shared.Domain
|
||
@using Microsoft.AspNetCore.Authorization
|
||
@using Microsoft.AspNetCore.Components.Authorization
|
||
@attribute [Authorize]
|
||
@inject AuthorizedSessionService SessionService
|
||
@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 (session == null)
|
||
{
|
||
<div class="glass-card" style="padding: 2rem;">
|
||
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 1.5rem;"></div>
|
||
<div class="skeleton skeleton-text" style="width: 100%; height: 2.5rem; margin-bottom: 1.5rem;"></div>
|
||
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 1.5rem;"></div>
|
||
<div class="skeleton skeleton-text" style="width: 100%; height: 2.5rem; margin-bottom: 1.5rem;"></div>
|
||
<div class="skeleton skeleton-text" style="width: 30%; height: 2.5rem;"></div>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="glass-card animate-slide-up" style="max-width: 640px;">
|
||
<EditForm Model="@model" OnValidSubmit="HandleSubmit">
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Название игры</label>
|
||
<InputText @bind-Value="model.Title" class="gm-form-control" placeholder="например, D&D 5e: Dragon's Hoard" />
|
||
<div class="gm-form-hint">Изменение этого поля обновит все сессии в одной группе.</div>
|
||
</div>
|
||
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Запланированное время (МСК, UTC+3)</label>
|
||
<input type="datetime-local" @bind="model.ScheduledAtLocal" class="gm-form-control" />
|
||
<div class="gm-form-hint">Текущее: @session.ScheduledAt.FormatMoscow()</div>
|
||
</div>
|
||
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Ссылка для подключения</label>
|
||
<InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" />
|
||
</div>
|
||
|
||
<div class="gm-form-group">
|
||
<label class="gm-form-label">Лимит мест</label>
|
||
<InputNumber @bind-Value="model.MaxPlayers" class="gm-form-control" min="1" placeholder="Без лимита" />
|
||
<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 ? "⏳ Сохранение..." : "✅ Сохранить изменения")
|
||
</button>
|
||
<button type="button" class="btn-gm btn-gm-outline" @onclick="GoBack">
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</EditForm>
|
||
</div>
|
||
|
||
@if (!string.IsNullOrEmpty(errorMessage))
|
||
{
|
||
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem; max-width: 640px;">
|
||
⚠️ @errorMessage
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
@code {
|
||
[Parameter] public Guid SessionId { get; set; }
|
||
private WebSession? session;
|
||
private SessionEditModel model = new();
|
||
private bool isSubmitting;
|
||
private string? errorMessage;
|
||
|
||
protected override async Task OnInitializedAsync()
|
||
{
|
||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||
{
|
||
Navigation.NavigateTo("/access-denied");
|
||
return;
|
||
}
|
||
|
||
session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
|
||
if (session is null)
|
||
{
|
||
Navigation.NavigateTo("/access-denied");
|
||
return;
|
||
}
|
||
|
||
model.Title = session.Title;
|
||
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
||
model.JoinLink = session.JoinLink;
|
||
model.MaxPlayers = session.MaxPlayers;
|
||
model.PublicationMode = session.PublicationMode;
|
||
}
|
||
|
||
private async Task HandleSubmit()
|
||
{
|
||
isSubmitting = true;
|
||
errorMessage = null;
|
||
|
||
try
|
||
{
|
||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||
{
|
||
Navigation.NavigateTo("/access-denied");
|
||
return;
|
||
}
|
||
|
||
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)
|
||
{
|
||
Navigation.NavigateTo("/access-denied");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
errorMessage = "Не удалось сохранить изменения: " + ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
isSubmitting = false;
|
||
}
|
||
}
|
||
|
||
private void GoBack() => Navigation.NavigateTo("/");
|
||
|
||
public class SessionEditModel
|
||
{
|
||
public string Title { get; set; } = "";
|
||
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
|
||
public string JoinLink { get; set; } = "";
|
||
public int? MaxPlayers { get; set; }
|
||
public string PublicationMode { get; set; } = PublicationModeExtensions.NoneValue;
|
||
}
|
||
}
|