diff --git a/src/GmRelay.Web/Components/Pages/PublicClub.razor b/src/GmRelay.Web/Components/Pages/PublicClub.razor index ce511cb..e8c6b25 100644 --- a/src/GmRelay.Web/Components/Pages/PublicClub.razor +++ b/src/GmRelay.Web/Components/Pages/PublicClub.razor @@ -1,7 +1,10 @@ @page "/club/{Slug}" @layout PublicLayout @inject ISessionStore SessionStore +@inject IPortfolioStore PortfolioStore @inject NavigationManager Navigation +@using GmRelay.Web.Components.Portfolio +@using GmRelay.Web.Services.Portfolio @PageTitleText @@ -75,12 +78,22 @@ else if (club is not null) } } + + @if (portfolioGames.Count > 0) + { +
+

Завершённые игры клуба

+

Публичные портфолио, опубликованные мастерами этого клуба.

+ +
+ } } @code { [Parameter] public string? Slug { get; set; } private WebPublicClub? club; + private IReadOnlyList portfolioGames = []; private bool loaded; private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay"; @@ -93,9 +106,13 @@ else if (club is not null) protected override async Task OnParametersSetAsync() { loaded = false; - club = string.IsNullOrWhiteSpace(Slug) + var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim(); + club = trimmedSlug is null ? null - : await SessionStore.GetPublicClubBySlugAsync(Slug.Trim()); + : await SessionStore.GetPublicClubBySlugAsync(trimmedSlug); + portfolioGames = trimmedSlug is null + ? [] + : await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug); loaded = true; } diff --git a/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor b/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor index e8ada8a..fd40887 100644 --- a/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor +++ b/src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor @@ -1,7 +1,10 @@ @page "/gm/{Slug}" @layout PublicLayout @inject ISessionStore SessionStore +@inject IPortfolioStore PortfolioStore @inject NavigationManager Navigation +@using GmRelay.Web.Components.Portfolio +@using GmRelay.Web.Services.Portfolio @PageTitleText @@ -83,12 +86,22 @@ else if (profile is not null) } } + + @if (portfolioGames.Count > 0) + { +
+

Портфолио

+

Завершённые игры мастера, открытые для публичного просмотра.

+ +
+ } } @code { [Parameter] public string? Slug { get; set; } private GmRelay.Web.Services.PublicMasterProfile? profile; + private IReadOnlyList portfolioGames = []; private bool loaded; private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay"; @@ -101,9 +114,13 @@ else if (profile is not null) protected override async Task OnParametersSetAsync() { loaded = false; - profile = string.IsNullOrWhiteSpace(Slug) + var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim(); + profile = trimmedSlug is null ? null - : await SessionStore.GetPublicMasterProfileBySlugAsync(Slug.Trim()); + : await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug); + portfolioGames = trimmedSlug is null + ? [] + : await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug); loaded = true; } diff --git a/src/GmRelay.Web/Components/Pages/PublicPortfolio.razor b/src/GmRelay.Web/Components/Pages/PublicPortfolio.razor new file mode 100644 index 0000000..eda2deb --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/PublicPortfolio.razor @@ -0,0 +1,266 @@ +@page "/portfolio/{Slug}" +@layout PublicLayout +@inject IPortfolioStore PortfolioStore +@inject AuthorizedPortfolioService AuthorizedPortfolio +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider +@using GmRelay.Shared.Domain +@using GmRelay.Web.Services.Portfolio + +@PageTitleText + +@if (loaded && game is null) +{ + + + + +
+ Недоступно +

Портфолио не найдено

+

Эта игра скрыта, ещё не опубликована или короткий адрес больше не используется.

+
+} +else if (!loaded) +{ +
+
+
+
+} +else if (game is not null) +{ + + + + + @if (!string.IsNullOrWhiteSpace(game.CoverPath)) + { +
+ } + +
+ Завершено +

@game.Title

+

Завершено @game.CompletedAt.ToLocalTime().FormatMoscow()

+
+ @if (!string.IsNullOrWhiteSpace(game.System)) + { + @GetSystemDisplayName(game.System) + } + @if (!string.IsNullOrWhiteSpace(game.Format)) + { + @TranslateFormat(game.Format) + } +
+
+ +
+ @if (!string.IsNullOrWhiteSpace(game.Description)) + { +
+

Описание

+

@game.Description

+
+ } + + @if (game.Masters.Count > 0) + { + + } + + @if (!string.IsNullOrWhiteSpace(game.ClubSlug) && !string.IsNullOrWhiteSpace(game.ClubName)) + { + + } + + +
+ +
+

Отзывы игроков

+ @if (game.Reviews.Count == 0) + { +

Пока нет одобренных отзывов.

+ } + else + { +
    + @foreach (var review in game.Reviews) + { +
  • +
    + @review.AuthorDisplayName + @review.CreatedAt.ToLocalTime().FormatMoscowShort() +
    +

    @review.Body

    +
  • + } +
+ } +
+ +
+

Оставить отзыв

+ @switch (submissionState) + { + case PortfolioReviewSubmissionState.RequiresAuthentication: +

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

+ + break; + case PortfolioReviewSubmissionState.Ineligible: +

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

+ break; + case PortfolioReviewSubmissionState.AlreadySubmitted: +

Отзыв отправлен на модерацию.

+ break; + case PortfolioReviewSubmissionState.Eligible: + +
+ + + @if (!string.IsNullOrWhiteSpace(submissionError)) + { +

@submissionError

+ } +
+ +
+
+
+ break; + } +
+} + +@code { + [Parameter] public string? Slug { get; set; } + + private PublicPortfolioGame? game; + private PortfolioReviewSubmissionState submissionState = PortfolioReviewSubmissionState.RequiresAuthentication; + private ReviewFormModel reviewModel = new(); + private string? submissionError; + private bool isSubmitting; + private bool loaded; + + private string PageTitleText => game is null ? "Портфолио — GM-Relay" : $"{game.Title} — GM-Relay"; + + private string PublicPortfolioUrl => Navigation.ToAbsoluteUri($"/portfolio/{Slug}").ToString(); + + private string GetLoginUrl() => $"/login?returnUrl={Uri.EscapeDataString($"/portfolio/{Slug}")}"; + + protected override async Task OnParametersSetAsync() + { + loaded = false; + var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim(); + game = trimmedSlug is null + ? null + : await PortfolioStore.GetPublicPortfolioGameBySlugAsync(trimmedSlug); + + if (game is not null) + { + submissionState = await AuthorizedPortfolio.GetReviewSubmissionStateForCurrentUserAsync(game.Slug); + } + + reviewModel = new ReviewFormModel(); + submissionError = null; + isSubmitting = false; + loaded = true; + } + + private async Task SubmitReviewAsync() + { + if (game is null) + { + return; + } + + if (!reviewModel.PublicationConsent) + { + submissionError = "Нужно подтвердить согласие на публикацию."; + return; + } + + if (string.IsNullOrWhiteSpace(reviewModel.Body) || reviewModel.Body.Trim().Length < 10) + { + submissionError = "Отзыв должен содержать не меньше 10 символов."; + return; + } + + isSubmitting = true; + submissionError = null; + try + { + await AuthorizedPortfolio.SubmitReviewForCurrentUserAsync( + game.Slug, + reviewModel.Body, + reviewModel.PublicationConsent); + submissionState = PortfolioReviewSubmissionState.AlreadySubmitted; + reviewModel = new ReviewFormModel(); + } + catch (Exception ex) + { + submissionError = ex.Message; + } + finally + { + isSubmitting = false; + } + } + + private static string GetSystemDisplayName(string? system) + { + if (string.IsNullOrWhiteSpace(system)) + return system ?? string.Empty; + + if (Enum.TryParse(system, out var gs)) + return gs.ToDisplayName(); + + return system; + } + + private static string TranslateFormat(string format) => format switch + { + "Online" => "Онлайн", + "Offline" => "Офлайн", + "Hybrid" => "Гибрид", + _ => format + }; + + private sealed class ReviewFormModel + { + public string Body { get; set; } = string.Empty; + public bool PublicationConsent { get; set; } + } +} diff --git a/src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor b/src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor new file mode 100644 index 0000000..dc0926f --- /dev/null +++ b/src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor @@ -0,0 +1,64 @@ +@using GmRelay.Shared.Domain +@using GmRelay.Web.Services.Portfolio + + + +@code { + [Parameter, EditorRequired] + public IReadOnlyList Games { get; set; } = []; + + private static string GetSystemDisplayName(string? system) + { + if (string.IsNullOrWhiteSpace(system)) + return system ?? string.Empty; + + if (Enum.TryParse(system, out var gs)) + return gs.ToDisplayName(); + + return system; + } + + private static string TranslateFormat(string format) => format switch + { + "Online" => "Онлайн", + "Offline" => "Офлайн", + "Hybrid" => "Гибрид", + _ => format + }; +} diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index f85db37..aa74d10 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -2288,3 +2288,160 @@ body.telegram-mini-app .session-card-mobile { } } +/* === Public Portfolio === */ +.portfolio-section { + margin-top: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portfolio-section h2 { + font-size: 1.25rem; + margin: 0; +} + +.portfolio-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1rem; + margin-top: 0.25rem; +} + +.portfolio-card { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + color: inherit; + text-decoration: none; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.portfolio-card:hover { + transform: translateY(-2px); + border-color: var(--accent-primary); +} + +.portfolio-card-cover { + width: 100%; + aspect-ratio: 16 / 9; + background-color: var(--bg-secondary); + background-size: cover; + background-position: center; +} + +.portfolio-card-cover-empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 0.875rem; +} + +.portfolio-card-body { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.875rem 1rem 1rem; +} + +.portfolio-card-body h3 { + margin: 0; + font-size: 1rem; + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.portfolio-card-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.portfolio-card-date { + color: var(--text-muted); + font-size: 0.8125rem; +} + +.portfolio-card-badges { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.portfolio-cover-hero { + width: 100%; + aspect-ratio: 16 / 7; + background-color: var(--bg-secondary); + background-size: cover; + background-position: center; + border-radius: var(--radius-md); + margin-bottom: 1.5rem; + border: 1px solid var(--border-color); +} + +.portfolio-review-list { + list-style: none; + padding: 0; + margin: 0.5rem 0 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portfolio-review-card { + 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-textarea { + width: 100%; + min-height: 7rem; + resize: vertical; + padding: 0.75rem; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + font: inherit; +} + +.portfolio-review-textarea:focus { + outline: 2px solid var(--accent-primary); + outline-offset: 1px; +} + +.portfolio-review-consent { + display: flex; + align-items: flex-start; + gap: 0.5rem; + color: var(--text-secondary); +} + +.portfolio-review-error { + margin: 0; + color: var(--status-error, #ff6b6b); + font-size: 0.875rem; +} + +@media (max-width: 768px) { + .portfolio-grid { + grid-template-columns: 1fr; + } + + .portfolio-cover-hero { + aspect-ratio: 16 / 9; + } +} + diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs index a85e4e4..a9bc26b 100644 --- a/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs @@ -54,6 +54,52 @@ public sealed class PortfolioPagesTests Assert.Contains(".portfolio-review-moderation", css, StringComparison.Ordinal); } + [Fact] + public async Task PublicPortfolioPage_ShouldExposeSanitizedDetailAndReviewForm() + { + var publicPortfolio = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicPortfolio.razor"); + + Assert.Contains("@page \"/portfolio/{Slug}\"", publicPortfolio, StringComparison.Ordinal); + Assert.Contains("@layout PublicLayout", publicPortfolio, StringComparison.Ordinal); + Assert.DoesNotContain("@attribute [Authorize]", publicPortfolio, StringComparison.Ordinal); + Assert.Contains("GetPublicPortfolioGameBySlugAsync", publicPortfolio, StringComparison.Ordinal); + Assert.Contains("SubmitReviewForCurrentUserAsync", publicPortfolio, StringComparison.Ordinal); + Assert.Contains("publicationConsent", publicPortfolio, StringComparison.Ordinal); + Assert.DoesNotContain("PlayerId", publicPortfolio, StringComparison.Ordinal); + Assert.DoesNotContain("StorageKey", publicPortfolio, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicMasterProfilePage_ShouldIncludePortfolioCardGrid() + { + var publicMaster = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor"); + + Assert.Contains("PortfolioCardGrid", publicMaster, StringComparison.Ordinal); + Assert.Contains("GetPublicPortfolioGamesForMasterAsync", publicMaster, StringComparison.Ordinal); + } + + [Fact] + public async Task PublicClubPage_ShouldIncludePortfolioCardGrid() + { + var publicClub = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor"); + + Assert.Contains("PortfolioCardGrid", publicClub, StringComparison.Ordinal); + Assert.Contains("GetPublicPortfolioGamesForClubAsync", publicClub, StringComparison.Ordinal); + } + + [Fact] + public async Task AppCss_ShouldStylePublicPortfolioComponents() + { + var css = await ReadRepositoryFileAsync("src/GmRelay.Web/wwwroot/app.css"); + + Assert.Contains(".portfolio-grid", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-card", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-card-cover", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-cover-hero", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-review-list", css, StringComparison.Ordinal); + Assert.Contains(".portfolio-review-card", css, StringComparison.Ordinal); + } + private static async Task ReadRepositoryFileAsync(string relativePath) { var directory = new DirectoryInfo(AppContext.BaseDirectory);