feat: add public club pages
PR Checks / test-and-build (pull_request) Successful in 12m47s

Add publication settings for clubs and sessions, read-only public club/session pages, dashboard controls, privacy-focused public queries, docs, and tests.

Bump version to 3.3.0
This commit is contained in:
2026-05-28 12:23:47 +03:00
parent fac5d75c7e
commit 3418d1a46c
18 changed files with 1239 additions and 24 deletions
@@ -0,0 +1,17 @@
-- Public club pages and read-only schedule publication controls.
ALTER TABLE game_groups
ADD COLUMN public_slug VARCHAR(120),
ADD COLUMN public_schedule_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN public_schedule_updated_at TIMESTAMPTZ;
ALTER TABLE sessions
ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false;
CREATE UNIQUE INDEX ux_game_groups_public_slug
ON game_groups (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_sessions_public_schedule
ON sessions (group_id, scheduled_at)
WHERE is_public = true AND status <> 'Cancelled';
@@ -73,7 +73,7 @@
</button>
</form>
<div class="nav-version">v3.2.0</div>
<div class="nav-version">v3.3.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -0,0 +1,21 @@
@inherits LayoutComponentBase
<div class="public-shell">
<header class="public-topbar">
<a class="public-brand" href="/">
<img src="/logo.png" alt="GM-Relay" />
<span>GM-Relay</span>
</a>
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
</header>
<main class="public-content">
@Body
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
Произошла непредвиденная ошибка.
<a href="." class="reload">Перезагрузить</a>
<span class="dismiss">×</span>
</div>
@@ -72,6 +72,58 @@
</div>
}
@if (publicSettings is not null)
{
<div class="glass-card animate-slide-up public-settings-panel" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Публичная страница клуба</h3>
<p>@publicSettings.PublicSessionCount опубликованных игр без состава игроков и приватных ссылок</p>
</div>
<span class="status-badge @(publicSettings.PublicScheduleEnabled ? "status-success" : "status-neutral")">
@(publicSettings.PublicScheduleEnabled ? "Включена" : "Выключена")
</span>
</div>
<EditForm Model="@publicSettingsModel" OnValidSubmit="SavePublicSettings">
<div class="batch-bulk-fields">
<div class="gm-form-group public-toggle-field">
<label class="gm-checkbox-label">
<InputCheckbox @bind-Value="publicSettingsModel.PublicScheduleEnabled" />
<span>Включить публичное расписание</span>
</label>
<div class="gm-form-hint">Если выключено, публичная страница и ссылки на сессии недоступны.</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Короткий адрес</label>
<InputText @bind-Value="publicSettingsModel.PublicSlug" class="gm-form-control" />
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-club`.</div>
</div>
</div>
<div class="public-settings-actions">
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingPublicSettings">
@(savingPublicSettings ? "Сохраняем..." : "Сохранить публикацию")
</button>
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
{
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
Открыть публичную страницу
</a>
}
</div>
</EditForm>
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
{
<div class="public-link-row">
<span>Ссылка клуба</span>
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
</div>
}
</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
@@ -201,6 +253,17 @@
</button>
</EditForm>
<div class="batch-publish-row">
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
@FormatBatchPublication(batch)
</span>
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
@(IsBatchPublishBusy(batch)
? "Обновляем..."
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
</button>
</div>
<div class="batch-clone-row">
<select @bind="batch.CloneInterval" class="gm-form-control">
<option value="week">Следующая неделя</option>
@@ -249,6 +312,16 @@
</td>
<td>
<div class="session-table-actions">
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
@(publishingSessionId == session.Id
? "Обновляем..."
: session.IsPublic ? "Скрыть" : "Опубликовать")
</button>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
}
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
✏️ Изменить
</a>
@@ -337,6 +410,15 @@
</div>
</div>
<div class="session-card-actions">
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
@(publishingSessionId == session.Id
? "Обновляем..."
: session.IsPublic ? "Скрыть" : "Опубликовать")
</button>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
}
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
✏️ Изменить
</a>
@@ -398,18 +480,23 @@
private List<WebSession>? sessions;
private List<WebCampaignTemplate>? campaignTemplates;
private WebGroupManagement? groupManagement;
private WebPublicGroupSettings? publicSettings;
private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private Guid? promotingSessionId;
private Guid? processingBatchId;
private Guid? processingTemplateId;
private Guid? publishingBatchId;
private Guid? publishingSessionId;
private string? removingCoGmId;
private bool isAddingCoGm;
private bool savingPublicSettings;
private string? currentPlatform;
private string? externalUserId;
private string? errorMessage;
private string? successMessage;
private CoGmEditModel coGmModel = new();
private PublicSettingsEditModel publicSettingsModel = new();
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
private HashSet<Guid> expandedSessions = new();
private Guid? kickingParticipantId;
@@ -444,6 +531,13 @@
return;
}
publicSettings = await SessionService.GetPublicGroupSettingsForCurrentUserAsync(GroupId);
if (publicSettings is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
if (campaignTemplates is null)
{
@@ -453,6 +547,92 @@
RebuildBatchModels();
RebuildCampaignTemplateModels();
RebuildPublicSettingsModel();
}
private async Task SavePublicSettings()
{
errorMessage = null;
successMessage = null;
savingPublicSettings = true;
try
{
await SessionService.UpdatePublicGroupSettingsForCurrentUserAsync(
GroupId,
publicSettingsModel.PublicSlug,
publicSettingsModel.PublicScheduleEnabled);
successMessage = "Настройки публичной страницы обновлены.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичную страницу: " + ex.Message;
}
finally
{
savingPublicSettings = false;
}
}
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
{
errorMessage = null;
successMessage = null;
publishingBatchId = batch.BatchId;
try
{
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
successMessage = isPublic
? "Batch опубликован в публичном расписании."
: "Batch скрыт из публичного расписания.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичность batch: " + ex.Message;
}
finally
{
publishingBatchId = null;
}
}
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
{
errorMessage = null;
successMessage = null;
publishingSessionId = sessionId;
try
{
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
successMessage = isPublic
? "Сессия опубликована в публичном расписании."
: "Сессия скрыта из публичного расписания.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичность сессии: " + ex.Message;
}
finally
{
publishingSessionId = null;
}
}
private async Task AddCoGm()
@@ -806,7 +986,9 @@
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
IntervalDays = InferIntervalDays(orderedSessions),
SessionCount = orderedSessions.Count
SessionCount = orderedSessions.Count,
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
AllSessionsPublic = orderedSessions.All(session => session.IsPublic)
};
})
.OrderBy(batch => batch.FirstScheduledAtLocal)
@@ -834,6 +1016,20 @@
.ToList() ?? [];
}
private void RebuildPublicSettingsModel()
{
if (publicSettings is null)
{
return;
}
publicSettingsModel = new PublicSettingsEditModel
{
PublicScheduleEnabled = publicSettings.PublicScheduleEnabled,
PublicSlug = publicSettings.PublicSlug ?? ""
};
}
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
{
batch.Title = batch.Title.Trim();
@@ -843,8 +1039,31 @@
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private bool IsBatchPublishBusy(BatchBulkEditModel batch) => publishingBatchId == batch.BatchId;
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
private string? PublicClubUrl =>
string.IsNullOrWhiteSpace(publicSettings?.PublicSlug)
? null
: Navigation.ToAbsoluteUri($"/club/{publicSettings.PublicSlug}").ToString();
private string PublicSessionUrl(Guid sessionId) =>
Navigation.ToAbsoluteUri($"/s/{sessionId}").ToString();
private static string FormatPublicationStatus(WebSession session) =>
session.IsPublic ? "Опубликована" : "Скрыта";
private static string GetPublicationStatusClass(WebSession session) =>
session.IsPublic ? "status-success" : "status-neutral";
private static string FormatBatchPublication(BatchBulkEditModel batch) =>
batch.PublicSessionCount == 0
? "Все игры скрыты"
: batch.PublicSessionCount == batch.SessionCount
? "Все игры опубликованы"
: $"{batch.PublicSessionCount}/{batch.SessionCount} опубликовано";
private string CoGmPlatform =>
string.IsNullOrWhiteSpace(groupManagement?.Group.Platform)
? "Telegram"
@@ -966,6 +1185,8 @@
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
public int IntervalDays { get; set; } = 7;
public int SessionCount { get; init; }
public int PublicSessionCount { get; init; }
public bool AllSessionsPublic { get; init; }
public string CloneInterval { get; set; } = "week";
}
@@ -988,4 +1209,10 @@
public string DisplayName { get; set; } = "";
public string? ExternalUsername { get; set; }
}
private sealed class PublicSettingsEditModel
{
public bool PublicScheduleEnabled { get; set; }
public string? PublicSlug { get; set; }
}
}
@@ -0,0 +1,121 @@
@page "/club/{Slug}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation
<PageTitle>@PageTitleText</PageTitle>
@if (loaded && club is null)
{
<HeadContent>
<meta name="robots" content="noindex, nofollow" />
</HeadContent>
<section class="public-hero public-hero-compact">
<span class="status-badge status-neutral">Недоступно</span>
<h1>Публичная страница не найдена</h1>
<p>Расписание клуба выключено или адрес больше не используется.</p>
</section>
}
else if (!loaded)
{
<section class="public-hero public-hero-compact">
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
<div class="skeleton skeleton-text" style="width: 75%;"></div>
</section>
}
else if (club is not null)
{
<HeadContent>
<meta name="description" content="@($"Публичное расписание клуба {club.Name} в GM-Relay.")" />
</HeadContent>
<section class="public-hero">
<span class="status-badge status-success">Публичное расписание</span>
<h1>@club.Name</h1>
<p>Открытые игры клуба без состава игроков, личных данных и приватных ссылок.</p>
<div class="public-share-row">
<span>Ссылка клуба</span>
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
</div>
</section>
@if (club.Sessions.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Опубликованных игр пока нет</h2>
<p>Когда owner или co-GM откроет сессии для публичного расписания, они появятся здесь.</p>
</div>
}
else
{
<div class="public-session-list">
@foreach (var session in club.Sessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
</article>
}
</div>
}
}
@code {
[Parameter] public string? Slug { get; set; }
private WebPublicClub? club;
private bool loaded;
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
private string PublicClubUrl =>
club is null
? Navigation.ToAbsoluteUri($"/club/{Slug}").ToString()
: Navigation.ToAbsoluteUri($"/club/{club.Slug}").ToString();
protected override async Task OnParametersSetAsync()
{
loaded = false;
club = string.IsNullOrWhiteSpace(Slug)
? null
: await SessionStore.GetPublicClubBySlugAsync(Slug.Trim());
loaded = true;
}
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
private static string FormatSeats(WebPublicSession session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
return session.WaitlistedPlayerCount > 0
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private static string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private static string TranslateStatus(string status) => status switch
{
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждем подтверждения",
SessionStatus.Confirmed => "Подтверждено",
_ => status
};
}
@@ -0,0 +1,108 @@
@page "/s/{SessionId:guid}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation
<PageTitle>@PageTitleText</PageTitle>
@if (loaded && session is null)
{
<HeadContent>
<meta name="robots" content="noindex, nofollow" />
</HeadContent>
<section class="public-hero public-hero-compact">
<span class="status-badge status-neutral">Недоступно</span>
<h1>Сессия не опубликована</h1>
<p>Эта игра скрыта, отменена, уже прошла или клуб выключил публичное расписание.</p>
</section>
}
else if (!loaded)
{
<section class="public-hero public-hero-compact">
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
<div class="skeleton skeleton-text" style="width: 75%;"></div>
</section>
}
else if (session is not null)
{
<HeadContent>
<meta name="description" content="@($"Публичная сессия {session.Title} клуба {session.GroupName} в GM-Relay.")" />
</HeadContent>
<section class="public-hero public-hero-compact">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h1>@session.Title</h1>
<p>@session.GroupName</p>
</section>
<article class="glass-card public-session-detail">
<div class="public-detail-grid">
<div>
<span>Время</span>
<strong>@session.ScheduledAt.FormatMoscow()</strong>
</div>
<div>
<span>Места</span>
<strong>@FormatSeats(session)</strong>
</div>
<div>
<span>Статус</span>
<strong>@TranslateStatus(session.Status)</strong>
</div>
</div>
<div class="public-settings-actions">
@if (!string.IsNullOrWhiteSpace(session.GroupSlug))
{
<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>
</div>
</article>
}
@code {
[Parameter] public Guid SessionId { get; set; }
private WebPublicSession? session;
private bool loaded;
private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay";
private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString();
protected override async Task OnParametersSetAsync()
{
loaded = false;
session = await SessionStore.GetPublicSessionAsync(SessionId);
loaded = true;
}
private static string FormatSeats(WebPublicSession session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
return session.WaitlistedPlayerCount > 0
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private static string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private static string TranslateStatus(string status) => status switch
{
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждем подтверждения",
SessionStatus.Confirmed => "Подтверждено",
_ => status
};
}
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Text.RegularExpressions;
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
@@ -54,6 +55,71 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
return await sessionStore.GetUpcomingSessionsAsync(groupId);
}
public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsForCurrentUserAsync(Guid groupId)
{
var identity = GetCurrentIdentity();
if (identity is null)
return null;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return null;
return await sessionStore.GetPublicGroupSettingsAsync(groupId);
}
public async Task UpdatePublicGroupSettingsForCurrentUserAsync(
Guid groupId,
string? publicSlug,
bool publicScheduleEnabled)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
{
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
}
var normalizedSlug = NormalizePublicSlug(publicSlug);
if (publicScheduleEnabled && normalizedSlug is null)
{
throw new InvalidOperationException("Для публичной страницы нужен короткий адрес.");
}
await sessionStore.UpdatePublicGroupSettingsAsync(groupId, normalizedSlug, publicScheduleEnabled);
}
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
}
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
}
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var batch = await GetBatchForCurrentUserAsync(batchId);
if (batch is null)
{
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
}
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
}
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
{
var identity = GetCurrentIdentity();
@@ -390,4 +456,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
JoinLink = joinLink
};
}
private static string? NormalizePublicSlug(string? publicSlug)
{
if (string.IsNullOrWhiteSpace(publicSlug))
{
return null;
}
var slug = Regex.Replace(publicSlug.Trim().ToLowerInvariant(), @"[\s_]+", "-").Trim('-');
if (slug.Length is < 3 or > 80 || !Regex.IsMatch(slug, "^[a-z0-9]+(?:-[a-z0-9]+)*$"))
{
throw new InvalidOperationException("Короткий адрес может содержать только латинские буквы, цифры и дефисы.");
}
return slug;
}
}
+31
View File
@@ -24,10 +24,41 @@ public sealed record SessionAuditLogEntry(
string? NewValue,
DateTime ChangedAt);
public sealed record WebPublicGroupSettings(
Guid GroupId,
string GroupName,
string? PublicSlug,
bool PublicScheduleEnabled,
int PublicSessionCount);
public sealed record WebPublicSession(
Guid Id,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Title,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount);
public sealed record WebPublicClub(
Guid GroupId,
string Name,
string Slug,
IReadOnlyList<WebPublicSession> Sessions);
public interface ISessionStore
{
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
+247 -6
View File
@@ -67,7 +67,8 @@ public sealed record WebSession(
int ActivePlayerCount,
int WaitlistedPlayerCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
int? ThreadId = null);
int? ThreadId = null,
bool IsPublic = false);
public sealed record WebParticipant(
Guid Id,
@@ -108,6 +109,7 @@ internal sealed record WebBatchSessionRow(
bool TopicCreatedByBot = false);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
internal sealed record WebPublicGroupRow(Guid GroupId, string Name, string Slug);
public sealed class SessionService(
NpgsqlDataSource dataSource,
@@ -182,6 +184,184 @@ public sealed class SessionService(
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebPublicGroupSettings>(
"""
SELECT g.id AS GroupId,
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
g.public_slug AS PublicSlug,
g.public_schedule_enabled AS PublicScheduleEnabled,
COALESCE(public_counts.count, 0)::int AS PublicSessionCount
FROM game_groups g
LEFT JOIN LATERAL (
SELECT s.title
FROM sessions s
WHERE s.group_id = g.id
ORDER BY s.scheduled_at DESC
LIMIT 1
) latest_session ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM sessions s
WHERE s.group_id = g.id
AND s.is_public = true
) public_counts ON true
WHERE g.id = @GroupId
""",
new { GroupId = groupId });
}
public async Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled)
{
await using var conn = await dataSource.OpenConnectionAsync();
try
{
await conn.ExecuteAsync(
"""
UPDATE game_groups
SET public_slug = @PublicSlug,
public_schedule_enabled = @PublicScheduleEnabled,
public_schedule_updated_at = now()
WHERE id = @GroupId
""",
new
{
GroupId = groupId,
PublicSlug = string.IsNullOrWhiteSpace(publicSlug) ? null : publicSlug,
PublicScheduleEnabled = publicScheduleEnabled
});
}
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
{
throw new InvalidOperationException("Public slug is already in use.", ex);
}
}
public async Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
{
await using var conn = await dataSource.OpenConnectionAsync();
var updatedRows = await conn.ExecuteAsync(
"""
UPDATE sessions
SET is_public = @IsPublic,
updated_at = now()
WHERE id = @SessionId
AND group_id = @GroupId
""",
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
if (updatedRows == 0)
{
throw new SessionAccessDeniedException(sessionId, "0");
}
}
public async Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
{
await using var conn = await dataSource.OpenConnectionAsync();
var updatedRows = await conn.ExecuteAsync(
"""
UPDATE sessions
SET is_public = @IsPublic,
updated_at = now()
WHERE batch_id = @BatchId
AND group_id = @GroupId
""",
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
if (updatedRows == 0)
{
throw new SessionAccessDeniedException(batchId, "0");
}
}
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug)
{
await using var conn = await dataSource.OpenConnectionAsync();
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
"""
SELECT g.id AS GroupId,
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
g.public_slug AS Slug
FROM game_groups g
LEFT JOIN LATERAL (
SELECT s.title
FROM sessions s
WHERE s.group_id = g.id
ORDER BY s.scheduled_at DESC
LIMIT 1
) latest_session ON true
WHERE g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND lower(g.public_slug) = lower(@Slug)
""",
new { Slug = slug });
if (group is null)
{
return null;
}
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions);
}
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
"""
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.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
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
});
}
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
@@ -379,7 +559,8 @@ public sealed class SessionService(
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
s.thread_id AS ThreadId,
s.is_public AS IsPublic
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -417,7 +598,8 @@ public sealed class SessionService(
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
s.thread_id AS ThreadId,
s.is_public AS IsPublic
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -476,7 +658,8 @@ public sealed class SessionService(
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
s.thread_id AS ThreadId,
s.is_public AS IsPublic
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id AND s.group_id = @GroupId",
@@ -562,7 +745,8 @@ public sealed class SessionService(
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
s.thread_id AS ThreadId,
s.is_public AS IsPublic
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -688,7 +872,8 @@ public sealed class SessionService(
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
s.thread_id AS ThreadId,
s.is_public AS IsPublic
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -1396,6 +1581,62 @@ public sealed class SessionService(
}
}
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
NpgsqlConnection conn,
Guid groupId)
{
return (await conn.QueryAsync<WebPublicSession>(
"""
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.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
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.group_id = @GroupId
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
ORDER BY s.scheduled_at
""",
new
{
GroupId = groupId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
})).ToList();
}
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
Npgsql.NpgsqlConnection conn,
Guid batchId,
+201
View File
@@ -785,6 +785,62 @@ select option {
white-space: nowrap;
}
.batch-publish-row,
.public-settings-actions,
.public-link-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.batch-publish-row {
justify-content: space-between;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.public-settings-panel {
position: relative;
z-index: 2;
}
.public-toggle-field {
display: flex;
flex-direction: column;
justify-content: center;
}
.gm-checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.625rem;
color: var(--text-primary);
font-weight: 600;
}
.gm-checkbox-label input {
width: 1rem;
height: 1rem;
accent-color: var(--accent-primary);
}
.public-settings-actions {
margin-top: 0.25rem;
}
.public-link-row {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 0.875rem;
}
.public-link-row a {
overflow-wrap: anywhere;
}
/* === Campaign templates === */
.campaign-template-panel {
margin-bottom: 1.5rem;
@@ -1620,6 +1676,151 @@ body.telegram-mini-app .session-card-mobile {
}
}
/* === Public pages === */
.public-shell {
min-height: 100vh;
position: relative;
z-index: 2;
}
.public-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 2rem;
border-bottom: 1px solid var(--border-color);
background: rgba(5, 8, 16, 0.82);
backdrop-filter: blur(16px);
}
.public-brand {
display: inline-flex;
align-items: center;
gap: 0.75rem;
color: var(--text-primary);
font-weight: 700;
}
.public-brand img {
width: 2rem;
height: 2rem;
}
.public-content {
width: min(960px, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 3rem;
}
.public-hero {
margin-bottom: 1.5rem;
padding: 2rem 0 1rem;
}
.public-hero-compact {
max-width: 720px;
}
.public-hero h1 {
font-size: 2rem;
margin: 0.75rem 0 0.5rem;
}
.public-hero p {
max-width: 640px;
margin: 0;
color: var(--text-secondary);
}
.public-session-list {
display: grid;
gap: 0.875rem;
}
.public-session-card,
.public-session-detail {
position: relative;
z-index: 2;
}
.public-session-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
align-items: center;
padding: 1rem;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
}
.public-session-main h2 {
font-size: 1.125rem;
margin: 0.625rem 0 0.375rem;
overflow-wrap: anywhere;
}
.public-session-meta,
.public-detail-grid {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.public-detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 1.25rem;
}
.public-detail-grid div {
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-surface);
}
.public-detail-grid span {
display: block;
margin-bottom: 0.375rem;
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
}
.public-detail-grid strong {
color: var(--text-primary);
}
.public-empty-state h2 {
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
.public-empty-state p {
margin: 0;
color: var(--text-secondary);
}
@media (max-width: 768px) {
.public-topbar {
padding: 0.875rem 1rem;
}
.public-session-card,
.public-detail-grid {
grid-template-columns: 1fr;
}
.public-session-card .btn-gm {
justify-content: center;
width: 100%;
}
}
/* === Discord Login Button === */
.login-btn-discord {
display: flex;