feat(web): add /showcase catalog page with filters
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user