From e970e94e004fbc4b884ef55530d1f812302e7501 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 2 Jun 2026 15:21:51 +0300 Subject: [PATCH] feat(web): add portfolio management UI --- .../Pages/GroupCompletedSessions.razor | 108 +++++ .../Components/Pages/GroupDetails.razor | 85 ++++ .../Components/Pages/PortfolioEditor.razor | 456 ++++++++++++++++++ .../Components/Pages/SessionHistory.razor | 38 +- src/GmRelay.Web/wwwroot/app.css | 267 ++++++++++ .../Web/PortfolioPagesTests.cs | 73 +++ 6 files changed, 1026 insertions(+), 1 deletion(-) create mode 100644 src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor create mode 100644 src/GmRelay.Web/Components/Pages/PortfolioEditor.razor create mode 100644 tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs diff --git a/src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor b/src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor new file mode 100644 index 0000000..524d1d6 --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor @@ -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 + +Проведённые сессии — GM-Relay + +
+ + + + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ ⚠️ @errorMessage +
+ } + + @if (sessions is null) + { +
+
+
+
+
+ } + else if (sessions.Count == 0) + { +
+
+
📭
+
Проведённых сессий пока нет
+

Как только сессии закончатся, они появятся здесь и их можно будет добавить в портфолио.

+
+
+ } + else + { +
+ @foreach (var session in sessions) + { +
+
+ @session.Title + @session.ScheduledAt.FormatMoscow() +
+
+ +
+
+ } +
+ } +
+ +@code { + [Parameter] public Guid GroupId { get; set; } + + private IReadOnlyList? 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; + } + } +} diff --git a/src/GmRelay.Web/Components/Pages/GroupDetails.razor b/src/GmRelay.Web/Components/Pages/GroupDetails.razor index 371c8cc..fa9c46a 100644 --- a/src/GmRelay.Web/Components/Pages/GroupDetails.razor +++ b/src/GmRelay.Web/Components/Pages/GroupDetails.razor @@ -1,10 +1,12 @@ @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 AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation @@ -138,6 +140,60 @@ } + @if (portfolioGames is not null) + { +
+
+
+

Проведённые приключения

+

Черновики и опубликованные приключения для каталога мастера.

+
+ +
+ + @if (portfolioGames.Count == 0) + { +
+
Приключений пока нет
+

Создайте первый черновик и добавьте проведённые сессии.

+
+ } + else + { +
+ @foreach (var game in portfolioGames) + { +
+
+ @game.Title + + @(game.IsPublic ? "Опубликовано" : "Черновик") + +
+
+ @game.SessionCount игр + @game.MasterCount мастеров + @if (game.PendingReviewCount > 0) + { + @game.PendingReviewCount на модерации + } +
+ +
+ } +
+ } + +
+ 📜 Все проведённые сессии +
+
+ } + @if (campaignTemplates is not null) {
@@ -481,6 +537,7 @@ private List? campaignTemplates; private WebGroupManagement? groupManagement; private WebPublicGroupSettings? publicSettings; + private IReadOnlyList? portfolioGames; private List batchModels = []; private List campaignTemplateModels = []; private Guid? promotingSessionId; @@ -490,6 +547,7 @@ private Guid? publishingSessionId; private string? removingCoGmId; private bool isAddingCoGm; + private bool isCreatingDraft; private bool savingPublicSettings; private string? currentPlatform; private string? externalUserId; @@ -545,11 +603,38 @@ return; } + portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(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; diff --git a/src/GmRelay.Web/Components/Pages/PortfolioEditor.razor b/src/GmRelay.Web/Components/Pages/PortfolioEditor.razor new file mode 100644 index 0000000..582961b --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/PortfolioEditor.razor @@ -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 + +Портфолио — GM-Relay + +
+ + + + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ ⚠️ @errorMessage +
+ } + + @if (!string.IsNullOrEmpty(successMessage)) + { +
+ ✅ @successMessage +
+ } + + @if (editor is null) + { +
+
+
+
+
+ } + else + { +
+
+
+

Параметры публикации

+

Управление видимостью и обложкой приключения.

+
+ + @(editor.IsPublic ? "Опубликовано" : "Черновик") + +
+ +
+
+ @if (!string.IsNullOrEmpty(editor.CoverPath)) + { + Обложка + } + else + { +
Обложка не загружена
+ } + + +
+ +
+ +
+ + +
+
+ + +
Латиница, цифры и дефисы, например "night-city-run".
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+ + +
+
+
+
+ +
+
+
+

Проведённые сессии

+

Отметьте игры, которые вошли в это приключение.

+
+ @editorModel.SessionIds.Count +
+
+ @foreach (var session in editor.Sessions) + { + + } +
+
+ +
+
+
+

Мастера приключения

+

Выберите мастеров, которые вели это приключение.

+
+ @editorModel.MasterPlayerIds.Count +
+
+ @foreach (var master in editor.Masters) + { + + } +
+
+ +
+
+
+

Модерация отзывов

+

Одобрите, отклоните или скройте отзывы игроков перед публикацией.

+
+ + @editor.Reviews.Count(r => r.ModerationStatus == "Pending") на модерации + +
+ + @if (editor.Reviews.Count == 0) + { +
+
Отзывов пока нет
+

Игроки смогут оставить отзыв после публикации приключения.

+
+ } + else + { +
+ @foreach (var review in editor.Reviews) + { +
+
+ @review.AuthorDisplayName + @TranslateReviewStatus(review.ModerationStatus) + @review.CreatedAt.ToString("dd.MM.yyyy HH:mm") +
+

@review.Body

+
+ + + +
+
+ } +
+ } +
+ } +
+ +@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 SessionIds { get; set; } = new(); + public List MasterPlayerIds { get; set; } = new(); + } +} diff --git a/src/GmRelay.Web/Components/Pages/SessionHistory.razor b/src/GmRelay.Web/Components/Pages/SessionHistory.razor index 623c133..2cb42e7 100644 --- a/src/GmRelay.Web/Components/Pages/SessionHistory.razor +++ b/src/GmRelay.Web/Components/Pages/SessionHistory.razor @@ -1,9 +1,11 @@ @page "/session/{SessionId:guid}/history" @using GmRelay.Web.Services +@using GmRelay.Web.Services.Portfolio @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @attribute [Authorize] @inject AuthorizedSessionService SessionService +@inject AuthorizedPortfolioService PortfolioService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation @@ -22,6 +24,14 @@ {

@sessionTitle

} + @if (groupId is not null && session is not null && session.ScheduledAt < DateTime.UtcNow) + { +
+ +
+ }
@if (entries is null) @@ -78,6 +88,8 @@ private List? entries; private string? sessionTitle; private Guid? groupId; + private WebSession? session; + private bool isCreatingDraft; protected override async Task OnInitializedAsync() { @@ -88,7 +100,7 @@ return; } - var session = await SessionService.GetSessionForCurrentUserAsync(SessionId); + session = await SessionService.GetSessionForCurrentUserAsync(SessionId); if (session is null) { Navigation.NavigateTo("/access-denied"); @@ -100,6 +112,30 @@ 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 { "Title" => "Название", diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index a769634..f85db37 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -2021,3 +2021,270 @@ body.telegram-mini-app .session-card-mobile { object-fit: cover; 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; + } +} + diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs new file mode 100644 index 0000000..a85e4e4 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs @@ -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 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}'."); + } +}