0c1d3abd7e
PR Checks / test-and-build (pull_request) Successful in 12m32s
Add sanitized public GM profiles with publication controls, public /gm/{slug} pages, and links from public game surfaces.
Bump version -> 3.5.0
454 lines
15 KiB
Plaintext
454 lines
15 KiB
Plaintext
@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">
|
|
<label class="showcase-filter-label" for="system-filter">Система</label>
|
|
<select id="system-filter" class="gm-form-control showcase-filter-select" aria-label="Система" @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>
|
|
@if (!string.IsNullOrWhiteSpace(session.MasterProfileSlug))
|
|
{
|
|
<div class="showcase-card-master">
|
|
<a href="@MasterProfilePath(session.MasterProfileSlug)">@(session.MasterDisplayName ?? "Профиль мастера")</a>
|
|
</div>
|
|
}
|
|
<div class="showcase-card-actions">
|
|
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Подробнее</a>
|
|
@if (session.AllowDirectRegistration)
|
|
{
|
|
<a class="btn-gm btn-gm-primary" href="@($"/s/{session.Id}?register=1")">Записаться</a>
|
|
}
|
|
</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,
|
|
.showcase-card-master {
|
|
font-size: 0.8125rem;
|
|
color: var(--text-secondary);
|
|
font-family: 'Jura', sans-serif;
|
|
}
|
|
|
|
.showcase-card-club {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.showcase-card-master a {
|
|
color: var(--accent-primary);
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.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;
|
|
try
|
|
{
|
|
page = 1;
|
|
sessions.Clear();
|
|
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
|
|
sessions.AddRange(results);
|
|
hasMore = results.Count == PageSize;
|
|
}
|
|
finally
|
|
{
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
private async Task LoadMoreAsync()
|
|
{
|
|
if (loading)
|
|
return;
|
|
|
|
loading = true;
|
|
try
|
|
{
|
|
page++;
|
|
var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize);
|
|
sessions.AddRange(results);
|
|
hasMore = results.Count == PageSize;
|
|
}
|
|
finally
|
|
{
|
|
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 MasterProfilePath(string slug) => $"/gm/{slug}";
|
|
|
|
private static string TranslateFormat(string format) => format switch
|
|
{
|
|
"Online" => "Онлайн",
|
|
"Offline" => "Офлайн",
|
|
"Hybrid" => "Гибрид",
|
|
_ => format
|
|
};
|
|
}
|