feat(web): add portfolio management UI
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
@page "/group/{GroupId:guid}/completed"
|
||||||
|
@using GmRelay.Web.Services
|
||||||
|
@using GmRelay.Web.Services.Portfolio
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject AuthorizedPortfolioService PortfolioService
|
||||||
|
@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><a href="/group/@GroupId">Группа</a></li>
|
||||||
|
<li class="active">Проведённые сессии</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="page-header animate-fade-in">
|
||||||
|
<h2>📚 Проведённые сессии</h2>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 0.25rem;">
|
||||||
|
Добавьте проведённые игры в портфолио — система создаст черновик и предложит заполнить детали.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
|
{
|
||||||
|
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||||
|
⚠️ @errorMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (sessions is null)
|
||||||
|
{
|
||||||
|
<div class="glass-card" style="padding: 2rem;">
|
||||||
|
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 1rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 55%; margin-bottom: 0.75rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (sessions.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="glass-card animate-slide-up">
|
||||||
|
<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="portfolio-completed-list animate-slide-up">
|
||||||
|
@foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
<div class="portfolio-completed-row">
|
||||||
|
<div class="portfolio-completed-info">
|
||||||
|
<a href="/session/@session.Id/history" class="portfolio-completed-title">@session.Title</a>
|
||||||
|
<span class="portfolio-completed-date">@session.ScheduledAt.FormatMoscow()</span>
|
||||||
|
</div>
|
||||||
|
<div class="portfolio-completed-actions">
|
||||||
|
<button type="button" class="btn-gm btn-gm-primary" disabled="@(creatingDraftSessionId == session.Id)" @onclick="() => AddToPortfolio(session.Id)">
|
||||||
|
@(creatingDraftSessionId == session.Id ? "⏳..." : "➕ Добавить в портфолио")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public Guid GroupId { get; set; }
|
||||||
|
|
||||||
|
private IReadOnlyList<PortfolioSessionOption>? sessions;
|
||||||
|
private Guid? creatingDraftSessionId;
|
||||||
|
private string? errorMessage;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
sessions = await PortfolioService.GetCompletedSessionsForCurrentUserAsync(GroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddToPortfolio(Guid sessionId)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
creatingDraftSessionId = sessionId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(GroupId, sessionId);
|
||||||
|
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось создать черновик: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
creatingDraftSessionId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
@page "/group/{GroupId:guid}"
|
@page "/group/{GroupId:guid}"
|
||||||
@using GmRelay.Web.Services
|
@using GmRelay.Web.Services
|
||||||
@using GmRelay.Shared.Domain
|
@using GmRelay.Shared.Domain
|
||||||
|
@using GmRelay.Web.Services.Portfolio
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject AuthorizedSessionService SessionService
|
@inject AuthorizedSessionService SessionService
|
||||||
|
@inject AuthorizedPortfolioService PortfolioService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
@@ -138,6 +140,60 @@
|
|||||||
</div>
|
</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)
|
@if (campaignTemplates is not null)
|
||||||
{
|
{
|
||||||
<div class="glass-card campaign-template-panel animate-slide-up">
|
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||||
@@ -481,6 +537,7 @@
|
|||||||
private List<WebCampaignTemplate>? campaignTemplates;
|
private List<WebCampaignTemplate>? campaignTemplates;
|
||||||
private WebGroupManagement? groupManagement;
|
private WebGroupManagement? groupManagement;
|
||||||
private WebPublicGroupSettings? publicSettings;
|
private WebPublicGroupSettings? publicSettings;
|
||||||
|
private IReadOnlyList<PortfolioGameSummary>? portfolioGames;
|
||||||
private List<BatchBulkEditModel> batchModels = [];
|
private List<BatchBulkEditModel> batchModels = [];
|
||||||
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
|
||||||
private Guid? promotingSessionId;
|
private Guid? promotingSessionId;
|
||||||
@@ -490,6 +547,7 @@
|
|||||||
private Guid? publishingSessionId;
|
private Guid? publishingSessionId;
|
||||||
private string? removingCoGmId;
|
private string? removingCoGmId;
|
||||||
private bool isAddingCoGm;
|
private bool isAddingCoGm;
|
||||||
|
private bool isCreatingDraft;
|
||||||
private bool savingPublicSettings;
|
private bool savingPublicSettings;
|
||||||
private string? currentPlatform;
|
private string? currentPlatform;
|
||||||
private string? externalUserId;
|
private string? externalUserId;
|
||||||
@@ -545,11 +603,38 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
|
||||||
|
|
||||||
RebuildBatchModels();
|
RebuildBatchModels();
|
||||||
RebuildCampaignTemplateModels();
|
RebuildCampaignTemplateModels();
|
||||||
RebuildPublicSettingsModel();
|
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()
|
private async Task SavePublicSettings()
|
||||||
{
|
{
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
@page "/portfolio/manage/{PortfolioGameId:guid}"
|
||||||
|
@using GmRelay.Web.Services
|
||||||
|
@using GmRelay.Web.Services.Portfolio
|
||||||
|
@using GmRelay.Web.Services.Portfolio.Covers
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject AuthorizedPortfolioService PortfolioService
|
||||||
|
@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><a href="/group/@groupId">Группа</a></li>
|
||||||
|
<li class="active">Портфолио</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="page-header animate-fade-in">
|
||||||
|
<h2>📚 Управление портфолио</h2>
|
||||||
|
@if (editor is not null)
|
||||||
|
{
|
||||||
|
<p style="color: var(--text-muted); margin-top: 0.25rem;">@editor.Title</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@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 (editor is null)
|
||||||
|
{
|
||||||
|
<div class="glass-card" style="padding: 2rem;">
|
||||||
|
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 1rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 0.75rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 60%;"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||||
|
<div class="batch-bulk-header">
|
||||||
|
<div>
|
||||||
|
<h3>Параметры публикации</h3>
|
||||||
|
<p>Управление видимостью и обложкой приключения.</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge @(editor.IsPublic ? "status-success" : "status-neutral")">
|
||||||
|
@(editor.IsPublic ? "Опубликовано" : "Черновик")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="portfolio-editor-grid">
|
||||||
|
<div class="portfolio-editor-cover">
|
||||||
|
@if (!string.IsNullOrEmpty(editor.CoverPath))
|
||||||
|
{
|
||||||
|
<img src="@editor.CoverPath" alt="Обложка" class="portfolio-editor-cover-image" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="portfolio-editor-cover-empty">Обложка не загружена</div>
|
||||||
|
}
|
||||||
|
<InputFile OnChange="HandleFileSelected" accept="image/jpeg,image/png,image/webp" class="portfolio-editor-cover-input" />
|
||||||
|
<button type="button" class="btn-gm btn-gm-outline" disabled="@isUploadingCover" @onclick="TriggerCoverUpload">
|
||||||
|
@(isUploadingCover ? "⏳ Загружаем..." : "🖼 Заменить обложку")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="portfolio-editor-fields">
|
||||||
|
<EditForm Model="@editorModel" OnValidSubmit="SaveDraft">
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Название</label>
|
||||||
|
<InputText @bind-Value="editorModel.Title" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Короткий адрес (slug)</label>
|
||||||
|
<InputText @bind-Value="editorModel.PublicSlug" class="gm-form-control" />
|
||||||
|
<div class="gm-form-hint">Латиница, цифры и дефисы, например "night-city-run".</div>
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Система</label>
|
||||||
|
<InputText @bind-Value="editorModel.System" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Формат</label>
|
||||||
|
<InputText @bind-Value="editorModel.Format" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Описание</label>
|
||||||
|
<InputTextArea @bind-Value="editorModel.Description" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="portfolio-editor-actions">
|
||||||
|
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSaving">
|
||||||
|
@(isSaving ? "⏳ Сохраняем..." : "💾 Сохранить")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<div class="portfolio-editor-publish-row">
|
||||||
|
<button type="button" class="btn-gm @(editor.IsPublic ? "btn-gm-outline" : "btn-gm-success")" disabled="@isUpdatingPublication" @onclick="() => SetPublication(!editor.IsPublic)">
|
||||||
|
@(isUpdatingPublication
|
||||||
|
? "Обновляем..."
|
||||||
|
: editor.IsPublic ? "Скрыть из каталога" : "Опубликовать")
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-gm btn-gm-danger" disabled="@isDeleting" @onclick="DeletePortfolio">
|
||||||
|
@(isDeleting ? "⏳ Удаляем..." : "🗑 Удалить")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||||
|
<div class="batch-bulk-header">
|
||||||
|
<div>
|
||||||
|
<h3>Проведённые сессии</h3>
|
||||||
|
<p>Отметьте игры, которые вошли в это приключение.</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge status-info">@editorModel.SessionIds.Count</span>
|
||||||
|
</div>
|
||||||
|
<div class="portfolio-option-list">
|
||||||
|
@foreach (var session in editor.Sessions)
|
||||||
|
{
|
||||||
|
<label class="portfolio-option-row">
|
||||||
|
<input type="checkbox" checked="@session.Selected" @onchange="e => ToggleSession(session.Id, (bool)(e.Value ?? false))" />
|
||||||
|
<span class="portfolio-option-title">@session.Title</span>
|
||||||
|
<span class="portfolio-option-meta">@session.ScheduledAt.FormatMoscow()</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||||
|
<div class="batch-bulk-header">
|
||||||
|
<div>
|
||||||
|
<h3>Мастера приключения</h3>
|
||||||
|
<p>Выберите мастеров, которые вели это приключение.</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge status-info">@editorModel.MasterPlayerIds.Count</span>
|
||||||
|
</div>
|
||||||
|
<div class="portfolio-option-list">
|
||||||
|
@foreach (var master in editor.Masters)
|
||||||
|
{
|
||||||
|
<label class="portfolio-option-row">
|
||||||
|
<input type="checkbox" checked="@master.Selected" @onchange="e => ToggleMaster(master.PlayerId, (bool)(e.Value ?? false))" />
|
||||||
|
<span class="portfolio-option-title">@master.DisplayName</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
|
||||||
|
<div class="batch-bulk-header">
|
||||||
|
<div>
|
||||||
|
<h3>Модерация отзывов</h3>
|
||||||
|
<p>Одобрите, отклоните или скройте отзывы игроков перед публикацией.</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge @(editor.Reviews.Any(r => r.ModerationStatus == "Pending") ? "status-warning" : "status-neutral")">
|
||||||
|
@editor.Reviews.Count(r => r.ModerationStatus == "Pending") на модерации
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (editor.Reviews.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-review-moderation">
|
||||||
|
@foreach (var review in editor.Reviews)
|
||||||
|
{
|
||||||
|
<div class="portfolio-review-row">
|
||||||
|
<div class="portfolio-review-meta">
|
||||||
|
<span class="portfolio-review-author">@review.AuthorDisplayName</span>
|
||||||
|
<span class="status-badge @GetReviewStatusClass(review.ModerationStatus)">@TranslateReviewStatus(review.ModerationStatus)</span>
|
||||||
|
<span class="portfolio-review-date">@review.CreatedAt.ToString("dd.MM.yyyy HH:mm")</span>
|
||||||
|
</div>
|
||||||
|
<p class="portfolio-review-body">@review.Body</p>
|
||||||
|
<div class="portfolio-review-actions">
|
||||||
|
<button type="button" class="btn-gm btn-gm-success" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Approved"))">
|
||||||
|
@(moderatingReviewId == review.Id ? "⏳..." : "Одобрить")
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-gm btn-gm-outline" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Rejected"))">
|
||||||
|
@(moderatingReviewId == review.Id ? "⏳..." : "Отклонить")
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-gm btn-gm-danger" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Hidden"))">
|
||||||
|
@(moderatingReviewId == review.Id ? "⏳..." : "Скрыть")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public Guid PortfolioGameId { get; set; }
|
||||||
|
|
||||||
|
private PortfolioGameEditor? editor;
|
||||||
|
private PortfolioEditorModel editorModel = new();
|
||||||
|
private Guid? groupId;
|
||||||
|
private string? errorMessage;
|
||||||
|
private string? successMessage;
|
||||||
|
private bool isSaving;
|
||||||
|
private bool isUploadingCover;
|
||||||
|
private bool isUpdatingPublication;
|
||||||
|
private bool isDeleting;
|
||||||
|
private Guid? moderatingReviewId;
|
||||||
|
private IBrowserFile? pendingCoverFile;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Reload()
|
||||||
|
{
|
||||||
|
editor = await PortfolioService.GetPortfolioGameForCurrentUserAsync(PortfolioGameId);
|
||||||
|
if (editor is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupId = editor.GroupId;
|
||||||
|
editorModel = new PortfolioEditorModel
|
||||||
|
{
|
||||||
|
Title = editor.Title,
|
||||||
|
PublicSlug = editor.PublicSlug ?? string.Empty,
|
||||||
|
Description = editor.Description ?? string.Empty,
|
||||||
|
System = editor.System ?? string.Empty,
|
||||||
|
Format = editor.Format ?? string.Empty,
|
||||||
|
SessionIds = editor.Sessions.Where(s => s.Selected).Select(s => s.Id).ToList(),
|
||||||
|
MasterPlayerIds = editor.Masters.Where(m => m.Selected).Select(m => m.PlayerId).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleSession(Guid sessionId, bool isChecked)
|
||||||
|
{
|
||||||
|
if (isChecked)
|
||||||
|
{
|
||||||
|
if (!editorModel.SessionIds.Contains(sessionId))
|
||||||
|
{
|
||||||
|
editorModel.SessionIds.Add(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
editorModel.SessionIds.Remove(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleMaster(Guid playerId, bool isChecked)
|
||||||
|
{
|
||||||
|
if (isChecked)
|
||||||
|
{
|
||||||
|
if (!editorModel.MasterPlayerIds.Contains(playerId))
|
||||||
|
{
|
||||||
|
editorModel.MasterPlayerIds.Add(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
editorModel.MasterPlayerIds.Remove(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveDraft()
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
isSaving = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await PortfolioService.UpdateDraftForCurrentUserAsync(
|
||||||
|
PortfolioGameId,
|
||||||
|
new PortfolioGameUpdate(
|
||||||
|
editorModel.Title,
|
||||||
|
editorModel.PublicSlug,
|
||||||
|
editorModel.Description,
|
||||||
|
editorModel.System,
|
||||||
|
editorModel.Format,
|
||||||
|
editorModel.SessionIds,
|
||||||
|
editorModel.MasterPlayerIds));
|
||||||
|
successMessage = "Черновик сохранён.";
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
errorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось сохранить: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TriggerCoverUpload()
|
||||||
|
{
|
||||||
|
// The InputFile control is rendered with a label. No-op click handler kept for symmetry.
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleFileSelected(InputFileChangeEventArgs e)
|
||||||
|
{
|
||||||
|
var file = e.File;
|
||||||
|
if (file is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingCoverFile = file;
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
isUploadingCover = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = file.OpenReadStream(LocalPortfolioCoverStorage.MaxBytes);
|
||||||
|
await PortfolioService.ReplaceCoverForCurrentUserAsync(PortfolioGameId, stream, file.ContentType);
|
||||||
|
successMessage = "Обложка обновлена.";
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
errorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось загрузить обложку: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isUploadingCover = false;
|
||||||
|
pendingCoverFile = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetPublication(bool isPublic)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
isUpdatingPublication = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await PortfolioService.SetPublicationForCurrentUserAsync(PortfolioGameId, isPublic);
|
||||||
|
successMessage = isPublic ? "Приключение опубликовано." : "Приключение скрыто.";
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
errorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось обновить публикацию: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isUpdatingPublication = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeletePortfolio()
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
isDeleting = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await PortfolioService.DeleteForCurrentUserAsync(PortfolioGameId);
|
||||||
|
Navigation.NavigateTo(groupId.HasValue ? $"/group/{groupId.Value}" : "/");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось удалить: " + ex.Message;
|
||||||
|
isDeleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Moderate(Guid reviewId, string moderationStatus)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
moderatingReviewId = reviewId;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await PortfolioService.ModerateReviewForCurrentUserAsync(PortfolioGameId, reviewId, moderationStatus);
|
||||||
|
successMessage = "Модерация обновлена.";
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
errorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось обновить отзыв: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
moderatingReviewId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetReviewStatusClass(string status) => status switch
|
||||||
|
{
|
||||||
|
"Approved" => "status-success",
|
||||||
|
"Rejected" => "status-danger",
|
||||||
|
"Hidden" => "status-warning",
|
||||||
|
_ => "status-neutral"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string TranslateReviewStatus(string status) => status switch
|
||||||
|
{
|
||||||
|
"Approved" => "Одобрен",
|
||||||
|
"Rejected" => "Отклонён",
|
||||||
|
"Hidden" => "Скрыт",
|
||||||
|
_ => "На модерации"
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class PortfolioEditorModel
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string PublicSlug { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string System { get; set; } = string.Empty;
|
||||||
|
public string Format { get; set; } = string.Empty;
|
||||||
|
public List<Guid> SessionIds { get; set; } = new();
|
||||||
|
public List<Guid> MasterPlayerIds { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
@page "/session/{SessionId:guid}/history"
|
@page "/session/{SessionId:guid}/history"
|
||||||
@using GmRelay.Web.Services
|
@using GmRelay.Web.Services
|
||||||
|
@using GmRelay.Web.Services.Portfolio
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject AuthorizedSessionService SessionService
|
@inject AuthorizedSessionService SessionService
|
||||||
|
@inject AuthorizedPortfolioService PortfolioService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
@@ -22,6 +24,14 @@
|
|||||||
{
|
{
|
||||||
<p style="color: var(--text-muted); margin-top: 0.25rem;">@sessionTitle</p>
|
<p style="color: var(--text-muted); margin-top: 0.25rem;">@sessionTitle</p>
|
||||||
}
|
}
|
||||||
|
@if (groupId is not null && session is not null && session.ScheduledAt < DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
<div style="margin-top: 0.75rem;">
|
||||||
|
<button type="button" class="btn-gm btn-gm-primary" disabled="@isCreatingDraft" @onclick="AddToPortfolio">
|
||||||
|
@(isCreatingDraft ? "⏳ Создаём..." : "➕ Добавить в портфолио")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (entries is null)
|
@if (entries is null)
|
||||||
@@ -78,6 +88,8 @@
|
|||||||
private List<SessionAuditLogEntry>? entries;
|
private List<SessionAuditLogEntry>? entries;
|
||||||
private string? sessionTitle;
|
private string? sessionTitle;
|
||||||
private Guid? groupId;
|
private Guid? groupId;
|
||||||
|
private WebSession? session;
|
||||||
|
private bool isCreatingDraft;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -88,7 +100,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
|
session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
@@ -100,6 +112,30 @@
|
|||||||
entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId);
|
entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task AddToPortfolio()
|
||||||
|
{
|
||||||
|
if (groupId is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreatingDraft = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(groupId.Value, SessionId);
|
||||||
|
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
isCreatingDraft = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private string GetChangeTypeLabel(string changeType) => changeType switch
|
private string GetChangeTypeLabel(string changeType) => changeType switch
|
||||||
{
|
{
|
||||||
"Title" => "Название",
|
"Title" => "Название",
|
||||||
|
|||||||
@@ -2021,3 +2021,270 @@ body.telegram-mini-app .session-card-mobile {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Portfolio Management === */
|
||||||
|
.portfolio-management-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-management-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.6fr) auto;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-management-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-management-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-management-title:hover {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-management-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-management-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-editor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.4fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-editor-cover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-editor-cover-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-editor-cover-empty {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-editor-cover-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-editor-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-editor-publish-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-option-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-option-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1.4fr) minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-option-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-option-meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-review-moderation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-review-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-review-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-review-author {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-review-date {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-review-body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-review-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-completed-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-completed-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-completed-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-completed-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-completed-title:hover {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-completed-date {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-completed-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.portfolio-editor-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-management-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-management-actions,
|
||||||
|
.portfolio-completed-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-option-row {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-option-meta {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-completed-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class PortfolioPagesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task PortfolioEditorPage_ShouldBeAuthorizedAndExposeEditorActions()
|
||||||
|
{
|
||||||
|
var editor = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PortfolioEditor.razor");
|
||||||
|
|
||||||
|
Assert.Contains("@page \"/portfolio/manage/{PortfolioGameId:guid}\"", editor, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("@attribute [Authorize]", editor, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("InputFile", editor, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("ReplaceCoverForCurrentUserAsync", editor, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("SetPublicationForCurrentUserAsync", editor, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("ModerateReviewForCurrentUserAsync", editor, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GroupDetailsPage_ShouldExposePortfolioManagementActions()
|
||||||
|
{
|
||||||
|
var groupDetails = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
|
||||||
|
|
||||||
|
Assert.Contains("CreateDraftForCurrentUserAsync", groupDetails, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GroupCompletedSessionsPage_ShouldBeAuthorizedAndExposeCompletedSessions()
|
||||||
|
{
|
||||||
|
var completedSessions = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor");
|
||||||
|
|
||||||
|
Assert.Contains("@page \"/group/{GroupId:guid}/completed\"", completedSessions, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("@attribute [Authorize]", completedSessions, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("GetCompletedSessionsForCurrentUserAsync", completedSessions, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("CreateDraftForCurrentUserAsync", completedSessions, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SessionHistoryPage_ShouldExposeQuickPortfolioActionForPastSessions()
|
||||||
|
{
|
||||||
|
var sessionHistory = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/SessionHistory.razor");
|
||||||
|
|
||||||
|
Assert.Contains("CreateDraftForCurrentUserAsync", sessionHistory, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Добавить в портфолио", sessionHistory, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AppCss_ShouldStylePortfolioManagementComponents()
|
||||||
|
{
|
||||||
|
var css = await ReadRepositoryFileAsync("src/GmRelay.Web/wwwroot/app.css");
|
||||||
|
|
||||||
|
Assert.Contains(".portfolio-management-list", css, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(".portfolio-editor-grid", css, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(".portfolio-option-list", css, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(".portfolio-review-moderation", css, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return await File.ReadAllTextAsync(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user