This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 3.3.0
|
VERSION: 3.4.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.3.0</Version>
|
<Version>3.4.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v3.3.0`.
|
**Текущая версия:** `v3.4.0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.3.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.4.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.3.0
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.4.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -84,7 +84,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.3.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.4.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Showcase fields for game catalog / public session browsing.
|
||||||
|
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN system VARCHAR(50),
|
||||||
|
ADD COLUMN description TEXT,
|
||||||
|
ADD COLUMN cover_image_url TEXT,
|
||||||
|
ADD COLUMN duration_minutes INTEGER,
|
||||||
|
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
|
||||||
|
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE INDEX ix_sessions_showcase
|
||||||
|
ON sessions (scheduled_at, system, is_one_shot, format)
|
||||||
|
WHERE is_public = true AND status <> 'Cancelled';
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
|
namespace GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
public enum GameSystem
|
||||||
|
{
|
||||||
|
Dnd5e,
|
||||||
|
Pathfinder2e,
|
||||||
|
CallOfCthulhu7e,
|
||||||
|
Shadowdark,
|
||||||
|
OldSchoolEssentials,
|
||||||
|
Dragonbane,
|
||||||
|
BladesInTheDark,
|
||||||
|
Daggerheart,
|
||||||
|
CyberpunkRed,
|
||||||
|
Mothership,
|
||||||
|
AlienRpg,
|
||||||
|
WarhammerFantasy,
|
||||||
|
VampireMasquerade5e,
|
||||||
|
StarWarsFfg,
|
||||||
|
Genesys,
|
||||||
|
SavageWorlds,
|
||||||
|
GURPS,
|
||||||
|
Fate,
|
||||||
|
DungeonWorld,
|
||||||
|
Ironsworn,
|
||||||
|
Other
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class GameSystemExtensions
|
||||||
|
{
|
||||||
|
private static readonly FrozenDictionary<GameSystem, string> DisplayNames =
|
||||||
|
new Dictionary<GameSystem, string>
|
||||||
|
{
|
||||||
|
[GameSystem.Dnd5e] = "D&D 5e",
|
||||||
|
[GameSystem.Pathfinder2e] = "Pathfinder 2e",
|
||||||
|
[GameSystem.CallOfCthulhu7e] = "Call of Cthulhu 7e",
|
||||||
|
[GameSystem.Shadowdark] = "Shadowdark",
|
||||||
|
[GameSystem.OldSchoolEssentials] = "Old School Essentials",
|
||||||
|
[GameSystem.Dragonbane] = "Dragonbane",
|
||||||
|
[GameSystem.BladesInTheDark] = "Blades in the Dark",
|
||||||
|
[GameSystem.Daggerheart] = "Daggerheart",
|
||||||
|
[GameSystem.CyberpunkRed] = "Cyberpunk RED",
|
||||||
|
[GameSystem.Mothership] = "Mothership",
|
||||||
|
[GameSystem.AlienRpg] = "Alien RPG",
|
||||||
|
[GameSystem.WarhammerFantasy] = "Warhammer Fantasy",
|
||||||
|
[GameSystem.VampireMasquerade5e] = "Vampire: The Masquerade 5e",
|
||||||
|
[GameSystem.StarWarsFfg] = "Star Wars (FFG)",
|
||||||
|
[GameSystem.Genesys] = "Genesys",
|
||||||
|
[GameSystem.SavageWorlds] = "Savage Worlds",
|
||||||
|
[GameSystem.GURPS] = "GURPS",
|
||||||
|
[GameSystem.Fate] = "Fate",
|
||||||
|
[GameSystem.DungeonWorld] = "Dungeon World",
|
||||||
|
[GameSystem.Ironsworn] = "Ironsworn",
|
||||||
|
[GameSystem.Other] = "Другое"
|
||||||
|
}.ToFrozenDictionary();
|
||||||
|
|
||||||
|
public static string ToDisplayName(this GameSystem system) =>
|
||||||
|
DisplayNames.TryGetValue(system, out var name) ? name : "Другое";
|
||||||
|
|
||||||
|
public static GameSystem? TryParseFuzzy(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var normalized = input.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (Enum.TryParse<GameSystem>(normalized, true, out var exact))
|
||||||
|
return exact;
|
||||||
|
|
||||||
|
foreach (var value in Enum.GetValues<GameSystem>())
|
||||||
|
{
|
||||||
|
if (value == GameSystem.Other)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var display = value.ToDisplayName().ToLowerInvariant();
|
||||||
|
if (display == normalized || display.Contains(normalized) || normalized.Contains(display))
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GameSystem.Other;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
|
|
||||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
@@ -9,4 +10,9 @@ public sealed record CreateSessionCommand(
|
|||||||
string Link,
|
string Link,
|
||||||
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
||||||
int? MaxPlayers,
|
int? MaxPlayers,
|
||||||
string? ImageReference);
|
string? ImageReference,
|
||||||
|
GameSystem? System = null,
|
||||||
|
string? Description = null,
|
||||||
|
string? Format = null,
|
||||||
|
int? DurationMinutes = null,
|
||||||
|
bool IsOneShot = false);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class CreateSessionHandler(
|
|||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
|
var transactionCommitted = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var platform = command.User.Platform.ToString();
|
var platform = command.User.Platform.ToString();
|
||||||
@@ -33,7 +34,7 @@ public sealed class CreateSessionHandler(
|
|||||||
SET display_name = EXCLUDED.display_name,
|
SET display_name = EXCLUDED.display_name,
|
||||||
external_username = EXCLUDED.external_username;
|
external_username = EXCLUDED.external_username;
|
||||||
""",
|
""",
|
||||||
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername },
|
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername, Platform = platform },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||||
@@ -117,8 +118,8 @@ public sealed class CreateSessionHandler(
|
|||||||
{
|
{
|
||||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
|
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url)
|
||||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
|
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
@@ -129,7 +130,13 @@ public sealed class CreateSessionHandler(
|
|||||||
Link = command.Link,
|
Link = command.Link,
|
||||||
ScheduledAt = scheduledAt,
|
ScheduledAt = scheduledAt,
|
||||||
Status = SessionStatus.Planned,
|
Status = SessionStatus.Planned,
|
||||||
MaxPlayers = command.MaxPlayers
|
MaxPlayers = command.MaxPlayers,
|
||||||
|
System = command.System?.ToString(),
|
||||||
|
command.Description,
|
||||||
|
command.Format,
|
||||||
|
DurationMinutes = command.DurationMinutes,
|
||||||
|
IsOneShot = command.IsOneShot,
|
||||||
|
CoverImageUrl = command.ImageReference
|
||||||
},
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
@@ -137,6 +144,7 @@ public sealed class CreateSessionHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
transactionCommitted = true;
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
|
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
|
||||||
@@ -150,7 +158,10 @@ public sealed class CreateSessionHandler(
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
await transaction.RollbackAsync(ct);
|
if (!transactionCommitted)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(ct);
|
||||||
|
}
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace GmRelay.Shared.Features.Showcase;
|
||||||
|
|
||||||
|
public sealed record ShowcaseFilter(
|
||||||
|
DateFilter Date = DateFilter.All,
|
||||||
|
SeatFilter Seats = SeatFilter.Any,
|
||||||
|
string? System = null,
|
||||||
|
bool? IsOneShot = null,
|
||||||
|
string? Format = null);
|
||||||
|
|
||||||
|
public enum DateFilter
|
||||||
|
{
|
||||||
|
Today,
|
||||||
|
Tomorrow,
|
||||||
|
ThisWeek,
|
||||||
|
All
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SeatFilter
|
||||||
|
{
|
||||||
|
Available,
|
||||||
|
Waitlist,
|
||||||
|
Any
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace GmRelay.Shared.Features.Showcase;
|
||||||
|
|
||||||
|
public sealed record ShowcaseSessionDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid GroupId,
|
||||||
|
string GroupName,
|
||||||
|
string? GroupSlug,
|
||||||
|
string Title,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
|
string? System,
|
||||||
|
bool IsOneShot,
|
||||||
|
string? Format,
|
||||||
|
int? DurationMinutes,
|
||||||
|
string? CoverImageUrl,
|
||||||
|
int? MaxPlayers,
|
||||||
|
int ActivePlayerCount,
|
||||||
|
int WaitlistedPlayerCount,
|
||||||
|
bool AllowDirectRegistration,
|
||||||
|
string? Description);
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v3.3.0</div>
|
<div class="nav-version">v3.4.0</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
@layout PublicLayout
|
@layout PublicLayout
|
||||||
@inject ISessionStore SessionStore
|
@inject ISessionStore SessionStore
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@using GmRelay.Shared.Features.Showcase
|
||||||
|
@using GmRelay.Web.Services
|
||||||
|
|
||||||
<PageTitle>@PageTitleText</PageTitle>
|
<PageTitle>@PageTitleText</PageTitle>
|
||||||
|
|
||||||
@@ -30,10 +33,29 @@ else if (session is not null)
|
|||||||
<meta name="description" content="@($"Публичная сессия {session.Title} клуба {session.GroupName} в GM-Relay.")" />
|
<meta name="description" content="@($"Публичная сессия {session.Title} клуба {session.GroupName} в GM-Relay.")" />
|
||||||
</HeadContent>
|
</HeadContent>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.CoverImageUrl))
|
||||||
|
{
|
||||||
|
<div class="session-cover-hero" style="background-image: url('@session.CoverImageUrl')"></div>
|
||||||
|
}
|
||||||
|
|
||||||
<section class="public-hero public-hero-compact">
|
<section class="public-hero public-hero-compact">
|
||||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||||
<h1>@session.Title</h1>
|
<h1>@session.Title</h1>
|
||||||
<p>@session.GroupName</p>
|
<p>@session.GroupName</p>
|
||||||
|
<div class="session-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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<article class="glass-card public-session-detail">
|
<article class="glass-card public-session-detail">
|
||||||
@@ -50,14 +72,47 @@ else if (session is not null)
|
|||||||
<span>Статус</span>
|
<span>Статус</span>
|
||||||
<strong>@TranslateStatus(session.Status)</strong>
|
<strong>@TranslateStatus(session.Status)</strong>
|
||||||
</div>
|
</div>
|
||||||
|
@if (session.DurationMinutes.HasValue)
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<span>Длительность</span>
|
||||||
|
<strong>@FormatDuration(session.DurationMinutes.Value)</strong>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(session.Description))
|
||||||
|
{
|
||||||
|
<div class="session-description">
|
||||||
|
<h3>Описание</h3>
|
||||||
|
<p>@session.Description</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (registrationResult is not null)
|
||||||
|
{
|
||||||
|
<div class="glass-card @GetRegistrationResultClass()">
|
||||||
|
<p>@registrationResult</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="public-settings-actions">
|
<div class="public-settings-actions">
|
||||||
@if (!string.IsNullOrWhiteSpace(session.GroupSlug))
|
@if (!string.IsNullOrWhiteSpace(session.GroupSlug))
|
||||||
{
|
{
|
||||||
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
|
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
|
||||||
}
|
}
|
||||||
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
|
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
|
||||||
|
@if (session.AllowDirectRegistration)
|
||||||
|
{
|
||||||
|
@if (isAuthenticated)
|
||||||
|
{
|
||||||
|
<button class="btn-gm btn-gm-primary" @onclick="RegisterAsync">Записаться</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a class="btn-gm btn-gm-primary" href="@GetLoginUrl()">Войти, чтобы записаться</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
@@ -65,8 +120,10 @@ else if (session is not null)
|
|||||||
@code {
|
@code {
|
||||||
[Parameter] public Guid SessionId { get; set; }
|
[Parameter] public Guid SessionId { get; set; }
|
||||||
|
|
||||||
private WebPublicSession? session;
|
private ShowcaseSessionDto? session;
|
||||||
private bool loaded;
|
private bool loaded;
|
||||||
|
private bool isAuthenticated;
|
||||||
|
private string? registrationResult;
|
||||||
|
|
||||||
private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay";
|
private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay";
|
||||||
|
|
||||||
@@ -75,11 +132,52 @@ else if (session is not null)
|
|||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
loaded = false;
|
loaded = false;
|
||||||
session = await SessionStore.GetPublicSessionAsync(SessionId);
|
registrationResult = null;
|
||||||
|
session = await SessionStore.GetShowcaseSessionAsync(SessionId);
|
||||||
|
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false;
|
||||||
|
|
||||||
|
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||||
|
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
var shouldRegister = query.TryGetValue("register", out var val) && val == "1";
|
||||||
|
|
||||||
|
if (session is not null && shouldRegister && session.AllowDirectRegistration)
|
||||||
|
{
|
||||||
|
if (isAuthenticated && authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||||
|
{
|
||||||
|
var success = await SessionStore.RegisterFromShowcaseAsync(SessionId, platform, externalUserId, authState.User.Identity?.Name ?? "Игрок");
|
||||||
|
registrationResult = success
|
||||||
|
? "Вы успешно записались на игру!"
|
||||||
|
: "Не удалось записаться. Возможно, места закончились или вы уже зарегистрированы.";
|
||||||
|
}
|
||||||
|
else if (!isAuthenticated)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString($"/s/{SessionId}")}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatSeats(WebPublicSession session)
|
private async Task RegisterAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
if (authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||||
|
{
|
||||||
|
var success = await SessionStore.RegisterFromShowcaseAsync(SessionId, platform, externalUserId, authState.User.Identity?.Name ?? "Игрок");
|
||||||
|
registrationResult = success
|
||||||
|
? "Вы успешно записались на игру!"
|
||||||
|
: "Не удалось записаться. Возможно, места закончились или вы уже зарегистрированы.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetLoginUrl() => $"/login?returnUrl={Uri.EscapeDataString($"/s/{SessionId}?register=1")}";
|
||||||
|
|
||||||
|
private string GetRegistrationResultClass() => registrationResult?.StartsWith("Вы успешно") == true ? "status-success-bg" : "status-warning-bg";
|
||||||
|
|
||||||
|
private static string FormatSeats(ShowcaseSessionDto session)
|
||||||
{
|
{
|
||||||
var seats = session.MaxPlayers.HasValue
|
var seats = session.MaxPlayers.HasValue
|
||||||
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||||
@@ -90,6 +188,35 @@ else if (session is not null)
|
|||||||
: seats;
|
: 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 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 TranslateFormat(string format) => format switch
|
||||||
|
{
|
||||||
|
"Online" => "Онлайн",
|
||||||
|
"Offline" => "Офлайн",
|
||||||
|
"Hybrid" => "Гибрид",
|
||||||
|
_ => format
|
||||||
|
};
|
||||||
|
|
||||||
private static string GetStatusClass(string status) => status switch
|
private static string GetStatusClass(string status) => status switch
|
||||||
{
|
{
|
||||||
SessionStatus.Confirmed => "status-success",
|
SessionStatus.Confirmed => "status-success",
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
@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>
|
||||||
|
<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 {
|
||||||
|
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;
|
||||||
|
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 TranslateFormat(string format) => format switch
|
||||||
|
{
|
||||||
|
"Online" => "Онлайн",
|
||||||
|
"Offline" => "Офлайн",
|
||||||
|
"Hybrid" => "Гибрид",
|
||||||
|
_ => format
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Features.Showcase;
|
||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
@@ -91,6 +92,11 @@ public interface ISessionStore
|
|||||||
Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName);
|
Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName);
|
||||||
Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId);
|
Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId);
|
||||||
Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl);
|
Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl);
|
||||||
|
|
||||||
|
// --- Showcase / game catalog (issue #39) ---
|
||||||
|
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
|
||||||
|
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
||||||
|
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record LinkedIdentity(
|
public sealed record LinkedIdentity(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Features.Showcase;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
@@ -110,6 +111,24 @@ internal sealed record WebBatchSessionRow(
|
|||||||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||||||
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
||||||
internal sealed record WebPublicGroupRow(Guid GroupId, string Name, string Slug);
|
internal sealed record WebPublicGroupRow(Guid GroupId, string Name, string Slug);
|
||||||
|
internal sealed record ShowcaseSessionRow(
|
||||||
|
Guid Id,
|
||||||
|
Guid GroupId,
|
||||||
|
string GroupName,
|
||||||
|
string? GroupSlug,
|
||||||
|
string Title,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
|
string? System,
|
||||||
|
bool IsOneShot,
|
||||||
|
string? Format,
|
||||||
|
int? DurationMinutes,
|
||||||
|
string? CoverImageUrl,
|
||||||
|
int? MaxPlayers,
|
||||||
|
int ActivePlayerCount,
|
||||||
|
int WaitlistedPlayerCount,
|
||||||
|
bool AllowDirectRegistration,
|
||||||
|
string? Description);
|
||||||
|
|
||||||
public sealed class SessionService(
|
public sealed class SessionService(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
@@ -362,6 +381,228 @@ public sealed class SessionService(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
var rows = await conn.QueryAsync<ShowcaseSessionRow>(
|
||||||
|
"""
|
||||||
|
SELECT s.id AS Id,
|
||||||
|
s.group_id AS GroupId,
|
||||||
|
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||||
|
g.public_slug AS GroupSlug,
|
||||||
|
s.title AS Title,
|
||||||
|
s.scheduled_at AS ScheduledAt,
|
||||||
|
s.status AS Status,
|
||||||
|
s.system AS System,
|
||||||
|
s.is_one_shot AS IsOneShot,
|
||||||
|
s.format AS Format,
|
||||||
|
s.duration_minutes AS DurationMinutes,
|
||||||
|
s.cover_image_url AS CoverImageUrl,
|
||||||
|
s.max_players AS MaxPlayers,
|
||||||
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
|
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||||
|
s.allow_direct_registration AS AllowDirectRegistration,
|
||||||
|
s.description AS Description
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT recent.title
|
||||||
|
FROM sessions recent
|
||||||
|
WHERE recent.group_id = g.id
|
||||||
|
ORDER BY recent.scheduled_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest_session ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM session_participants sp
|
||||||
|
WHERE sp.session_id = s.id
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
) active_counts ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM session_participants sp
|
||||||
|
WHERE sp.session_id = s.id
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Waitlisted
|
||||||
|
) waitlist_counts ON true
|
||||||
|
WHERE g.public_schedule_enabled = true
|
||||||
|
AND g.public_slug IS NOT NULL
|
||||||
|
AND s.is_public = true
|
||||||
|
AND s.scheduled_at > now() - interval '4 hours'
|
||||||
|
AND s.status <> @Cancelled
|
||||||
|
AND (
|
||||||
|
@DateFilter = 'All'
|
||||||
|
OR (@DateFilter = 'Today' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '1 day')
|
||||||
|
OR (@DateFilter = 'Tomorrow' AND s.scheduled_at >= CURRENT_DATE + interval '1 day' AND s.scheduled_at < CURRENT_DATE + interval '2 days')
|
||||||
|
OR (@DateFilter = 'ThisWeek' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '7 days')
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
@SeatFilter = 'Any'
|
||||||
|
OR (@SeatFilter = 'Available' AND (s.max_players IS NULL OR active_counts.count < s.max_players))
|
||||||
|
OR (@SeatFilter = 'Waitlist' AND (s.max_players IS NOT NULL AND active_counts.count >= s.max_players))
|
||||||
|
)
|
||||||
|
AND (@System IS NULL OR s.system = @System)
|
||||||
|
AND (@IsOneShot IS NULL OR s.is_one_shot = @IsOneShot)
|
||||||
|
AND (@Format IS NULL OR s.format = @Format)
|
||||||
|
ORDER BY s.scheduled_at ASC
|
||||||
|
LIMIT @PageSize OFFSET @Offset
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||||
|
Cancelled = SessionStatus.Cancelled,
|
||||||
|
DateFilter = filter.Date.ToString(),
|
||||||
|
SeatFilter = filter.Seats.ToString(),
|
||||||
|
filter.System,
|
||||||
|
filter.IsOneShot,
|
||||||
|
filter.Format,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Offset = (page - 1) * pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.Select(r => new ShowcaseSessionDto(
|
||||||
|
r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
|
||||||
|
r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
|
||||||
|
r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration,
|
||||||
|
r.Description)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
var row = await conn.QuerySingleOrDefaultAsync<ShowcaseSessionRow>(
|
||||||
|
"""
|
||||||
|
SELECT s.id AS Id,
|
||||||
|
s.group_id AS GroupId,
|
||||||
|
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||||||
|
g.public_slug AS GroupSlug,
|
||||||
|
s.title AS Title,
|
||||||
|
s.scheduled_at AS ScheduledAt,
|
||||||
|
s.status AS Status,
|
||||||
|
s.system AS System,
|
||||||
|
s.is_one_shot AS IsOneShot,
|
||||||
|
s.format AS Format,
|
||||||
|
s.duration_minutes AS DurationMinutes,
|
||||||
|
s.cover_image_url AS CoverImageUrl,
|
||||||
|
s.max_players AS MaxPlayers,
|
||||||
|
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||||
|
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||||||
|
s.allow_direct_registration AS AllowDirectRegistration,
|
||||||
|
s.description AS Description
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT recent.title
|
||||||
|
FROM sessions recent
|
||||||
|
WHERE recent.group_id = g.id
|
||||||
|
ORDER BY recent.scheduled_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest_session ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM session_participants sp
|
||||||
|
WHERE sp.session_id = s.id
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Active
|
||||||
|
) active_counts ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM session_participants sp
|
||||||
|
WHERE sp.session_id = s.id
|
||||||
|
AND sp.is_gm = false
|
||||||
|
AND sp.registration_status = @Waitlisted
|
||||||
|
) waitlist_counts ON true
|
||||||
|
WHERE s.id = @SessionId
|
||||||
|
AND g.public_schedule_enabled = true
|
||||||
|
AND g.public_slug IS NOT NULL
|
||||||
|
AND s.is_public = true
|
||||||
|
AND s.scheduled_at > now() - interval '4 hours'
|
||||||
|
AND s.status <> @Cancelled
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
Active = ParticipantRegistrationStatus.Active,
|
||||||
|
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||||||
|
Cancelled = SessionStatus.Cancelled
|
||||||
|
});
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new ShowcaseSessionDto(
|
||||||
|
row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status,
|
||||||
|
row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
|
||||||
|
row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration,
|
||||||
|
row.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
await using var transaction = await conn.BeginTransactionAsync();
|
||||||
|
|
||||||
|
var session = await conn.QuerySingleOrDefaultAsync<dynamic>(
|
||||||
|
"""
|
||||||
|
SELECT s.id, s.max_players AS MaxPlayers, s.allow_direct_registration AS AllowDirectRegistration
|
||||||
|
FROM sessions s
|
||||||
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
|
WHERE s.id = @SessionId
|
||||||
|
AND s.is_public = true
|
||||||
|
AND g.public_schedule_enabled = true
|
||||||
|
AND g.public_slug IS NOT NULL
|
||||||
|
AND s.scheduled_at > now() - interval '4 hours'
|
||||||
|
AND s.status <> @Cancelled
|
||||||
|
FOR UPDATE OF s
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (session is null || !(bool)session.allowdirectregistration)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerId = await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, null, transaction);
|
||||||
|
|
||||||
|
var registrationStatus = SessionCapacityRules.DecideJoinStatus(
|
||||||
|
(int?)session.maxplayers,
|
||||||
|
await conn.ExecuteScalarAsync<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM session_participants
|
||||||
|
WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active
|
||||||
|
""",
|
||||||
|
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
|
||||||
|
transaction));
|
||||||
|
|
||||||
|
var inserted = await conn.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status)
|
||||||
|
VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus)
|
||||||
|
ON CONFLICT (session_id, player_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
PlayerId = playerId,
|
||||||
|
Pending = RsvpStatus.Pending,
|
||||||
|
RegistrationStatus = registrationStatus
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (inserted == 0)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
|||||||
@@ -1795,6 +1795,24 @@ body.telegram-mini-app .session-card-mobile {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-description {
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-description h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-description p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.public-empty-state h2 {
|
.public-empty-state h2 {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public sealed class DiscordProjectStructureTests
|
|||||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||||
|
|
||||||
Assert.Contains("gmrelay-discord-bot:3.3.0", compose);
|
Assert.Contains("gmrelay-discord-bot:3.4.0", compose);
|
||||||
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
||||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||||
@@ -76,13 +76,13 @@ public sealed class DiscordProjectStructureTests
|
|||||||
{
|
{
|
||||||
var repoRoot = GetRepoRoot();
|
var repoRoot = GetRepoRoot();
|
||||||
|
|
||||||
Assert.Contains("<Version>3.3.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
Assert.Contains("<Version>3.4.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||||
Assert.Contains("VERSION: 3.3.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
Assert.Contains("VERSION: 3.4.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||||
Assert.Contains("gmrelay-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains("gmrelay-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains("gmrelay-web:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains("gmrelay-web:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains("gmrelay-discord-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains("gmrelay-discord-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"v3.3.0",
|
"v3.4.0",
|
||||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Domain;
|
||||||
|
|
||||||
|
public sealed class GameSystemTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Dnd5e", GameSystem.Dnd5e)]
|
||||||
|
[InlineData("D&D", GameSystem.Dnd5e)]
|
||||||
|
[InlineData("dnd5e", GameSystem.Dnd5e)]
|
||||||
|
[InlineData(" dnd5e ", GameSystem.Dnd5e)]
|
||||||
|
[InlineData("D&D 5e", GameSystem.Dnd5e)]
|
||||||
|
[InlineData("pathfinder", GameSystem.Pathfinder2e)]
|
||||||
|
[InlineData("call of cthulhu", GameSystem.CallOfCthulhu7e)]
|
||||||
|
[InlineData("shadow", GameSystem.Shadowdark)]
|
||||||
|
[InlineData("dark", GameSystem.Shadowdark)]
|
||||||
|
[InlineData("unknown xyz", GameSystem.Other)]
|
||||||
|
public void TryParseFuzzy_ShouldMapInputToExpectedSystem(string input, GameSystem expected)
|
||||||
|
{
|
||||||
|
var result = GameSystemExtensions.TryParseFuzzy(input);
|
||||||
|
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("днд")]
|
||||||
|
[InlineData("колова")]
|
||||||
|
public void TryParseFuzzy_ShouldReturnOtherForUnmatchedCyrillicInput(string input)
|
||||||
|
{
|
||||||
|
var result = GameSystemExtensions.TryParseFuzzy(input);
|
||||||
|
|
||||||
|
Assert.Equal(GameSystem.Other, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParseFuzzy_ShouldReturnNullForNullInput()
|
||||||
|
{
|
||||||
|
var result = GameSystemExtensions.TryParseFuzzy(null!);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void TryParseFuzzy_ShouldReturnNullForEmptyOrWhitespaceInput(string input)
|
||||||
|
{
|
||||||
|
var result = GameSystemExtensions.TryParseFuzzy(input);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(GameSystem.Dnd5e, "D&D 5e")]
|
||||||
|
[InlineData(GameSystem.Other, "Другое")]
|
||||||
|
[InlineData(GameSystem.Pathfinder2e, "Pathfinder 2e")]
|
||||||
|
[InlineData(GameSystem.Shadowdark, "Shadowdark")]
|
||||||
|
[InlineData((GameSystem)999, "Другое")]
|
||||||
|
public void ToDisplayName_ShouldReturnExpectedName(GameSystem system, string expected)
|
||||||
|
{
|
||||||
|
var result = system.ToDisplayName();
|
||||||
|
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,30 @@ public sealed class SessionCapacityRulesTests
|
|||||||
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
|
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecideJoinStatus_ShouldReturnActive_WhenUnlimitedSeats()
|
||||||
|
{
|
||||||
|
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: null, activeParticipants: 5);
|
||||||
|
|
||||||
|
Assert.Equal(ParticipantRegistrationStatus.Active, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecideJoinStatus_ShouldReturnWaitlisted_WhenOverCapacity()
|
||||||
|
{
|
||||||
|
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 3, activeParticipants: 5);
|
||||||
|
|
||||||
|
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecideJoinStatus_ShouldReturnActive_WhenZeroActiveAndPositiveMax()
|
||||||
|
{
|
||||||
|
var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 1, activeParticipants: 0);
|
||||||
|
|
||||||
|
Assert.Equal(ParticipantRegistrationStatus.Active, status);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CanPromoteWaitlistedPlayer_ShouldRequireWaitlistAndFreeSeat()
|
public void CanPromoteWaitlistedPlayer_ShouldRequireWaitlistAndFreeSeat()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using GmRelay.Web.Services;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Features.Showcase;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Web;
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
@@ -1209,6 +1210,15 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) =>
|
public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize) =>
|
||||||
|
Task.FromResult<IReadOnlyList<ShowcaseSessionDto>>([]);
|
||||||
|
|
||||||
|
public Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId) =>
|
||||||
|
Task.FromResult<ShowcaseSessionDto?>(null);
|
||||||
|
|
||||||
|
public Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) =>
|
||||||
|
Task.FromResult(false);
|
||||||
|
|
||||||
private bool IsManager(Guid groupId, long telegramId) =>
|
private bool IsManager(Guid groupId, long telegramId) =>
|
||||||
IsOwner(groupId, telegramId) ||
|
IsOwner(groupId, telegramId) ||
|
||||||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
|
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
|
||||||
|
|||||||
Reference in New Issue
Block a user