Compare commits

..

20 Commits

Author SHA1 Message Date
Toutsu d81564c308 Merge pull request #109: feat: добавить каталог игр и витрину ваншотов (issue #39)
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m21s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-28 17:56:53 +03:00
Toutsu accb3b2405 fix(web): правильный парсинг query string для ?register=1
PR Checks / test-and-build (pull_request) Successful in 12m50s
Заменен Navigation.Uri.Contains() на QueryHelpers.ParseQuery
для корректного определения параметра register без ложных
срабатываний на подстроки (например, register=10).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:28:51 +03:00
Toutsu a63e3bef1e fix: финальные правки ревью для issue #39
PR Checks / test-and-build (pull_request) Successful in 13m16s
- PublicSession.razor: добавлена обработка ?register=1, AuthStateProvider,
  TryGetPlatformIdentity, кнопки записи для авторизованных/неавторизованных
  пользователей, отображение результата регистрации
- CreateSessionHandler: добавлен cover_image_url в INSERT SQL
- DiscordProjectStructureTests: версия 3.4.0 во всех проверках
- README.md: актуальная версия v3.4.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:00:33 +03:00
Toutsu 9d9aca53df chore: bump version to 3.4.0 2026-05-28 16:43:11 +03:00
Toutsu 5b6971fda5 test: consolidate capacity tests, add GameSystem edge cases, remove ShowcaseQueryTests 2026-05-28 16:40:11 +03:00
Toutsu b496a401fc test: add GameSystem fuzzy matching and showcase query tests
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:33:29 +03:00
Toutsu 76c6818952 fix(shared): add missing Platform parameter in players upsert 2026-05-28 16:28:25 +03:00
Toutsu 633a020212 fix(shared): convert GameSystem to string in SQL, guard rollback after commit 2026-05-28 16:26:54 +03:00
Toutsu ab38238fe8 feat(shared): extend CreateSessionCommand with showcase metadata
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:20:44 +03:00
Toutsu 4145cacc52 fix(web): add session-description styles for public session detail 2026-05-28 16:18:58 +03:00
Toutsu 6d59737d07 feat(web): update public session detail with showcase fields
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:13:35 +03:00
Toutsu 71ffcce06b fix(web): add try/finally, concurrency guard, accessible label, registration link to showcase 2026-05-28 16:07:09 +03:00
Toutsu 72f43dbef2 feat(web): add /showcase catalog page with filters
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:00:21 +03:00
Toutsu a5f4a68c6a fix(web): add public-session guards and ON CONFLICT to RegisterFromShowcaseAsync 2026-05-28 15:40:21 +03:00
Toutsu b2497ed877 feat(web): add showcase query and registration methods 2026-05-28 15:27:18 +03:00
Toutsu 9b42ea034a fix(shared): align GameSystem enum with spec, make TryParseFuzzy nullable and display-name based 2026-05-28 15:06:39 +03:00
Toutsu f94bea3e74 feat(shared): add GameSystem enum and Showcase DTOs
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:58:49 +03:00
Toutsu cde1e4311f feat(db): V027 add showcase fields to sessions 2026-05-28 14:46:04 +03:00
Toutsu 847a40815f docs: add implementation plan for issue #39 2026-05-28 14:41:12 +03:00
Toutsu 6fd03ef836 docs: add design spec for issue #39 game catalog and showcase 2026-05-28 14:31:17 +03:00
22 changed files with 2567 additions and 23 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 3.3.0 VERSION: 3.4.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v3.3.0`. **Текущая версия:** `v3.4.0`.
--- ---
+3 -3
View File
@@ -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:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
# Game Catalog and One-Shot Showcase — Design Spec
> Issue #39: feat: добавить каталог игр и витрину ваншотов
---
## Goal
Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog. Users can browse games by system, format, date, and availability. GM controls whether direct registration from the catalog is allowed. The catalog respects existing seat limits and waitlist logic.
---
## Architecture
Extend the existing public-pages infrastructure (V026) with new session metadata fields, a cross-group query layer in `ISessionStore`, and new Razor pages in `GmRelay.Web`. Bot flows (Telegram + Discord) are updated to collect the new fields during session creation. Fuzzy matching on game system names is performed client-side in the bot UI.
---
## Tech Stack
- .NET 10, Blazor Server, Dapper.AOT, Npgsql
- Existing: `PublicLayout`, `ISessionStore`, `SessionService`, `SessionCapacityRules`
- New: `GameSystem` enum, `ShowcaseFilter` record, `ShowcaseSessionDto`
---
## Data Model
### New Fields on `sessions` (Migration V027)
| Column | Type | Constraints | Description |
|---|---|---|---|
| `is_one_shot` | `BOOLEAN` | `NOT NULL DEFAULT false` | One-shot or campaign |
| `system` | `VARCHAR(50)` | nullable | Game system name (enum value or custom) |
| `description` | `TEXT` | nullable | Short description for card |
| `cover_image_url` | `TEXT` | nullable | Cover image URL |
| `duration_minutes` | `INTEGER` | nullable | Duration in minutes |
| `format` | `VARCHAR(20)` | `CHECK (format IN ('Online','Offline','Hybrid'))`, nullable | Session format |
| `allow_direct_registration` | `BOOLEAN` | `NOT NULL DEFAULT false` | Allow direct registration from showcase |
### `GameSystem` Enum
```csharp
public enum GameSystem
{
Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark,
OldSchoolEssentials, Dragonbane, BladesInTheDark,
Daggerheart, CyberpunkRed, Mothership, AlienRpg,
WarhammerFantasy, VampireMasquerade5e, StarWarsFfg,
Genesys, SavageWorlds, GURPS, Fate, DungeonWorld,
Ironsworn, Other
}
```
Stored as `VARCHAR(50)` in DB (not native enum) to allow future extension without migration.
### DTOs
```csharp
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);
public sealed record ShowcaseFilter(
DateFilter Date = DateFilter.All,
SeatFilter Seats = SeatFilter.Any,
GameSystem? System = null,
bool? IsOneShot = null,
string? Format = null);
public enum DateFilter { Today, Tomorrow, ThisWeek, All }
public enum SeatFilter { Available, Waitlist, Any }
```
---
## UI Design
### `/showcase` — Catalog Page
**Layout:**
- Hero with title "Каталог игр"
- Sticky filter bar (horizontal on desktop, collapsible on mobile)
- Responsive grid of session cards (1 col mobile, 2 col tablet, 3 col desktop)
- Pagination (page + pageSize = 12)
**Filters:**
- Date: "Сегодня" | "Завтра" | "На неделю" | "Все"
- Seats: "Есть места" | "Waitlist" | "Любое"
- System: dropdown with all `GameSystem` values
- Type: "Ваншот" | "Кампания" | "Любое"
- Format: "Онлайн" | "Офлайн" | "Гибрид" | "Любое"
**Card Design:**
- Cover image (fallback: colored placeholder with initials)
- Title
- System badge
- Date + time (MSK)
- Duration (e.g. "3 часа")
- Format badge
- Seats indicator: "5/6 мест" | "Waitlist (3)" | "Мест нет"
- Club name (link to `/club/{slug}`)
- Buttons: "Подробнее" → `/s/{id}`, "Записаться" (if `AllowDirectRegistration`)
### `/s/{id}` — Public Session Detail (Updated)
New fields added to existing page:
- Cover image (full-width hero)
- System badge
- Description block
- Duration + format
- GM contact (always visible: Telegram username or Discord tag)
- If `allow_direct_registration`:
- "Записаться" button → Telegram Mini App deeplink or Discord OAuth
- Direct registration into `session_participants` via `SessionCapacityRules`
---
## Backend
### ISessionStore Methods
```csharp
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(
ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, PlatformUser user);
```
`GetShowcaseSessionsAsync` query:
- Cross-group (all clubs with `public_schedule_enabled = true`)
- Only `is_public = true` sessions
- `scheduled_at > now() - interval '4 hours'`
- `status <> 'Cancelled'`
- Apply filters in SQL WHERE clause
- Order by `scheduled_at ASC`
- Offset/limit pagination
`RegisterFromShowcaseAsync`:
- Check `allow_direct_registration = true`
- Load session with `FOR UPDATE`
- Count active + waitlisted participants
- Use `SessionCapacityRules.DecideJoinStatus`
- Insert participant with appropriate `registration_status`
- Return true on success, false if full and no waitlist allowed
---
## Bot Integration
### Telegram Bot
During `CreateSessionCommand` flow, after title/link/time input:
1. "Выберите систему:" inline keyboard with `GameSystem` values + "Другое"
2. If text input instead of button: fuzzy match against display names (Levenshtein/Contains/StartsWith)
3. "Описание игры (краткое):" — text input, optional (skip button)
4. "Формат:" inline keyboard — "Онлайн" | "Офлайн" | "Гибрид"
5. "Продолжительность (в часах):" — int input, optional
6. "Обложка (URL или пропустить):" — text input, optional
During `/publish` flow:
- "Разрешить прямую запись из каталога?" — yes/no toggle (default: no)
### Discord Bot
Same flow adapted for Discord interactions:
- Slash command options or button menus for system/format
- Modal input for description, duration, cover URL
- Fuzzy matching on free-text system input
---
## Migration V027
```sql
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';
```
---
## Testing Strategy
1. **Unit tests:** `SessionCapacityRules` with showcase registration scenarios
2. **Integration tests:** `GetShowcaseSessionsAsync` with each filter combination
3. **UI tests:** `Showcase.razor` rendering with/without cover images, filters applied
4. **Bot tests:** Fuzzy matching algorithm for `GameSystem` resolution
---
## Version Bump
Issue label: `type:feature`**minor bump**
Current: `3.3.0` → Next: `3.4.0`
Files to sync:
- `Directory.Build.props`
- `compose.yaml` (bot, discord, web image tags)
- `.gitea/workflows/deploy.yml` (`VERSION` env)
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
---
## Acceptance Criteria (from Issue #39)
- [ ] User can find a published game without accessing a private dashboard
- [ ] Registration does not bypass existing seat/waitlist limits
- [ ] Owner/co-GM controls what appears in the showcase via `is_public` + `allow_direct_registration`
- [ ] Filters work: date, seats, system, type, format
- [ ] GM contact is always visible on public session detail
- [ ] Direct registration respects `SessionCapacityRules`
@@ -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';
+83
View File
@@ -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(
+241
View File
@@ -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();
+18
View File
@@ -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);