Extract workspace host and collapse Home partials
This commit is contained in:
3
FAQ.md
3
FAQ.md
@@ -70,7 +70,8 @@ There is no separate activate button in Play. The selected character in the char
|
||||
|
||||
## Where did the Home page logic move after the refactor?
|
||||
|
||||
`Home.razor` now focuses on composition and delegates behavior to concern-based code-behind partials (`Home.Auth.cs`, `Home.Campaign.cs`, `Home.Character.cs`, `Home.Skill.cs`, etc.) plus dedicated UI controls under `Components/Pages/HomeControls/`.
|
||||
`Home.razor` + `Home.razor.cs` now act as a small gateway that switches between loading, anonymous auth, and workspace views.
|
||||
Authenticated application state and behavior were moved into `Components/Pages/Workspace.razor`, while reusable concern UI remains under `Components/Pages/HomeControls/`.
|
||||
|
||||
## Why is auth form state kept in `AuthSection` instead of `Home`?
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ Tracking against `UX.md` tasks and decisions.
|
||||
- Branch: `feature/blazor-frontend-rebuild-ux`
|
||||
- Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`)
|
||||
- Legacy TypeScript frontend/runtime artifacts: removed
|
||||
- Home page orchestration split by concern (`Home.*.cs` partials + `HomeControls/*`) to reduce merge churn and keep auth/campaign/character/skill flows isolated.
|
||||
- Concern controls now own their local form state and mutation workflows; `Home` handles shared cross-control state refresh.
|
||||
- Home was simplified to a minimal gateway (`Loading` / `Anonymous` / `Workspace`) in a single `Home.razor.cs` class.
|
||||
- The authenticated workspace shell/state/behavior was moved to `Components/Pages/Workspace.razor`.
|
||||
- Concern controls now own their local form state and mutation workflows; the workspace host handles shared cross-control state refresh.
|
||||
- Skill create/edit flow is now owned by `CharacterPanel` (where characters and their skills are presented together).
|
||||
|
||||
## UX Checklist
|
||||
|
||||
@@ -26,13 +26,14 @@ Backend:
|
||||
Frontend:
|
||||
|
||||
- `RpgRoller/Components/`: Blazor root app, routes, layout and page components
|
||||
- `RpgRoller/Components/Pages/Home.razor`: top-level page composition using concern-focused child controls
|
||||
- `RpgRoller/Components/Pages/Home.*.cs`: concern-based partial class split (`State`, `Lifecycle`, `Auth`, `Campaign`, `Character`, `Skill`, `Realtime`, `Api`, `Presentation`, `Validation`)
|
||||
- `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch)
|
||||
- `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration
|
||||
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic
|
||||
- `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models
|
||||
- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor`
|
||||
- Form ownership model: controls own transient form/error state and execute their concern-specific API mutations directly
|
||||
- Skill create/edit workflow ownership: `CharacterPanel` (characters own skills in UI and behavior)
|
||||
- `RpgRoller/Components/RpgRollerApiClient.cs`: shared browser API client used by `Home` and leaf controls
|
||||
- `RpgRoller/Components/RpgRollerApiClient.cs`: shared browser API client used by `Home`, `Workspace`, and leaf controls
|
||||
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor
|
||||
- `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens
|
||||
|
||||
|
||||
@@ -1,20 +1 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Components;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = default!;
|
||||
|
||||
private Task<T> RequestAsync<T>(string method, string path, object? payload = null)
|
||||
{
|
||||
return ApiClient.RequestAsync<T>(method, path, payload);
|
||||
}
|
||||
|
||||
private Task RequestWithoutPayloadAsync(string method, string path)
|
||||
{
|
||||
return ApiClient.RequestWithoutPayloadAsync(method, path);
|
||||
}
|
||||
}
|
||||
// Moved into Home.razor.cs and Workspace.razor during cleanup.
|
||||
|
||||
@@ -1,44 +1 @@
|
||||
using RpgRoller.Components;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
private async Task OnLoggedInAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ReloadAuthenticatedSessionAsync(null);
|
||||
SetStatus("Logged in.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LogoutAsync()
|
||||
{
|
||||
if (IsMutating)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
await RequestWithoutPayloadAsync("POST", "/api/auth/logout");
|
||||
}
|
||||
catch (ApiRequestException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
|
||||
ClearAuthenticatedState();
|
||||
await StopStateEventsAsync();
|
||||
SetStatus("Logged out.", false);
|
||||
}
|
||||
}
|
||||
// Moved into Home.razor.cs and Workspace.razor during cleanup.
|
||||
|
||||
@@ -1,60 +1 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
private async Task SwitchScreenAsync(string screen)
|
||||
{
|
||||
CurrentScreen = string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase) ? "management" : "play";
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen);
|
||||
}
|
||||
|
||||
private Task SwitchToPlayAsync()
|
||||
{
|
||||
return SwitchScreenAsync("play");
|
||||
}
|
||||
|
||||
private Task SwitchToManagementAsync()
|
||||
{
|
||||
return SwitchScreenAsync("management");
|
||||
}
|
||||
|
||||
private async Task SetMobilePanelAsync(string panel)
|
||||
{
|
||||
MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character";
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel);
|
||||
}
|
||||
|
||||
private Task SetMobilePanelCharacterAsync()
|
||||
{
|
||||
return SetMobilePanelAsync("character");
|
||||
}
|
||||
|
||||
private Task SetMobilePanelLogAsync()
|
||||
{
|
||||
return SetMobilePanelAsync("log");
|
||||
}
|
||||
|
||||
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
|
||||
{
|
||||
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedCampaignId = campaignId;
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
}
|
||||
|
||||
private async Task OnCampaignCreatedAsync(Guid campaignId)
|
||||
{
|
||||
await ReloadCampaignsAsync(campaignId);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
SetStatus("Campaign created.", false);
|
||||
}
|
||||
}
|
||||
// Moved into Home.razor.cs and Workspace.razor during cleanup.
|
||||
|
||||
@@ -1,96 +1 @@
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
private void OpenCreateCharacterModal()
|
||||
{
|
||||
CreateCharacterInitialModel = new CharacterFormModel
|
||||
{
|
||||
Name = string.Empty,
|
||||
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty
|
||||
};
|
||||
CreateCharacterFormVersion++;
|
||||
ShowCreateCharacterModal = true;
|
||||
}
|
||||
|
||||
private void OpenEditCharacterModal(CharacterSummary character)
|
||||
{
|
||||
EditingCharacterId = character.Id;
|
||||
|
||||
EditCharacterInitialModel = new CharacterFormModel
|
||||
{
|
||||
Name = character.Name,
|
||||
CampaignId = character.CampaignId.ToString()
|
||||
};
|
||||
EditCharacterFormVersion++;
|
||||
ShowEditCharacterModal = true;
|
||||
}
|
||||
|
||||
private void CloseCharacterModals()
|
||||
{
|
||||
ShowCreateCharacterModal = false;
|
||||
ShowEditCharacterModal = false;
|
||||
EditingCharacterId = null;
|
||||
}
|
||||
|
||||
private async Task OnCharacterCreatedAsync(Guid campaignId)
|
||||
{
|
||||
CloseCharacterModals();
|
||||
await ReloadCampaignsAsync(campaignId);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
SetStatus("Character created.", false);
|
||||
}
|
||||
|
||||
private async Task OnCharacterUpdatedAsync(Guid campaignId)
|
||||
{
|
||||
CloseCharacterModals();
|
||||
await ReloadCampaignsAsync(campaignId);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
SetStatus("Character updated.", false);
|
||||
}
|
||||
|
||||
private async Task SelectCharacterAsync(Guid characterId)
|
||||
{
|
||||
SelectedCharacterId = characterId;
|
||||
SyncSelectedSkill();
|
||||
await EnsureSelectedCharacterActiveAsync();
|
||||
}
|
||||
|
||||
private bool CanEditCharacter(CharacterSummary character)
|
||||
{
|
||||
return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm);
|
||||
}
|
||||
|
||||
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
|
||||
{
|
||||
return user is not null && character.OwnerUserId == user.Id;
|
||||
}
|
||||
|
||||
private async Task EnsureSelectedCharacterActiveAsync()
|
||||
{
|
||||
if (!SelectedCharacterId.HasValue || SelectedCampaign is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value);
|
||||
if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate");
|
||||
ActiveCharacterId = character.Id;
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Moved into Home.razor.cs and Workspace.razor during cleanup.
|
||||
|
||||
@@ -1,202 +1 @@
|
||||
using RpgRoller.Contracts;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
HasInteractiveRenderStarted = true;
|
||||
if (!firstRender)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await InitializeAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
var storedScreen = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
|
||||
if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
CurrentScreen = "management";
|
||||
}
|
||||
|
||||
var storedPanel = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
|
||||
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MobilePanel = "log";
|
||||
}
|
||||
|
||||
Guid? preferredCampaignId = null;
|
||||
var storedCampaignId = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
|
||||
if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
|
||||
{
|
||||
preferredCampaignId = parsedCampaignId;
|
||||
}
|
||||
|
||||
await CheckHealthAsync();
|
||||
await LoadRulesetsAsync();
|
||||
await ReloadAuthenticatedSessionAsync(preferredCampaignId);
|
||||
IsInitialized = true;
|
||||
}
|
||||
|
||||
private async Task RetryAfterHealthIssueAsync()
|
||||
{
|
||||
await CheckHealthAsync();
|
||||
if (!HasHealthIssue && User is not null)
|
||||
{
|
||||
await ReloadAuthenticatedSessionAsync(SelectedCampaignId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckHealthAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var health = await RequestAsync<HealthResponse>("GET", "/api/health");
|
||||
if (!string.Equals(health.Status, "ok", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HasHealthIssue = true;
|
||||
HealthIssueMessage = "Health endpoint returned an unhealthy response.";
|
||||
return;
|
||||
}
|
||||
|
||||
HasHealthIssue = false;
|
||||
HealthIssueMessage = string.Empty;
|
||||
}
|
||||
catch (ApiRequestException)
|
||||
{
|
||||
HasHealthIssue = true;
|
||||
HealthIssueMessage = "Unable to reach API. Retry to continue.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadRulesetsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Rulesets = (await RequestAsync<IReadOnlyList<RulesetDefinition>>("GET", "/api/rulesets")).ToList();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId)
|
||||
{
|
||||
var me = await TryGetMeAsync();
|
||||
if (me is null)
|
||||
{
|
||||
ClearAuthenticatedState();
|
||||
await StopStateEventsAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
User = me.User;
|
||||
ActiveCharacterId = me.ActiveCharacterId;
|
||||
|
||||
await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
}
|
||||
|
||||
private async Task<MeResponse?> TryGetMeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await RequestAsync<MeResponse>("GET", "/api/me");
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
|
||||
{
|
||||
var campaigns = await RequestAsync<IReadOnlyList<CampaignSummary>>("GET", "/api/campaigns");
|
||||
Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
if (Campaigns.Count == 0)
|
||||
{
|
||||
SelectedCampaignId = null;
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var campaignIds = Campaigns.Select(c => c.Id).ToHashSet();
|
||||
if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value))
|
||||
{
|
||||
SelectedCampaignId = preferredCampaignId.Value;
|
||||
}
|
||||
else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value))
|
||||
{
|
||||
SelectedCampaignId = Campaigns[0].Id;
|
||||
}
|
||||
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString());
|
||||
}
|
||||
|
||||
private async Task RefreshCampaignScopeAsync()
|
||||
{
|
||||
if (!SelectedCampaignId.HasValue)
|
||||
{
|
||||
SelectedCampaign = null;
|
||||
CampaignLog = [];
|
||||
SelectedCharacterId = null;
|
||||
SelectedSkillId = null;
|
||||
ConnectionState = "offline";
|
||||
return;
|
||||
}
|
||||
|
||||
IsCampaignDataLoading = true;
|
||||
try
|
||||
{
|
||||
var campaignId = SelectedCampaignId.Value;
|
||||
SelectedCampaign = await RequestAsync<CampaignDetails>("GET", $"/api/campaigns/{campaignId}");
|
||||
CampaignLog = (await RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{campaignId}/log")).ToList();
|
||||
SyncSelectedCharacter();
|
||||
SyncSelectedSkill();
|
||||
await EnsureSelectedCharacterActiveAsync();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
ClearAuthenticatedState();
|
||||
await StopStateEventsAsync();
|
||||
SetStatus("Session expired. Please log in again.", true);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsCampaignDataLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ManualRefreshAsync()
|
||||
{
|
||||
if (IsMutating)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
await CheckHealthAsync();
|
||||
await ReloadAuthenticatedSessionAsync(SelectedCampaignId);
|
||||
SetStatus("Campaign data refreshed.", false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Moved into Home.razor.cs and Workspace.razor during cleanup.
|
||||
|
||||
@@ -1,173 +1 @@
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
private void SyncSelectedCharacter()
|
||||
{
|
||||
if (SelectedCampaign is null || SelectedCampaign.Characters.Count == 0)
|
||||
{
|
||||
SelectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet();
|
||||
if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ActiveCharacterId.HasValue && candidateIds.Contains(ActiveCharacterId.Value))
|
||||
{
|
||||
SelectedCharacterId = ActiveCharacterId;
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedCharacterId = SelectedCampaign.Characters[0].Id;
|
||||
}
|
||||
|
||||
private void SyncSelectedSkill()
|
||||
{
|
||||
var skills = SelectedCharacterSkills;
|
||||
if (skills.Count == 0)
|
||||
{
|
||||
SelectedSkillId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedSkillId.HasValue && skills.Any(skill => skill.Id == SelectedSkillId.Value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedSkillId = skills[0].Id;
|
||||
}
|
||||
|
||||
private string OwnerLabel(Guid ownerUserId)
|
||||
{
|
||||
if (User is not null && ownerUserId == User.Id)
|
||||
{
|
||||
return "You";
|
||||
}
|
||||
|
||||
if (SelectedCampaign is not null && ownerUserId == SelectedCampaign.Gm.Id)
|
||||
{
|
||||
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
|
||||
}
|
||||
|
||||
return ownerUserId.ToString("N")[..8];
|
||||
}
|
||||
|
||||
private string CharacterLabel(Guid characterId)
|
||||
{
|
||||
return SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == characterId)?.Name ?? "Hidden character";
|
||||
}
|
||||
|
||||
private string SkillLabel(Guid skillId)
|
||||
{
|
||||
return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill";
|
||||
}
|
||||
|
||||
private string SkillDefinitionLabel(SkillSummary skill)
|
||||
{
|
||||
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return skill.DiceRollDefinition;
|
||||
}
|
||||
|
||||
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
|
||||
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}";
|
||||
}
|
||||
|
||||
private string RollerLabel(CampaignLogEntry entry)
|
||||
{
|
||||
if (User is not null && entry.RollerUserId == User.Id)
|
||||
{
|
||||
return "You";
|
||||
}
|
||||
|
||||
if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id)
|
||||
{
|
||||
return "GM";
|
||||
}
|
||||
|
||||
return "Participant";
|
||||
}
|
||||
|
||||
private string VisibilityLabel(CampaignLogEntry entry)
|
||||
{
|
||||
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Public";
|
||||
}
|
||||
|
||||
if (User is not null && entry.RollerUserId == User.Id)
|
||||
{
|
||||
return "Private (you)";
|
||||
}
|
||||
|
||||
return IsCurrentUserGm ? "Private (GM view)" : "Private";
|
||||
}
|
||||
|
||||
private string VisibilityBadgeCssClass(CampaignLogEntry entry)
|
||||
{
|
||||
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public";
|
||||
}
|
||||
|
||||
if (User is not null && entry.RollerUserId == User.Id)
|
||||
{
|
||||
return "private-self";
|
||||
}
|
||||
|
||||
return IsCurrentUserGm ? "private-gm" : "private-generic";
|
||||
}
|
||||
|
||||
private string LogEntryCssClass(CampaignLogEntry entry)
|
||||
{
|
||||
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public";
|
||||
}
|
||||
|
||||
if (User is not null && entry.RollerUserId == User.Id)
|
||||
{
|
||||
return "private-self";
|
||||
}
|
||||
|
||||
return IsCurrentUserGm ? "private-gm" : "private-generic";
|
||||
}
|
||||
|
||||
private void ClearAuthenticatedState()
|
||||
{
|
||||
User = null;
|
||||
ActiveCharacterId = null;
|
||||
SelectedCampaignId = null;
|
||||
SelectedCampaign = null;
|
||||
Campaigns = [];
|
||||
CampaignLog = [];
|
||||
SelectedCharacterId = null;
|
||||
SelectedSkillId = null;
|
||||
LastRoll = null;
|
||||
ShowCreateCharacterModal = false;
|
||||
ShowEditCharacterModal = false;
|
||||
CreateCharacterInitialModel = new();
|
||||
EditCharacterInitialModel = new();
|
||||
CreateCharacterFormVersion = 0;
|
||||
EditCharacterFormVersion = 0;
|
||||
}
|
||||
|
||||
private void SetStatus(string message, bool isError)
|
||||
{
|
||||
StatusMessage = message;
|
||||
StatusIsError = isError;
|
||||
Announce(message);
|
||||
}
|
||||
|
||||
private void Announce(string message)
|
||||
{
|
||||
LiveAnnouncement = message;
|
||||
}
|
||||
}
|
||||
// Moved into Home.razor.cs and Workspace.razor during cleanup.
|
||||
|
||||
@@ -1,93 +1 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
[JSInvokable]
|
||||
public async Task OnStateEventReceived(long _)
|
||||
{
|
||||
if (StateRefreshInProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StateRefreshInProgress = true;
|
||||
try
|
||||
{
|
||||
await RefreshCampaignScopeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
StateRefreshInProgress = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task OnConnectionStateChanged(string state)
|
||||
{
|
||||
ConnectionState = state switch
|
||||
{
|
||||
"connected" => "connected",
|
||||
"reconnecting" => "reconnecting",
|
||||
_ => "offline"
|
||||
};
|
||||
|
||||
if (ConnectionState == "reconnecting")
|
||||
{
|
||||
Announce("Reconnecting to live updates.");
|
||||
}
|
||||
|
||||
if (ConnectionState == "offline")
|
||||
{
|
||||
Announce("Live updates offline. Use manual refresh.");
|
||||
}
|
||||
|
||||
return InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task SyncStateEventsAsync()
|
||||
{
|
||||
if (User is null || !SelectedCampaignId.HasValue)
|
||||
{
|
||||
await StopStateEventsAsync();
|
||||
ConnectionState = "offline";
|
||||
return;
|
||||
}
|
||||
|
||||
DotNetRef ??= DotNetObjectReference.Create(this);
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", SelectedCampaignId.Value.ToString(), DotNetRef);
|
||||
ConnectionState = "reconnecting";
|
||||
}
|
||||
|
||||
private async Task StopStateEventsAsync()
|
||||
{
|
||||
if (!HasInteractiveRenderStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents");
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopStateEventsAsync();
|
||||
DotNetRef?.Dispose();
|
||||
}
|
||||
|
||||
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
|
||||
{
|
||||
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
// Moved into Home.razor.cs and Workspace.razor during cleanup.
|
||||
|
||||
@@ -1,76 +1 @@
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
private async Task OnSkillCreatedAsync(Guid _)
|
||||
{
|
||||
await RefreshCampaignScopeAsync();
|
||||
SetStatus("Skill created.", false);
|
||||
}
|
||||
|
||||
private async Task OnSkillUpdatedAsync(Guid skillId)
|
||||
{
|
||||
SelectedSkillId = skillId;
|
||||
await RefreshCampaignScopeAsync();
|
||||
SetStatus("Skill updated.", false);
|
||||
}
|
||||
|
||||
private async Task RollSelectedSkillAsync()
|
||||
{
|
||||
if (SelectedSkill is null)
|
||||
{
|
||||
SetStatus("Select a skill to roll.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
LastRoll = await RequestAsync<RollResult>(
|
||||
"POST",
|
||||
$"/api/skills/{SelectedSkill.Id}/roll",
|
||||
new RollSkillRequest(RollVisibility));
|
||||
|
||||
await RefreshCampaignScopeAsync();
|
||||
SetStatus("Roll recorded.", false);
|
||||
Announce("Roll result updated.");
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnRollVisibilityChanged(string visibility)
|
||||
{
|
||||
RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void SelectSkill(Guid skillId)
|
||||
{
|
||||
SelectedSkillId = skillId;
|
||||
}
|
||||
|
||||
private bool CanEditSkill(SkillSummary skill)
|
||||
{
|
||||
if (SelectedCampaign is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId);
|
||||
return character is not null && CanEditCharacter(character);
|
||||
}
|
||||
|
||||
private bool CanRollSkill(SkillSummary skill)
|
||||
{
|
||||
return CanEditSkill(skill);
|
||||
}
|
||||
}
|
||||
// Moved into Home.razor.cs and Workspace.razor during cleanup.
|
||||
|
||||
@@ -1,107 +1 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class Home
|
||||
{
|
||||
private const string ScreenSessionKey = "screen";
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
private const string MobilePanelSessionKey = "play-panel";
|
||||
|
||||
private UserSummary? User { get; set; }
|
||||
private Guid? ActiveCharacterId { get; set; }
|
||||
private Guid? SelectedCampaignId { get; set; }
|
||||
private CampaignDetails? SelectedCampaign { get; set; }
|
||||
private List<CampaignSummary> Campaigns { get; set; } = [];
|
||||
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
|
||||
private List<RulesetDefinition> Rulesets { get; set; } = [];
|
||||
private Guid? SelectedCharacterId { get; set; }
|
||||
private Guid? SelectedSkillId { get; set; }
|
||||
private RollResult? LastRoll { get; set; }
|
||||
private string RollVisibility { get; set; } = "public";
|
||||
|
||||
private bool IsInitialized { get; set; }
|
||||
private bool IsMutating { get; set; }
|
||||
private bool IsCampaignDataLoading { get; set; }
|
||||
private bool HasHealthIssue { get; set; }
|
||||
private string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
|
||||
private string? StatusMessage { get; set; }
|
||||
private bool StatusIsError { get; set; }
|
||||
private string CurrentScreen { get; set; } = "play";
|
||||
private string MobilePanel { get; set; } = "character";
|
||||
private string ConnectionState { get; set; } = "offline";
|
||||
private string LiveAnnouncement { get; set; } = string.Empty;
|
||||
|
||||
private bool ShowCreateCharacterModal { get; set; }
|
||||
private bool ShowEditCharacterModal { get; set; }
|
||||
private Guid? EditingCharacterId { get; set; }
|
||||
private CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
|
||||
private CharacterFormModel EditCharacterInitialModel { get; set; } = new();
|
||||
private int CreateCharacterFormVersion { get; set; }
|
||||
private int EditCharacterFormVersion { get; set; }
|
||||
private bool StateRefreshInProgress { get; set; }
|
||||
private bool HasInteractiveRenderStarted { get; set; }
|
||||
private DotNetObjectReference<Home>? DotNetRef { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JS { get; set; } = default!;
|
||||
|
||||
private string? SelectedCampaignName => SelectedCampaign?.Name;
|
||||
|
||||
private CharacterSummary? SelectedCharacter =>
|
||||
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
|
||||
|
||||
private SkillSummary? SelectedSkill =>
|
||||
SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
|
||||
|
||||
private string? ActiveCharacterName =>
|
||||
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name;
|
||||
|
||||
private bool IsCurrentUserGm =>
|
||||
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
|
||||
|
||||
private bool IsSelectedCampaignD6 =>
|
||||
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private List<SkillSummary> SelectedCharacterSkills =>
|
||||
SelectedCampaign is null || !SelectedCharacterId.HasValue
|
||||
? []
|
||||
: SelectedCampaign.Skills
|
||||
.Where(skill => skill.CharacterId == SelectedCharacterId.Value)
|
||||
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
private HomeViewMode CurrentView =>
|
||||
!IsInitialized
|
||||
? HomeViewMode.Loading
|
||||
: User is null
|
||||
? HomeViewMode.Anonymous
|
||||
: HomeViewMode.Workspace;
|
||||
|
||||
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
|
||||
private bool IsManagementScreen => !IsPlayScreen;
|
||||
|
||||
private string ConnectionStateLabel => ConnectionState switch
|
||||
{
|
||||
"connected" => "Connected",
|
||||
"reconnecting" => "Reconnecting",
|
||||
_ => "Offline fallback"
|
||||
};
|
||||
|
||||
private string ConnectionStateCssClass => ConnectionState switch
|
||||
{
|
||||
"connected" => "ok",
|
||||
"reconnecting" => "warn",
|
||||
_ => "offline"
|
||||
};
|
||||
|
||||
private string AppCssClass =>
|
||||
User is not null && IsPlayScreen ? "rr-app app-play" : "rr-app";
|
||||
}
|
||||
// Moved into Home.razor.cs and Workspace.razor during cleanup.
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
}
|
||||
// Moved into Home.razor.cs and Workspace.razor during cleanup.
|
||||
|
||||
@@ -1,156 +1,27 @@
|
||||
@page "/"
|
||||
@implements IAsyncDisposable
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
|
||||
<div class="@AppCssClass">
|
||||
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
|
||||
|
||||
@if (HasHealthIssue)
|
||||
{
|
||||
<section class="health-banner" role="alert">
|
||||
<div>
|
||||
<strong>API currently unavailable.</strong>
|
||||
<p>@HealthIssueMessage</p>
|
||||
</div>
|
||||
<button type="button" @onclick="RetryAfterHealthIssueAsync">Retry</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@switch (CurrentView)
|
||||
{
|
||||
case HomeViewMode.Loading:
|
||||
@switch (CurrentView)
|
||||
{
|
||||
case HomeViewMode.Loading:
|
||||
<div class="rr-app">
|
||||
<main class="loading-shell" aria-busy="true" aria-live="polite">
|
||||
<h1>RpgRoller</h1>
|
||||
<p>Connecting...</p>
|
||||
</main>
|
||||
break;
|
||||
</div>
|
||||
break;
|
||||
|
||||
case HomeViewMode.Anonymous:
|
||||
case HomeViewMode.Anonymous:
|
||||
<div class="rr-app">
|
||||
<AuthSection
|
||||
StatusMessage="StatusMessage"
|
||||
StatusIsError="StatusIsError"
|
||||
LoggedIn="OnLoggedInAsync" />
|
||||
break;
|
||||
</div>
|
||||
break;
|
||||
|
||||
case HomeViewMode.Workspace:
|
||||
<div class="workspace-shell">
|
||||
<header class="workspace-header">
|
||||
<div class="header-group brand">
|
||||
<h1>RpgRoller</h1>
|
||||
<p>Tabletop utility cockpit</p>
|
||||
</div>
|
||||
<div class="header-group context">
|
||||
<p><strong>@User!.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
|
||||
<p>Campaign: <strong>@(SelectedCampaignName ?? "No campaign selected")</strong></p>
|
||||
<p>Active: <strong>@(ActiveCharacterName ?? "None selected")</strong></p>
|
||||
</div>
|
||||
<div class="header-group controls">
|
||||
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
|
||||
<div class="switch-group" role="tablist" aria-label="Screen selector">
|
||||
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)" @onclick="SwitchToPlayAsync">Play</button>
|
||||
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)" @onclick="SwitchToManagementAsync">Campaign Management</button>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button>
|
||||
<button type="button" class="ghost" @onclick="LogoutAsync" disabled="@IsMutating">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||
{
|
||||
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
|
||||
}
|
||||
|
||||
@if (IsPlayScreen)
|
||||
{
|
||||
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||
<CharacterPanel
|
||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||
SelectedCampaign="SelectedCampaign"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
SelectedCharacter="SelectedCharacter"
|
||||
IsMutating="IsMutating"
|
||||
SelectedCharacterSkills="SelectedCharacterSkills"
|
||||
SelectedSkillId="SelectedSkillId"
|
||||
SelectedSkill="SelectedSkill"
|
||||
IsD6="IsSelectedCampaignD6"
|
||||
RollVisibility="RollVisibility"
|
||||
RollVisibilityChanged="OnRollVisibilityChanged"
|
||||
LastRoll="LastRoll"
|
||||
OwnerLabel="OwnerLabel"
|
||||
SkillDefinitionLabel="SkillDefinitionLabel"
|
||||
CanEditCharacter="CanEditCharacter"
|
||||
CanEditSkill="CanEditSkill"
|
||||
CanRollSkill="CanRollSkill"
|
||||
CharacterSelected="SelectCharacterAsync"
|
||||
SkillSelected="SelectSkill"
|
||||
EditCharacterRequested="OpenEditCharacterModal"
|
||||
SkillCreated="OnSkillCreatedAsync"
|
||||
SkillUpdated="OnSkillUpdatedAsync"
|
||||
RollRequested="RollSelectedSkillAsync" />
|
||||
|
||||
<CampaignLogPanel
|
||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||
CampaignLog="CampaignLog"
|
||||
RollerLabel="RollerLabel"
|
||||
SkillLabel="SkillLabel"
|
||||
CharacterLabel="CharacterLabel"
|
||||
LogEntryCssClass="LogEntryCssClass"
|
||||
VisibilityLabel="VisibilityLabel"
|
||||
VisibilityBadgeCssClass="VisibilityBadgeCssClass" />
|
||||
</main>
|
||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button>
|
||||
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)" @onclick="SetMobilePanelLogAsync">Log</button>
|
||||
</nav>
|
||||
}
|
||||
|
||||
@if (IsManagementScreen)
|
||||
{
|
||||
<CampaignManagementPanel
|
||||
Campaigns="Campaigns"
|
||||
SelectedCampaignId="SelectedCampaignId"
|
||||
SelectedCampaignName="SelectedCampaignName"
|
||||
SelectedCampaign="SelectedCampaign"
|
||||
Rulesets="Rulesets"
|
||||
IsMutating="IsMutating"
|
||||
OwnerLabel="OwnerLabel"
|
||||
CanEditCharacter="CanEditCharacter"
|
||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
||||
CampaignCreated="OnCampaignCreatedAsync"
|
||||
CreateCharacterRequested="OpenCreateCharacterModal"
|
||||
EditCharacterRequested="OpenEditCharacterModal" />
|
||||
}
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
</div>
|
||||
|
||||
<CharacterFormModal
|
||||
Visible="ShowCreateCharacterModal"
|
||||
Title="Create Character"
|
||||
SubmitLabel="Create Character"
|
||||
NameInputId="character-create-name"
|
||||
CampaignInputId="character-create-campaign"
|
||||
InitialModel="CreateCharacterInitialModel"
|
||||
FormVersion="CreateCharacterFormVersion"
|
||||
EditingCharacterId="null"
|
||||
Campaigns="Campaigns"
|
||||
IsMutating="IsMutating"
|
||||
CharacterSaved="OnCharacterCreatedAsync"
|
||||
CancelRequested="CloseCharacterModals" />
|
||||
|
||||
<CharacterFormModal
|
||||
Visible="ShowEditCharacterModal"
|
||||
Title="Edit Character"
|
||||
SubmitLabel="Save Character"
|
||||
NameInputId="character-edit-name"
|
||||
CampaignInputId="character-edit-campaign"
|
||||
InitialModel="EditCharacterInitialModel"
|
||||
FormVersion="EditCharacterFormVersion"
|
||||
EditingCharacterId="EditingCharacterId"
|
||||
Campaigns="Campaigns"
|
||||
IsMutating="IsMutating"
|
||||
CharacterSaved="OnCharacterUpdatedAsync"
|
||||
CancelRequested="CloseCharacterModals" />
|
||||
case HomeViewMode.Workspace:
|
||||
<Workspace LoggedOut="OnLoggedOutAsync" />
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class Home
|
||||
{
|
||||
private HomeViewMode CurrentView { get; set; } = HomeViewMode.Loading;
|
||||
private string? StatusMessage { get; set; }
|
||||
private bool StatusIsError { get; set; }
|
||||
private bool HasInitialized { get; set; }
|
||||
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = default!;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || HasInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HasInitialized = true;
|
||||
await InitializeAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = await ApiClient.RequestAsync<MeResponse>("GET", "/api/me");
|
||||
CurrentView = HomeViewMode.Workspace;
|
||||
ClearStatus();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
CurrentView = HomeViewMode.Anonymous;
|
||||
ClearStatus();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
CurrentView = HomeViewMode.Anonymous;
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLoggedInAsync()
|
||||
{
|
||||
CurrentView = HomeViewMode.Workspace;
|
||||
ClearStatus();
|
||||
}
|
||||
|
||||
private void OnLoggedOutAsync(string? message)
|
||||
{
|
||||
CurrentView = HomeViewMode.Anonymous;
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
ClearStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
var isError = message.Contains("expired", StringComparison.OrdinalIgnoreCase);
|
||||
SetStatus(message, isError);
|
||||
}
|
||||
|
||||
private void SetStatus(string message, bool isError)
|
||||
{
|
||||
StatusMessage = message;
|
||||
StatusIsError = isError;
|
||||
}
|
||||
|
||||
private void ClearStatus()
|
||||
{
|
||||
StatusMessage = null;
|
||||
StatusIsError = false;
|
||||
}
|
||||
}
|
||||
|
||||
919
RpgRoller/Components/Pages/Workspace.razor
Normal file
919
RpgRoller/Components/Pages/Workspace.razor
Normal file
@@ -0,0 +1,919 @@
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using Microsoft.JSInterop
|
||||
@using RpgRoller.Components
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
@using RpgRoller.Contracts
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
@inject RpgRollerApiClient ApiClient
|
||||
|
||||
<div class="@AppCssClass">
|
||||
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
|
||||
|
||||
@if (HasHealthIssue)
|
||||
{
|
||||
<section class="health-banner" role="alert">
|
||||
<div>
|
||||
<strong>API currently unavailable.</strong>
|
||||
<p>@HealthIssueMessage</p>
|
||||
</div>
|
||||
<button type="button" @onclick="RetryAfterHealthIssueAsync">Retry</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="workspace-shell">
|
||||
<header class="workspace-header">
|
||||
<div class="header-group brand">
|
||||
<h1>RpgRoller</h1>
|
||||
<p>Tabletop utility cockpit</p>
|
||||
</div>
|
||||
<div class="header-group context">
|
||||
<p><strong>@User!.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
|
||||
<p>Campaign: <strong>@(SelectedCampaignName ?? "No campaign selected")</strong></p>
|
||||
<p>Active: <strong>@(ActiveCharacterName ?? "None selected")</strong></p>
|
||||
</div>
|
||||
<div class="header-group controls">
|
||||
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
|
||||
<div class="switch-group" role="tablist" aria-label="Screen selector">
|
||||
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)" @onclick="SwitchToPlayAsync">Play</button>
|
||||
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)" @onclick="SwitchToManagementAsync">Campaign Management</button>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button>
|
||||
<button type="button" class="ghost" @onclick="LogoutAsync" disabled="@IsMutating">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||
{
|
||||
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
|
||||
}
|
||||
|
||||
@if (IsPlayScreen)
|
||||
{
|
||||
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||
<CharacterPanel
|
||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||
SelectedCampaign="SelectedCampaign"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
SelectedCharacter="SelectedCharacter"
|
||||
IsMutating="IsMutating"
|
||||
SelectedCharacterSkills="SelectedCharacterSkills"
|
||||
SelectedSkillId="SelectedSkillId"
|
||||
SelectedSkill="SelectedSkill"
|
||||
IsD6="IsSelectedCampaignD6"
|
||||
RollVisibility="RollVisibility"
|
||||
RollVisibilityChanged="OnRollVisibilityChanged"
|
||||
LastRoll="LastRoll"
|
||||
OwnerLabel="OwnerLabel"
|
||||
SkillDefinitionLabel="SkillDefinitionLabel"
|
||||
CanEditCharacter="CanEditCharacter"
|
||||
CanEditSkill="CanEditSkill"
|
||||
CanRollSkill="CanRollSkill"
|
||||
CharacterSelected="SelectCharacterAsync"
|
||||
SkillSelected="SelectSkill"
|
||||
EditCharacterRequested="OpenEditCharacterModal"
|
||||
SkillCreated="OnSkillCreatedAsync"
|
||||
SkillUpdated="OnSkillUpdatedAsync"
|
||||
RollRequested="RollSelectedSkillAsync" />
|
||||
|
||||
<CampaignLogPanel
|
||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||
CampaignLog="CampaignLog"
|
||||
RollerLabel="RollerLabel"
|
||||
SkillLabel="SkillLabel"
|
||||
CharacterLabel="CharacterLabel"
|
||||
LogEntryCssClass="LogEntryCssClass"
|
||||
VisibilityLabel="VisibilityLabel"
|
||||
VisibilityBadgeCssClass="VisibilityBadgeCssClass" />
|
||||
</main>
|
||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button>
|
||||
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)" @onclick="SetMobilePanelLogAsync">Log</button>
|
||||
</nav>
|
||||
}
|
||||
|
||||
@if (IsManagementScreen)
|
||||
{
|
||||
<CampaignManagementPanel
|
||||
Campaigns="Campaigns"
|
||||
SelectedCampaignId="SelectedCampaignId"
|
||||
SelectedCampaignName="SelectedCampaignName"
|
||||
SelectedCampaign="SelectedCampaign"
|
||||
Rulesets="Rulesets"
|
||||
IsMutating="IsMutating"
|
||||
OwnerLabel="OwnerLabel"
|
||||
CanEditCharacter="CanEditCharacter"
|
||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
||||
CampaignCreated="OnCampaignCreatedAsync"
|
||||
CreateCharacterRequested="OpenCreateCharacterModal"
|
||||
EditCharacterRequested="OpenEditCharacterModal" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CharacterFormModal
|
||||
Visible="ShowCreateCharacterModal"
|
||||
Title="Create Character"
|
||||
SubmitLabel="Create Character"
|
||||
NameInputId="character-create-name"
|
||||
CampaignInputId="character-create-campaign"
|
||||
InitialModel="CreateCharacterInitialModel"
|
||||
FormVersion="CreateCharacterFormVersion"
|
||||
EditingCharacterId="null"
|
||||
Campaigns="Campaigns"
|
||||
IsMutating="IsMutating"
|
||||
CharacterSaved="OnCharacterCreatedAsync"
|
||||
CancelRequested="CloseCharacterModals" />
|
||||
|
||||
<CharacterFormModal
|
||||
Visible="ShowEditCharacterModal"
|
||||
Title="Edit Character"
|
||||
SubmitLabel="Save Character"
|
||||
NameInputId="character-edit-name"
|
||||
CampaignInputId="character-edit-campaign"
|
||||
InitialModel="EditCharacterInitialModel"
|
||||
FormVersion="EditCharacterFormVersion"
|
||||
EditingCharacterId="EditingCharacterId"
|
||||
Campaigns="Campaigns"
|
||||
IsMutating="IsMutating"
|
||||
CharacterSaved="OnCharacterUpdatedAsync"
|
||||
CancelRequested="CloseCharacterModals" />
|
||||
|
||||
@code {
|
||||
private const string ScreenSessionKey = "screen";
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
private const string MobilePanelSessionKey = "play-panel";
|
||||
|
||||
private UserSummary? User { get; set; }
|
||||
private Guid? ActiveCharacterId { get; set; }
|
||||
private Guid? SelectedCampaignId { get; set; }
|
||||
private CampaignDetails? SelectedCampaign { get; set; }
|
||||
private List<CampaignSummary> Campaigns { get; set; } = [];
|
||||
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
|
||||
private List<RulesetDefinition> Rulesets { get; set; } = [];
|
||||
private Guid? SelectedCharacterId { get; set; }
|
||||
private Guid? SelectedSkillId { get; set; }
|
||||
private RollResult? LastRoll { get; set; }
|
||||
private string RollVisibility { get; set; } = "public";
|
||||
|
||||
private bool IsMutating { get; set; }
|
||||
private bool IsCampaignDataLoading { get; set; }
|
||||
private bool HasHealthIssue { get; set; }
|
||||
private string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
|
||||
private string? StatusMessage { get; set; }
|
||||
private bool StatusIsError { get; set; }
|
||||
private string CurrentScreen { get; set; } = "play";
|
||||
private string MobilePanel { get; set; } = "character";
|
||||
private string ConnectionState { get; set; } = "offline";
|
||||
private string LiveAnnouncement { get; set; } = string.Empty;
|
||||
|
||||
private bool ShowCreateCharacterModal { get; set; }
|
||||
private bool ShowEditCharacterModal { get; set; }
|
||||
private Guid? EditingCharacterId { get; set; }
|
||||
private CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
|
||||
private CharacterFormModel EditCharacterInitialModel { get; set; } = new();
|
||||
private int CreateCharacterFormVersion { get; set; }
|
||||
private int EditCharacterFormVersion { get; set; }
|
||||
private bool StateRefreshInProgress { get; set; }
|
||||
private bool HasInteractiveRenderStarted { get; set; }
|
||||
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string?> LoggedOut { get; set; }
|
||||
|
||||
private string? SelectedCampaignName => SelectedCampaign?.Name;
|
||||
|
||||
private CharacterSummary? SelectedCharacter =>
|
||||
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
|
||||
|
||||
private SkillSummary? SelectedSkill =>
|
||||
SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
|
||||
|
||||
private string? ActiveCharacterName =>
|
||||
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name;
|
||||
|
||||
private bool IsCurrentUserGm =>
|
||||
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
|
||||
|
||||
private bool IsSelectedCampaignD6 =>
|
||||
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private List<SkillSummary> SelectedCharacterSkills =>
|
||||
SelectedCampaign is null || !SelectedCharacterId.HasValue
|
||||
? []
|
||||
: SelectedCampaign.Skills
|
||||
.Where(skill => skill.CharacterId == SelectedCharacterId.Value)
|
||||
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
|
||||
private bool IsManagementScreen => !IsPlayScreen;
|
||||
|
||||
private string ConnectionStateLabel => ConnectionState switch
|
||||
{
|
||||
"connected" => "Connected",
|
||||
"reconnecting" => "Reconnecting",
|
||||
_ => "Offline fallback"
|
||||
};
|
||||
|
||||
private string ConnectionStateCssClass => ConnectionState switch
|
||||
{
|
||||
"connected" => "ok",
|
||||
"reconnecting" => "warn",
|
||||
_ => "offline"
|
||||
};
|
||||
|
||||
private string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
HasInteractiveRenderStarted = true;
|
||||
if (!firstRender)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await InitializeAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
var storedScreen = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
|
||||
if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
CurrentScreen = "management";
|
||||
}
|
||||
|
||||
var storedPanel = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
|
||||
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MobilePanel = "log";
|
||||
}
|
||||
|
||||
Guid? preferredCampaignId = null;
|
||||
var storedCampaignId = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
|
||||
if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
|
||||
{
|
||||
preferredCampaignId = parsedCampaignId;
|
||||
}
|
||||
|
||||
await CheckHealthAsync();
|
||||
await LoadRulesetsAsync();
|
||||
|
||||
var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId);
|
||||
if (!reloaded)
|
||||
{
|
||||
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RetryAfterHealthIssueAsync()
|
||||
{
|
||||
await CheckHealthAsync();
|
||||
if (!HasHealthIssue && User is not null)
|
||||
{
|
||||
var reloaded = await ReloadAuthenticatedSessionAsync(SelectedCampaignId);
|
||||
if (!reloaded)
|
||||
{
|
||||
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckHealthAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var health = await ApiClient.RequestAsync<HealthResponse>("GET", "/api/health");
|
||||
if (!string.Equals(health.Status, "ok", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HasHealthIssue = true;
|
||||
HealthIssueMessage = "Health endpoint returned an unhealthy response.";
|
||||
return;
|
||||
}
|
||||
|
||||
HasHealthIssue = false;
|
||||
HealthIssueMessage = string.Empty;
|
||||
}
|
||||
catch (ApiRequestException)
|
||||
{
|
||||
HasHealthIssue = true;
|
||||
HealthIssueMessage = "Unable to reach API. Retry to continue.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadRulesetsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Rulesets = (await ApiClient.RequestAsync<IReadOnlyList<RulesetDefinition>>("GET", "/api/rulesets")).ToList();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId)
|
||||
{
|
||||
var me = await TryGetMeAsync();
|
||||
if (me is null)
|
||||
{
|
||||
ClearAuthenticatedState();
|
||||
await StopStateEventsAsync();
|
||||
return false;
|
||||
}
|
||||
|
||||
User = me.User;
|
||||
ActiveCharacterId = me.ActiveCharacterId;
|
||||
|
||||
await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<MeResponse?> TryGetMeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ApiClient.RequestAsync<MeResponse>("GET", "/api/me");
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
|
||||
{
|
||||
var campaigns = await ApiClient.RequestAsync<IReadOnlyList<CampaignSummary>>("GET", "/api/campaigns");
|
||||
Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
if (Campaigns.Count == 0)
|
||||
{
|
||||
SelectedCampaignId = null;
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var campaignIds = Campaigns.Select(c => c.Id).ToHashSet();
|
||||
if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value))
|
||||
{
|
||||
SelectedCampaignId = preferredCampaignId.Value;
|
||||
}
|
||||
else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value))
|
||||
{
|
||||
SelectedCampaignId = Campaigns[0].Id;
|
||||
}
|
||||
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString());
|
||||
}
|
||||
|
||||
private async Task RefreshCampaignScopeAsync()
|
||||
{
|
||||
if (!SelectedCampaignId.HasValue)
|
||||
{
|
||||
SelectedCampaign = null;
|
||||
CampaignLog = [];
|
||||
SelectedCharacterId = null;
|
||||
SelectedSkillId = null;
|
||||
ConnectionState = "offline";
|
||||
return;
|
||||
}
|
||||
|
||||
IsCampaignDataLoading = true;
|
||||
try
|
||||
{
|
||||
var campaignId = SelectedCampaignId.Value;
|
||||
SelectedCampaign = await ApiClient.RequestAsync<CampaignDetails>("GET", $"/api/campaigns/{campaignId}");
|
||||
CampaignLog = (await ApiClient.RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{campaignId}/log")).ToList();
|
||||
SyncSelectedCharacter();
|
||||
SyncSelectedSkill();
|
||||
await EnsureSelectedCharacterActiveAsync();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
ClearAuthenticatedState();
|
||||
await StopStateEventsAsync();
|
||||
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsCampaignDataLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ManualRefreshAsync()
|
||||
{
|
||||
if (IsMutating)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
await CheckHealthAsync();
|
||||
var reloaded = await ReloadAuthenticatedSessionAsync(SelectedCampaignId);
|
||||
if (!reloaded)
|
||||
{
|
||||
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
|
||||
return;
|
||||
}
|
||||
|
||||
SetStatus("Campaign data refreshed.", false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LogoutAsync()
|
||||
{
|
||||
if (IsMutating)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
await ApiClient.RequestWithoutPayloadAsync("POST", "/api/auth/logout");
|
||||
}
|
||||
catch (ApiRequestException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
|
||||
ClearAuthenticatedState();
|
||||
await StopStateEventsAsync();
|
||||
await LoggedOut.InvokeAsync("Logged out.");
|
||||
}
|
||||
|
||||
private async Task SwitchScreenAsync(string screen)
|
||||
{
|
||||
CurrentScreen = string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase) ? "management" : "play";
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen);
|
||||
}
|
||||
|
||||
private Task SwitchToPlayAsync() => SwitchScreenAsync("play");
|
||||
private Task SwitchToManagementAsync() => SwitchScreenAsync("management");
|
||||
|
||||
private async Task SetMobilePanelAsync(string panel)
|
||||
{
|
||||
MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character";
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel);
|
||||
}
|
||||
|
||||
private Task SetMobilePanelCharacterAsync() => SetMobilePanelAsync("character");
|
||||
private Task SetMobilePanelLogAsync() => SetMobilePanelAsync("log");
|
||||
|
||||
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
|
||||
{
|
||||
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedCampaignId = campaignId;
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
}
|
||||
|
||||
private async Task OnCampaignCreatedAsync(Guid campaignId)
|
||||
{
|
||||
await ReloadCampaignsAsync(campaignId);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
SetStatus("Campaign created.", false);
|
||||
}
|
||||
|
||||
private void OpenCreateCharacterModal()
|
||||
{
|
||||
CreateCharacterInitialModel = new CharacterFormModel
|
||||
{
|
||||
Name = string.Empty,
|
||||
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty
|
||||
};
|
||||
|
||||
CreateCharacterFormVersion++;
|
||||
ShowCreateCharacterModal = true;
|
||||
}
|
||||
|
||||
private void OpenEditCharacterModal(CharacterSummary character)
|
||||
{
|
||||
EditingCharacterId = character.Id;
|
||||
EditCharacterInitialModel = new CharacterFormModel
|
||||
{
|
||||
Name = character.Name,
|
||||
CampaignId = character.CampaignId.ToString()
|
||||
};
|
||||
|
||||
EditCharacterFormVersion++;
|
||||
ShowEditCharacterModal = true;
|
||||
}
|
||||
|
||||
private void CloseCharacterModals()
|
||||
{
|
||||
ShowCreateCharacterModal = false;
|
||||
ShowEditCharacterModal = false;
|
||||
EditingCharacterId = null;
|
||||
}
|
||||
|
||||
private async Task OnCharacterCreatedAsync(Guid campaignId)
|
||||
{
|
||||
CloseCharacterModals();
|
||||
await ReloadCampaignsAsync(campaignId);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
SetStatus("Character created.", false);
|
||||
}
|
||||
|
||||
private async Task OnCharacterUpdatedAsync(Guid campaignId)
|
||||
{
|
||||
CloseCharacterModals();
|
||||
await ReloadCampaignsAsync(campaignId);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
SetStatus("Character updated.", false);
|
||||
}
|
||||
|
||||
private async Task SelectCharacterAsync(Guid characterId)
|
||||
{
|
||||
SelectedCharacterId = characterId;
|
||||
SyncSelectedSkill();
|
||||
await EnsureSelectedCharacterActiveAsync();
|
||||
}
|
||||
|
||||
private bool CanEditCharacter(CharacterSummary character)
|
||||
{
|
||||
return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm);
|
||||
}
|
||||
|
||||
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
|
||||
{
|
||||
return user is not null && character.OwnerUserId == user.Id;
|
||||
}
|
||||
|
||||
private async Task EnsureSelectedCharacterActiveAsync()
|
||||
{
|
||||
if (!SelectedCharacterId.HasValue || SelectedCampaign is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value);
|
||||
if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ApiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate");
|
||||
ActiveCharacterId = character.Id;
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSkillCreatedAsync(Guid _)
|
||||
{
|
||||
await RefreshCampaignScopeAsync();
|
||||
SetStatus("Skill created.", false);
|
||||
}
|
||||
|
||||
private async Task OnSkillUpdatedAsync(Guid skillId)
|
||||
{
|
||||
SelectedSkillId = skillId;
|
||||
await RefreshCampaignScopeAsync();
|
||||
SetStatus("Skill updated.", false);
|
||||
}
|
||||
|
||||
private async Task RollSelectedSkillAsync()
|
||||
{
|
||||
if (SelectedSkill is null)
|
||||
{
|
||||
SetStatus("Select a skill to roll.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
LastRoll = await ApiClient.RequestAsync<RollResult>(
|
||||
"POST",
|
||||
$"/api/skills/{SelectedSkill.Id}/roll",
|
||||
new RollSkillRequest(RollVisibility));
|
||||
|
||||
await RefreshCampaignScopeAsync();
|
||||
SetStatus("Roll recorded.", false);
|
||||
Announce("Roll result updated.");
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnRollVisibilityChanged(string visibility)
|
||||
{
|
||||
RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void SelectSkill(Guid skillId)
|
||||
{
|
||||
SelectedSkillId = skillId;
|
||||
}
|
||||
|
||||
private bool CanEditSkill(SkillSummary skill)
|
||||
{
|
||||
if (SelectedCampaign is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId);
|
||||
return character is not null && CanEditCharacter(character);
|
||||
}
|
||||
|
||||
private bool CanRollSkill(SkillSummary skill)
|
||||
{
|
||||
return CanEditSkill(skill);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnStateEventReceived(long _)
|
||||
{
|
||||
if (StateRefreshInProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StateRefreshInProgress = true;
|
||||
try
|
||||
{
|
||||
await RefreshCampaignScopeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
StateRefreshInProgress = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task OnConnectionStateChanged(string state)
|
||||
{
|
||||
ConnectionState = state switch
|
||||
{
|
||||
"connected" => "connected",
|
||||
"reconnecting" => "reconnecting",
|
||||
_ => "offline"
|
||||
};
|
||||
|
||||
if (ConnectionState == "reconnecting")
|
||||
{
|
||||
Announce("Reconnecting to live updates.");
|
||||
}
|
||||
|
||||
if (ConnectionState == "offline")
|
||||
{
|
||||
Announce("Live updates offline. Use manual refresh.");
|
||||
}
|
||||
|
||||
return InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task SyncStateEventsAsync()
|
||||
{
|
||||
if (User is null || !SelectedCampaignId.HasValue)
|
||||
{
|
||||
await StopStateEventsAsync();
|
||||
ConnectionState = "offline";
|
||||
return;
|
||||
}
|
||||
|
||||
DotNetRef ??= DotNetObjectReference.Create(this);
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", SelectedCampaignId.Value.ToString(), DotNetRef);
|
||||
ConnectionState = "reconnecting";
|
||||
}
|
||||
|
||||
private async Task StopStateEventsAsync()
|
||||
{
|
||||
if (!HasInteractiveRenderStarted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents");
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopStateEventsAsync();
|
||||
DotNetRef?.Dispose();
|
||||
}
|
||||
|
||||
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
|
||||
{
|
||||
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void SyncSelectedCharacter()
|
||||
{
|
||||
if (SelectedCampaign is null || SelectedCampaign.Characters.Count == 0)
|
||||
{
|
||||
SelectedCharacterId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet();
|
||||
if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ActiveCharacterId.HasValue && candidateIds.Contains(ActiveCharacterId.Value))
|
||||
{
|
||||
SelectedCharacterId = ActiveCharacterId;
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedCharacterId = SelectedCampaign.Characters[0].Id;
|
||||
}
|
||||
|
||||
private void SyncSelectedSkill()
|
||||
{
|
||||
var skills = SelectedCharacterSkills;
|
||||
if (skills.Count == 0)
|
||||
{
|
||||
SelectedSkillId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedSkillId.HasValue && skills.Any(skill => skill.Id == SelectedSkillId.Value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedSkillId = skills[0].Id;
|
||||
}
|
||||
|
||||
private string OwnerLabel(Guid ownerUserId)
|
||||
{
|
||||
if (User is not null && ownerUserId == User.Id)
|
||||
{
|
||||
return "You";
|
||||
}
|
||||
|
||||
if (SelectedCampaign is not null && ownerUserId == SelectedCampaign.Gm.Id)
|
||||
{
|
||||
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
|
||||
}
|
||||
|
||||
return ownerUserId.ToString("N")[..8];
|
||||
}
|
||||
|
||||
private string CharacterLabel(Guid characterId)
|
||||
{
|
||||
return SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == characterId)?.Name ?? "Hidden character";
|
||||
}
|
||||
|
||||
private string SkillLabel(Guid skillId)
|
||||
{
|
||||
return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill";
|
||||
}
|
||||
|
||||
private string SkillDefinitionLabel(SkillSummary skill)
|
||||
{
|
||||
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return skill.DiceRollDefinition;
|
||||
}
|
||||
|
||||
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
|
||||
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}";
|
||||
}
|
||||
|
||||
private string RollerLabel(CampaignLogEntry entry)
|
||||
{
|
||||
if (User is not null && entry.RollerUserId == User.Id)
|
||||
{
|
||||
return "You";
|
||||
}
|
||||
|
||||
if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id)
|
||||
{
|
||||
return "GM";
|
||||
}
|
||||
|
||||
return "Participant";
|
||||
}
|
||||
|
||||
private string VisibilityLabel(CampaignLogEntry entry)
|
||||
{
|
||||
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Public";
|
||||
}
|
||||
|
||||
if (User is not null && entry.RollerUserId == User.Id)
|
||||
{
|
||||
return "Private (you)";
|
||||
}
|
||||
|
||||
return IsCurrentUserGm ? "Private (GM view)" : "Private";
|
||||
}
|
||||
|
||||
private string VisibilityBadgeCssClass(CampaignLogEntry entry)
|
||||
{
|
||||
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public";
|
||||
}
|
||||
|
||||
if (User is not null && entry.RollerUserId == User.Id)
|
||||
{
|
||||
return "private-self";
|
||||
}
|
||||
|
||||
return IsCurrentUserGm ? "private-gm" : "private-generic";
|
||||
}
|
||||
|
||||
private string LogEntryCssClass(CampaignLogEntry entry)
|
||||
{
|
||||
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public";
|
||||
}
|
||||
|
||||
if (User is not null && entry.RollerUserId == User.Id)
|
||||
{
|
||||
return "private-self";
|
||||
}
|
||||
|
||||
return IsCurrentUserGm ? "private-gm" : "private-generic";
|
||||
}
|
||||
|
||||
private void ClearAuthenticatedState()
|
||||
{
|
||||
User = null;
|
||||
ActiveCharacterId = null;
|
||||
SelectedCampaignId = null;
|
||||
SelectedCampaign = null;
|
||||
Campaigns = [];
|
||||
CampaignLog = [];
|
||||
SelectedCharacterId = null;
|
||||
SelectedSkillId = null;
|
||||
LastRoll = null;
|
||||
ShowCreateCharacterModal = false;
|
||||
ShowEditCharacterModal = false;
|
||||
CreateCharacterInitialModel = new();
|
||||
EditCharacterInitialModel = new();
|
||||
CreateCharacterFormVersion = 0;
|
||||
EditCharacterFormVersion = 0;
|
||||
}
|
||||
|
||||
private void SetStatus(string message, bool isError)
|
||||
{
|
||||
StatusMessage = message;
|
||||
StatusIsError = isError;
|
||||
Announce(message);
|
||||
}
|
||||
|
||||
private void Announce(string message)
|
||||
{
|
||||
LiveAnnouncement = message;
|
||||
}
|
||||
}
|
||||
7
TECH.md
7
TECH.md
@@ -92,11 +92,12 @@ This pattern is a strong baseline for low to medium scale and should be the defa
|
||||
### 2.6 Frontend architecture
|
||||
|
||||
- Blazor component tree rooted in `Components/App.razor` and `Components/Pages/Home.razor`.
|
||||
- Home page logic split by concern with partials (`Home.State/Auth/Campaign/Character/Skill/Lifecycle/Realtime/Api/Presentation/Validation.cs`) to keep churn localized.
|
||||
- `Home.razor` + `Home.razor.cs` are intentionally minimal and only manage loading/auth/workspace view-mode switching.
|
||||
- Authenticated workspace UI plus workspace state/behavior are centralized in `Components/Pages/Workspace.razor`.
|
||||
- Form UX state uses reusable `FormState<TModel>` containers in leaf controls (`HomeControls/*`) rather than parallel form/error/message property sets in `Home`.
|
||||
- Concern controls execute their own auth/campaign/character/skill mutation workflows and notify `Home` only for shared-state refresh/orchestration.
|
||||
- Concern controls execute their own auth/campaign/character/skill mutation workflows and notify the workspace host only for shared-state refresh/orchestration.
|
||||
- Skill management workflows are owned by `CharacterPanel` to keep character-skill behavior cohesive.
|
||||
- Shared browser API interop is centralized in `RpgRollerApiClient` and reused by `Home` plus concern controls.
|
||||
- Shared browser API interop is centralized in `RpgRollerApiClient` and reused by `Home`, `Workspace`, and concern controls.
|
||||
- Browser API calls and SSE are handled via `wwwroot/js/rpgroller-api.js` interop.
|
||||
- UI state is maintained server-side per circuit with session/tab persistence for campaign + screen selection.
|
||||
- SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback.
|
||||
|
||||
Reference in New Issue
Block a user