feat(web): add /showcase catalog page with filters

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 16:00:21 +03:00
parent a5f4a68c6a
commit 72f43dbef2
@@ -0,0 +1,423 @@
@page "/showcase"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation
@using GmRelay.Shared.Features.Showcase
<PageTitle>Каталог игр — GM-Relay</PageTitle>
<HeadContent>
<meta name="description" content="Каталог настольных ролевых игр GM-Relay. Найдите игру по душе — ваншоты, кампании, онлайн и офлайн." />
</HeadContent>
<section class="public-hero">
<h1>Каталог игр</h1>
<p>Найдите настольную ролевую игру по душе — ваншоты, кампании, онлайн и офлайн.</p>
</section>
<section class="glass-card showcase-filters">
<div class="showcase-filter-group">
<span class="showcase-filter-label">Когда</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.Date == DateFilter.Today ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.Today)">Сегодня</button>
<button class="btn-gm @(filter.Date == DateFilter.Tomorrow ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.Tomorrow)">Завтра</button>
<button class="btn-gm @(filter.Date == DateFilter.ThisWeek ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.ThisWeek)">На этой неделе</button>
<button class="btn-gm @(filter.Date == DateFilter.All ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetDate(DateFilter.All)">Все</button>
</div>
</div>
<div class="showcase-filter-group">
<span class="showcase-filter-label">Места</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.Seats == SeatFilter.Available ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Available)">Есть места</button>
<button class="btn-gm @(filter.Seats == SeatFilter.Waitlist ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Waitlist)">Лист ожидания</button>
<button class="btn-gm @(filter.Seats == SeatFilter.Any ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetSeats(SeatFilter.Any)">Любые</button>
</div>
</div>
<div class="showcase-filter-group">
<span class="showcase-filter-label">Система</span>
<select class="gm-form-control showcase-filter-select" @onchange="OnSystemChanged">
<option value="" selected="@(filter.System is null)">Любая</option>
@foreach (var system in Enum.GetValues<GameSystem>())
{
var name = system.ToString();
<option value="@name" selected="@(filter.System == name)">@system.ToDisplayName()</option>
}
</select>
</div>
<div class="showcase-filter-group">
<span class="showcase-filter-label">Тип</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.IsOneShot == true ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(true)">Ваншот</button>
<button class="btn-gm @(filter.IsOneShot == false ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(false)">Кампания</button>
<button class="btn-gm @(filter.IsOneShot is null ? "btn-gm-primary" : "btn-gm-outline")" @onclick="() => SetOneShot(null)">Любое</button>
</div>
</div>
<div class="showcase-filter-group">
<span class="showcase-filter-label">Формат</span>
<div class="showcase-filter-buttons">
<button class="btn-gm @(filter.Format == "Online" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Online"))">Онлайн</button>
<button class="btn-gm @(filter.Format == "Offline" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Offline"))">Офлайн</button>
<button class="btn-gm @(filter.Format == "Hybrid" ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat("Hybrid"))">Гибрид</button>
<button class="btn-gm @(filter.Format is null ? "btn-gm-primary" : "btn-gm-outline")" @onclick="@(() => SetFormat((string?)null))">Любой</button>
</div>
</div>
</section>
@if (loading && sessions.Count == 0)
{
<div class="showcase-grid">
@for (var i = 0; i < 6; i++)
{
<div class="glass-card showcase-card showcase-skeleton">
<div class="skeleton showcase-skeleton-image"></div>
<div class="showcase-card-body">
<div class="skeleton skeleton-text" style="width: 70%;"></div>
<div class="skeleton skeleton-text" style="width: 45%;"></div>
<div class="skeleton skeleton-text" style="width: 55%;"></div>
</div>
</div>
}
</div>
}
else if (!loading && sessions.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Игры не найдены</h2>
<p>Попробуйте изменить фильтры или загляните позже — новые сессии появляются каждый день.</p>
</div>
}
else
{
<div class="showcase-grid">
@foreach (var session in sessions)
{
<article class="glass-card showcase-card animate-fade-in">
<div class="showcase-card-image"
style="@(string.IsNullOrWhiteSpace(session.CoverImageUrl)
? $"background: {GetGradientStyle(session.Id)}; background-size: cover; background-position: center;"
: $"background-image: url({session.CoverImageUrl}); background-size: cover; background-position: center;")">
</div>
<div class="showcase-card-body">
<div class="showcase-card-badges">
@if (!string.IsNullOrWhiteSpace(session.System))
{
<span class="status-badge status-info">@GetSystemDisplayName(session.System)</span>
}
@if (session.IsOneShot)
{
<span class="status-badge status-warning">Ваншот</span>
}
@if (!string.IsNullOrWhiteSpace(session.Format))
{
<span class="status-badge status-neutral">@TranslateFormat(session.Format)</span>
}
</div>
<h2 class="showcase-card-title">@session.Title</h2>
<div class="showcase-card-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
@if (session.DurationMinutes.HasValue)
{
<span>@FormatDuration(session.DurationMinutes.Value)</span>
}
</div>
<div class="showcase-card-seats">
<span>@FormatSeats(session)</span>
</div>
<div class="showcase-card-club">
<span>@session.GroupName</span>
</div>
<div class="showcase-card-actions">
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Подробнее</a>
@if (session.AllowDirectRegistration)
{
<button class="btn-gm btn-gm-primary">Записаться</button>
}
</div>
</div>
</article>
}
</div>
@if (hasMore)
{
<div class="showcase-load-more">
<button class="btn-gm btn-gm-primary" @onclick="LoadMoreAsync" disabled="@loading">
@if (loading)
{
<span>Загрузка...</span>
}
else
{
<span>Загрузить ещё</span>
}
</button>
</div>
}
}
<style>
.showcase-filters {
display: flex;
flex-wrap: wrap;
gap: 1rem 1.5rem;
margin-bottom: 1.5rem;
padding: 1.25rem;
}
.showcase-filter-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.showcase-filter-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: 'Jura', sans-serif;
}
.showcase-filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.showcase-filter-buttons .btn-gm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.showcase-filter-select {
min-width: 180px;
width: auto;
}
.showcase-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
@@media (min-width: 640px) {
.showcase-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@media (min-width: 1024px) {
.showcase-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.showcase-card {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.showcase-card-image {
height: 160px;
background-size: cover;
background-position: center;
}
.showcase-card-body {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
.showcase-card-badges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.showcase-card-title {
font-size: 1.0625rem;
margin: 0;
font-family: 'Cinzel', serif;
overflow-wrap: anywhere;
}
.showcase-card-meta,
.showcase-card-seats,
.showcase-card-club {
font-size: 0.8125rem;
color: var(--text-secondary);
font-family: 'Jura', sans-serif;
}
.showcase-card-club {
color: var(--text-muted);
}
.showcase-card-actions {
margin-top: auto;
padding-top: 0.75rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.showcase-card-actions .btn-gm {
flex: 1;
justify-content: center;
}
.showcase-load-more {
display: flex;
justify-content: center;
margin-bottom: 2rem;
}
.showcase-skeleton {
padding: 0;
overflow: hidden;
}
.showcase-skeleton-image {
height: 160px;
border-radius: 0;
}
.showcase-skeleton .showcase-card-body {
padding: 1rem;
}
.showcase-skeleton .skeleton-text {
margin-bottom: 0.5rem;
}
</style>
@code {
private ShowcaseFilter filter = new();
private List<ShowcaseSessionDto> sessions = new();
private bool loading;
private bool hasMore;
private int page = 1;
private const int PageSize = 12;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
loading = true;
page = 1;
sessions.Clear();
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
sessions.AddRange(results);
hasMore = results.Count == PageSize;
loading = false;
}
private async Task LoadMoreAsync()
{
loading = true;
page++;
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
sessions.AddRange(results);
hasMore = results.Count == PageSize;
loading = false;
}
private async Task OnFilterChanged()
{
await LoadAsync();
}
private async Task SetDate(DateFilter value)
{
filter = filter with { Date = value };
await OnFilterChanged();
}
private async Task SetSeats(SeatFilter value)
{
filter = filter with { Seats = value };
await OnFilterChanged();
}
private async Task OnSystemChanged(ChangeEventArgs e)
{
var value = e.Value?.ToString();
filter = filter with { System = string.IsNullOrWhiteSpace(value) ? null : value };
await OnFilterChanged();
}
private async Task SetOneShot(bool? value)
{
filter = filter with { IsOneShot = value };
await OnFilterChanged();
}
private async Task SetFormat(string? value)
{
filter = filter with { Format = value };
await OnFilterChanged();
}
private static string GetGradientStyle(Guid id)
{
var bytes = id.ToByteArray();
var hue1 = bytes[0] % 360;
var hue2 = (bytes[1] + 120) % 360;
return $"linear-gradient(135deg, hsl({hue1}, 55%, 28%) 0%, hsl({hue2}, 55%, 20%) 100%)";
}
private static string GetSystemDisplayName(string? system)
{
if (string.IsNullOrWhiteSpace(system))
return system ?? string.Empty;
if (Enum.TryParse<GameSystem>(system, out var gs))
return gs.ToDisplayName();
return system;
}
private static string FormatSeats(ShowcaseSessionDto session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
if (session.WaitlistedPlayerCount > 0)
seats += $", ожидание {session.WaitlistedPlayerCount}";
return seats;
}
private static string FormatDuration(int minutes)
{
if (minutes < 60)
return $"{minutes} мин";
var hours = minutes / 60;
var mins = minutes % 60;
return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч";
}
private static string TranslateFormat(string format) => format switch
{
"Online" => "Онлайн",
"Offline" => "Офлайн",
"Hybrid" => "Гибрид",
_ => format
};
}