Refactor Home page into concern-based components and partials

This commit is contained in:
2026-02-26 09:42:06 +01:00
parent 2d1bf9b9b7
commit b17490e5ac
21 changed files with 1791 additions and 1528 deletions

View File

@@ -0,0 +1,51 @@
using System.Text.Json;
using Microsoft.JSInterop;
namespace RpgRoller.Components.Pages;
public partial class Home
{
private async Task<T> RequestAsync<T>(string method, string path, object? payload = null)
{
var response = await JS.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
if (!response.Ok)
{
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
}
if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
{
return default!;
}
return response.Data.Deserialize<T>(JsonOptions)!;
}
private async Task RequestWithoutPayloadAsync(string method, string path)
{
var response = await JS.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null);
if (!response.Ok)
{
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
}
}
private sealed class JsApiResponse
{
public bool Ok { get; set; }
public int Status { get; set; }
public string? Error { get; set; }
public JsonElement Data { get; set; }
}
private sealed class ApiRequestException : Exception
{
public ApiRequestException(int statusCode, string message)
: base(message)
{
StatusCode = statusCode;
}
public int StatusCode { get; }
}
}

View File

@@ -0,0 +1,113 @@
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
public partial class Home
{
private async Task RegisterAsync()
{
RegisterState.ResetValidation();
var model = RegisterState.Model;
AddRequiredError(RegisterState.Errors, "username", model.Username, "Username is required.");
AddRequiredError(RegisterState.Errors, "displayName", model.DisplayName, "Display name is required.");
if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8)
{
RegisterState.Errors["password"] = "Password must be at least 8 characters.";
}
if (RegisterState.Errors.Count > 0)
{
RegisterState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsMutating = true;
try
{
_ = await RequestAsync<UserSummary>(
"POST",
"/api/auth/register",
new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
model.Password = string.Empty;
SetStatus("Registration successful. You can log in now.", false);
}
catch (ApiRequestException ex)
{
if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase))
{
RegisterState.Errors["username"] = "Username is already taken. Choose another one.";
return;
}
RegisterState.ErrorMessage = ex.Message;
}
finally
{
IsMutating = false;
}
}
private async Task LoginAsync()
{
LoginState.ResetValidation();
var model = LoginState.Model;
AddRequiredError(LoginState.Errors, "username", model.Username, "Username is required.");
AddRequiredError(LoginState.Errors, "password", model.Password, "Password is required.");
if (LoginState.Errors.Count > 0)
{
LoginState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsMutating = true;
try
{
_ = await RequestAsync<UserSummary>(
"POST",
"/api/auth/login",
new LoginRequest(model.Username.Trim(), model.Password));
model.Password = string.Empty;
await ReloadAuthenticatedSessionAsync(null);
SetStatus("Logged in.", false);
}
catch (ApiRequestException ex)
{
LoginState.ErrorMessage = ex.Message;
}
finally
{
IsMutating = false;
}
}
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);
}
}

View File

@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using RpgRoller.Contracts;
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 CreateCampaignAsync()
{
CampaignState.ResetValidation();
var model = CampaignState.Model;
AddRequiredError(CampaignState.Errors, "name", model.Name, "Campaign name is required.");
AddRequiredError(CampaignState.Errors, "rulesetId", model.RulesetId, "Ruleset is required.");
if (CampaignState.Errors.Count > 0)
{
CampaignState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsMutating = true;
try
{
var campaign = await RequestAsync<CampaignSummary>(
"POST",
"/api/campaigns",
new CreateCampaignRequest(model.Name.Trim(), model.RulesetId));
model.Name = string.Empty;
await ReloadCampaignsAsync(campaign.Id);
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus("Campaign created.", false);
}
catch (ApiRequestException ex)
{
CampaignState.ErrorMessage = ex.Message;
}
finally
{
IsMutating = false;
}
}
}

View File

@@ -0,0 +1,167 @@
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
public partial class Home
{
private void OpenCreateCharacterModal()
{
var model = CreateCharacterState.Model;
model.Name = string.Empty;
model.CampaignId = SelectedCampaignId?.ToString() ?? string.Empty;
CreateCharacterState.ResetValidation();
ShowCreateCharacterModal = true;
}
private void OpenEditCharacterModal(CharacterSummary character)
{
EditingCharacterId = character.Id;
var model = EditCharacterState.Model;
model.Name = character.Name;
model.CampaignId = character.CampaignId.ToString();
EditCharacterState.ResetValidation();
ShowEditCharacterModal = true;
}
private void CloseCharacterModals()
{
ShowCreateCharacterModal = false;
ShowEditCharacterModal = false;
EditingCharacterId = null;
}
private async Task CreateCharacterAsync()
{
CreateCharacterState.ResetValidation();
var model = CreateCharacterState.Model;
AddRequiredError(CreateCharacterState.Errors, "name", model.Name, "Character name is required.");
var hasCampaignId = TryParseGuid(
model.CampaignId,
CreateCharacterState.Errors,
"campaignId",
"Campaign is required.",
out var campaignId);
if (CreateCharacterState.Errors.Count > 0 || !hasCampaignId)
{
CreateCharacterState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsMutating = true;
try
{
_ = await RequestAsync<CharacterSummary>(
"POST",
"/api/characters",
new CreateCharacterRequest(model.Name.Trim(), campaignId));
CloseCharacterModals();
await ReloadCampaignsAsync(campaignId);
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus("Character created.", false);
}
catch (ApiRequestException ex)
{
CreateCharacterState.ErrorMessage = ex.Message;
}
finally
{
IsMutating = false;
}
}
private async Task UpdateCharacterAsync()
{
EditCharacterState.ResetValidation();
if (!EditingCharacterId.HasValue)
{
EditCharacterState.ErrorMessage = "No character selected.";
return;
}
var model = EditCharacterState.Model;
AddRequiredError(EditCharacterState.Errors, "name", model.Name, "Character name is required.");
var hasCampaignId = TryParseGuid(
model.CampaignId,
EditCharacterState.Errors,
"campaignId",
"Campaign is required.",
out var campaignId);
if (EditCharacterState.Errors.Count > 0 || !hasCampaignId)
{
EditCharacterState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsMutating = true;
try
{
var updatedCharacter = await RequestAsync<CharacterSummary>(
"PUT",
$"/api/characters/{EditingCharacterId.Value}",
new UpdateCharacterRequest(model.Name.Trim(), campaignId));
CloseCharacterModals();
await ReloadCampaignsAsync(updatedCharacter.CampaignId);
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
SetStatus("Character updated.", false);
}
catch (ApiRequestException ex)
{
EditCharacterState.ErrorMessage = ex.Message;
}
finally
{
IsMutating = 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);
}
}
}

View File

@@ -0,0 +1,206 @@
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();
if (Rulesets.Count > 0 && string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId))
{
CampaignState.Model.RulesetId = Rulesets[0].Id;
}
}
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;
}
}
}

View File

@@ -0,0 +1,55 @@
namespace RpgRoller.Components.Pages;
public sealed class FormState<TModel>
where TModel : new()
{
public TModel Model { get; } = new();
public Dictionary<string, string> Errors { get; } = [];
public string? ErrorMessage { get; set; }
public void ResetValidation()
{
Errors.Clear();
ErrorMessage = null;
}
}
public sealed class RegisterFormModel
{
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public sealed class LoginFormModel
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public sealed class CampaignFormModel
{
public string Name { get; set; } = string.Empty;
public string RulesetId { get; set; } = string.Empty;
}
public sealed class CharacterFormModel
{
public string Name { get; set; } = string.Empty;
public string CampaignId { get; set; } = string.Empty;
}
public sealed class SkillFormModel
{
public string Name { get; set; } = string.Empty;
public string DiceRollDefinition { get; set; } = string.Empty;
public int WildDice { get; set; }
public bool AllowFumble { get; set; }
}
public enum HomeViewMode
{
Loading,
Anonymous,
Workspace
}

View File

@@ -0,0 +1,171 @@
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;
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
}
private void SetStatus(string message, bool isError)
{
StatusMessage = message;
StatusIsError = isError;
Announce(message);
}
private void Announce(string message)
{
LiveAnnouncement = message;
}
}

View File

@@ -0,0 +1,93 @@
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);
}
}

View File

@@ -0,0 +1,196 @@
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
public partial class Home
{
private void OpenCreateSkillModal()
{
var model = CreateSkillState.Model;
model.Name = string.Empty;
model.DiceRollDefinition = string.Empty;
model.WildDice = IsSelectedCampaignD6 ? 1 : 0;
model.AllowFumble = IsSelectedCampaignD6;
CreateSkillState.ResetValidation();
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal()
{
if (SelectedSkill is null)
{
return;
}
EditingSkillId = SelectedSkill.Id;
var model = EditSkillState.Model;
model.Name = SelectedSkill.Name;
model.DiceRollDefinition = SelectedSkill.DiceRollDefinition;
model.WildDice = SelectedSkill.WildDice;
model.AllowFumble = SelectedSkill.AllowFumble;
EditSkillState.ResetValidation();
ShowEditSkillModal = true;
}
private void CloseSkillModals()
{
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
EditingSkillId = null;
}
private async Task CreateSkillAsync()
{
CreateSkillState.ResetValidation();
if (SelectedCharacter is null)
{
CreateSkillState.ErrorMessage = "Select a character first.";
return;
}
var model = CreateSkillState.Model;
AddRequiredError(CreateSkillState.Errors, "name", model.Name, "Skill name is required.");
AddRequiredError(CreateSkillState.Errors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required.");
if (IsSelectedCampaignD6 && model.WildDice < 1)
{
CreateSkillState.Errors["wildDice"] = "D6 skills require at least one wild die.";
}
if (CreateSkillState.Errors.Count > 0)
{
CreateSkillState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsMutating = true;
try
{
_ = await RequestAsync<SkillSummary>(
"POST",
$"/api/characters/{SelectedCharacter.Id}/skills",
new CreateSkillRequest(model.Name.Trim(), model.DiceRollDefinition.Trim(), model.WildDice, model.AllowFumble));
CloseSkillModals();
await RefreshCampaignScopeAsync();
SetStatus("Skill created.", false);
}
catch (ApiRequestException ex)
{
CreateSkillState.ErrorMessage = ex.Message;
}
finally
{
IsMutating = false;
}
}
private async Task UpdateSkillAsync()
{
EditSkillState.ResetValidation();
if (!EditingSkillId.HasValue)
{
EditSkillState.ErrorMessage = "No skill selected.";
return;
}
var model = EditSkillState.Model;
AddRequiredError(EditSkillState.Errors, "name", model.Name, "Skill name is required.");
AddRequiredError(EditSkillState.Errors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required.");
if (IsSelectedCampaignD6 && model.WildDice < 1)
{
EditSkillState.Errors["wildDice"] = "D6 skills require at least one wild die.";
}
if (EditSkillState.Errors.Count > 0)
{
EditSkillState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsMutating = true;
try
{
var updatedSkill = await RequestAsync<SkillSummary>(
"PUT",
$"/api/skills/{EditingSkillId.Value}",
new UpdateSkillRequest(model.Name.Trim(), model.DiceRollDefinition.Trim(), model.WildDice, model.AllowFumble));
SelectedSkillId = updatedSkill.Id;
CloseSkillModals();
await RefreshCampaignScopeAsync();
SetStatus("Skill updated.", false);
}
catch (ApiRequestException ex)
{
EditSkillState.ErrorMessage = ex.Message;
}
finally
{
IsMutating = 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);
}
}

View File

@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
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 static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private FormState<RegisterFormModel> RegisterState { get; } = new();
private FormState<LoginFormModel> LoginState { get; } = new();
private FormState<CampaignFormModel> CampaignState { get; } = new();
private FormState<CharacterFormModel> CreateCharacterState { get; } = new();
private FormState<CharacterFormModel> EditCharacterState { get; } = new();
private FormState<SkillFormModel> CreateSkillState { get; } = new();
private FormState<SkillFormModel> EditSkillState { get; } = new();
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 bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; }
private Guid? EditingCharacterId { get; set; }
private Guid? EditingSkillId { 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";
}

View File

@@ -0,0 +1,23 @@
namespace RpgRoller.Components.Pages;
public partial class Home
{
private static void AddRequiredError(Dictionary<string, string> errors, string field, string? value, string message)
{
if (string.IsNullOrWhiteSpace(value))
{
errors[field] = message;
}
}
private static bool TryParseGuid(string? rawValue, Dictionary<string, string> errors, string field, string message, out Guid parsed)
{
if (Guid.TryParse(rawValue, out parsed))
{
return true;
}
errors[field] = message;
return false;
}
}

View File

@@ -5,92 +5,38 @@
<div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
@if (!IsInitialized)
@if (HasHealthIssue)
{
<main class="loading-shell" aria-busy="true" aria-live="polite">
<h1>RpgRoller</h1>
<p>Connecting...</p>
</main>
<section class="health-banner" role="alert">
<div>
<strong>API currently unavailable.</strong>
<p>@HealthIssueMessage</p>
</div>
<button type="button" @onclick="RetryAfterHealthIssueAsync">Retry</button>
</section>
}
else
@switch (CurrentView)
{
@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>
}
@if (User is null)
{
<main class="auth-shell">
case HomeViewMode.Loading:
<main class="loading-shell" aria-busy="true" aria-live="polite">
<h1>RpgRoller</h1>
<p class="auth-subtitle">Register or log in to join a campaign session.</p>
@if (!string.IsNullOrWhiteSpace(StatusMessage))
{
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
}
<div class="auth-grid">
<section class="card auth-card">
<h2>Register</h2>
@if (!string.IsNullOrWhiteSpace(RegisterFormError))
{
<p class="form-error">@RegisterFormError</p>
}
<form class="form-grid" @onsubmit="RegisterAsync" @onsubmit:preventDefault>
<label for="register-username">Username</label>
<input id="register-username" @bind="RegisterForm.Username" @bind:event="oninput" autocomplete="username" />
@if (RegisterErrors.TryGetValue("username", out var registerUsernameError))
{
<p class="field-error">@registerUsernameError</p>
}
<label for="register-display-name">Display name</label>
<input id="register-display-name" @bind="RegisterForm.DisplayName" @bind:event="oninput" autocomplete="name" />
@if (RegisterErrors.TryGetValue("displayName", out var registerDisplayNameError))
{
<p class="field-error">@registerDisplayNameError</p>
}
<label for="register-password">Password</label>
<input id="register-password" type="password" @bind="RegisterForm.Password" @bind:event="oninput" autocomplete="new-password" />
@if (RegisterErrors.TryGetValue("password", out var registerPasswordError))
{
<p class="field-error">@registerPasswordError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Registering..." : "Register")</button>
</form>
</section>
<section class="card auth-card">
<h2>Login</h2>
@if (!string.IsNullOrWhiteSpace(LoginFormError))
{
<p class="form-error">@LoginFormError</p>
}
<form class="form-grid" @onsubmit="LoginAsync" @onsubmit:preventDefault>
<label for="login-username">Username</label>
<input id="login-username" @bind="LoginForm.Username" @bind:event="oninput" autocomplete="username" />
@if (LoginErrors.TryGetValue("username", out var loginUsernameError))
{
<p class="field-error">@loginUsernameError</p>
}
<label for="login-password">Password</label>
<input id="login-password" type="password" @bind="LoginForm.Password" @bind:event="oninput" autocomplete="current-password" />
@if (LoginErrors.TryGetValue("password", out var loginPasswordError))
{
<p class="field-error">@loginPasswordError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Logging in..." : "Login")</button>
</form>
</section>
</div>
<p>Connecting...</p>
</main>
}
else
{
break;
case HomeViewMode.Anonymous:
<AuthSection
RegisterState="RegisterState"
LoginState="LoginState"
IsMutating="IsMutating"
StatusMessage="StatusMessage"
StatusIsError="StatusIsError"
RegisterSubmitted="RegisterAsync"
LoginSubmitted="LoginAsync" />
break;
case HomeViewMode.Workspace:
<div class="workspace-shell">
<header class="workspace-header">
<div class="header-group brand">
@@ -98,7 +44,7 @@
<p>Tabletop utility cockpit</p>
</div>
<div class="header-group context">
<p><strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
<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>
@@ -120,7 +66,7 @@
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
}
@if (CurrentScreen == "play")
@if (IsPlayScreen)
{
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<CharacterPanel
@@ -162,250 +108,77 @@
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)" @onclick="SetMobilePanelLogAsync">Log</button>
</nav>
}
else
@if (IsManagementScreen)
{
<main class="management-screen">
<section class="card">
<h2>Campaign Selector</h2>
@if (Campaigns.Count == 0)
{
<p class="empty">No campaigns yet.</p>
}
else
{
<label for="campaign-select">Campaign</label>
<select id="campaign-select" @onchange="OnCampaignSelectionChangedAsync">
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option>
}
</select>
}
<p class="muted">Current campaign in this tab: <strong>@(SelectedCampaignName ?? "None selected")</strong></p>
</section>
<section class="card">
<h2>Create Campaign</h2>
@if (!string.IsNullOrWhiteSpace(CampaignFormError))
{
<p class="form-error">@CampaignFormError</p>
}
<form class="form-grid" @onsubmit="CreateCampaignAsync" @onsubmit:preventDefault>
<label for="campaign-name">Campaign name</label>
<input id="campaign-name" @bind="CampaignForm.Name" @bind:event="oninput" />
@if (CampaignErrors.TryGetValue("name", out var campaignNameError))
{
<p class="field-error">@campaignNameError</p>
}
<label for="campaign-ruleset">Ruleset</label>
<select id="campaign-ruleset" @bind="CampaignForm.RulesetId">
<option value="">Select ruleset</option>
@foreach (var ruleset in Rulesets)
{
<option value="@ruleset.Id">@ruleset.Name</option>
}
</select>
@if (CampaignErrors.TryGetValue("rulesetId", out var campaignRulesetError))
{
<p class="field-error">@campaignRulesetError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Creating..." : "Create Campaign")</button>
</form>
</section>
<section class="card">
<h2>Campaign Details</h2>
@if (SelectedCampaign is null)
{
<p class="empty">No campaign selected.</p>
}
else
{
<p>Name: <strong>@SelectedCampaign.Name</strong></p>
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p>
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
}
</section>
<section class="card">
<div class="section-head">
<h2>Character Management</h2>
<button type="button" disabled="@(IsMutating || SelectedCampaign is null)" @onclick="OpenCreateCharacterModal">Create Character</button>
</div>
@if (SelectedCampaign is null)
{
<p class="empty">Select a campaign first.</p>
}
else if (SelectedCampaign.Characters.Count == 0)
{
<p class="empty">No characters in this campaign yet.</p>
}
else
{
<ul class="management-list">
@foreach (var character in SelectedCampaign.Characters)
{
<li>
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => OpenEditCharacterModal(character)">Edit</button>
</div>
</li>
}
</ul>
}
</section>
</main>
<CampaignManagementPanel
Campaigns="Campaigns"
SelectedCampaignId="SelectedCampaignId"
SelectedCampaignName="SelectedCampaignName"
SelectedCampaign="SelectedCampaign"
CampaignState="CampaignState"
Rulesets="Rulesets"
IsMutating="IsMutating"
OwnerLabel="OwnerLabel"
CanEditCharacter="CanEditCharacter"
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
CreateCampaignSubmitted="CreateCampaignAsync"
CreateCharacterRequested="OpenCreateCharacterModal"
EditCharacterRequested="OpenEditCharacterModal" />
}
</div>
}
break;
}
</div>
@if (ShowCreateCharacterModal)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Create character">
<h2>Create Character</h2>
@if (!string.IsNullOrWhiteSpace(CharacterFormError))
{
<p class="form-error">@CharacterFormError</p>
}
<form class="form-grid" @onsubmit="CreateCharacterAsync" @onsubmit:preventDefault>
<label for="character-create-name">Character name</label>
<input id="character-create-name" @bind="CharacterForm.Name" @bind:event="oninput" />
@if (CharacterErrors.TryGetValue("name", out var createCharacterNameError))
{
<p class="field-error">@createCharacterNameError</p>
}
<label for="character-create-campaign">Campaign</label>
<select id="character-create-campaign" @bind="CharacterForm.CampaignId">
<option value="">Select campaign</option>
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id">@campaign.Name</option>
}
</select>
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">Create Character</button>
<button type="button" class="ghost" @onclick="CloseCharacterModals">Cancel</button>
</div>
</form>
</section>
</div>
}
<CharacterFormModal
Visible="ShowCreateCharacterModal"
Title="Create Character"
SubmitLabel="Create Character"
NameInputId="character-create-name"
CampaignInputId="character-create-campaign"
FormState="CreateCharacterState"
Campaigns="Campaigns"
IsMutating="IsMutating"
SubmitRequested="CreateCharacterAsync"
CancelRequested="CloseCharacterModals" />
@if (ShowEditCharacterModal)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Edit character">
<h2>Edit Character</h2>
@if (!string.IsNullOrWhiteSpace(EditCharacterFormError))
{
<p class="form-error">@EditCharacterFormError</p>
}
<form class="form-grid" @onsubmit="UpdateCharacterAsync" @onsubmit:preventDefault>
<label for="character-edit-name">Character name</label>
<input id="character-edit-name" @bind="EditCharacterForm.Name" @bind:event="oninput" />
@if (EditCharacterErrors.TryGetValue("name", out var editCharacterNameError))
{
<p class="field-error">@editCharacterNameError</p>
}
<label for="character-edit-campaign">Campaign</label>
<select id="character-edit-campaign" @bind="EditCharacterForm.CampaignId">
<option value="">Select campaign</option>
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id">@campaign.Name</option>
}
</select>
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">Save Character</button>
<button type="button" class="ghost" @onclick="CloseCharacterModals">Cancel</button>
</div>
</form>
</section>
</div>
}
<CharacterFormModal
Visible="ShowEditCharacterModal"
Title="Edit Character"
SubmitLabel="Save Character"
NameInputId="character-edit-name"
CampaignInputId="character-edit-campaign"
FormState="EditCharacterState"
Campaigns="Campaigns"
IsMutating="IsMutating"
SubmitRequested="UpdateCharacterAsync"
CancelRequested="CloseCharacterModals" />
@if (ShowCreateSkillModal)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Create skill">
<h2>Create Skill</h2>
@if (!string.IsNullOrWhiteSpace(SkillFormError))
{
<p class="form-error">@SkillFormError</p>
}
<form class="form-grid" @onsubmit="CreateSkillAsync" @onsubmit:preventDefault>
<label for="skill-create-name">Skill name</label>
<input id="skill-create-name" @bind="SkillForm.Name" @bind:event="oninput" />
@if (SkillErrors.TryGetValue("name", out var createSkillNameError))
{
<p class="field-error">@createSkillNameError</p>
}
<label for="skill-create-expression">Expression</label>
<input id="skill-create-expression" @bind="SkillForm.DiceRollDefinition" @bind:event="oninput" />
@if (SkillErrors.TryGetValue("diceRollDefinition", out var createSkillExpressionError))
{
<p class="field-error">@createSkillExpressionError</p>
}
@if (IsSelectedCampaignD6)
{
<label for="skill-create-wild-dice">Wild dice</label>
<input id="skill-create-wild-dice" type="number" min="1" step="1" @bind="SkillForm.WildDice" />
@if (SkillErrors.TryGetValue("wildDice", out var createSkillWildDiceError))
{
<p class="field-error">@createSkillWildDiceError</p>
}
<label for="skill-create-allow-fumble">Allow fumble</label>
<input id="skill-create-allow-fumble" type="checkbox" @bind="SkillForm.AllowFumble" />
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">Create Skill</button>
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
</div>
</form>
</section>
</div>
}
<SkillFormModal
Visible="ShowCreateSkillModal"
IsD6="IsSelectedCampaignD6"
Title="Create Skill"
SubmitLabel="Create Skill"
NameInputId="skill-create-name"
ExpressionInputId="skill-create-expression"
WildDiceInputId="skill-create-wild-dice"
AllowFumbleInputId="skill-create-allow-fumble"
FormState="CreateSkillState"
IsMutating="IsMutating"
SubmitRequested="CreateSkillAsync"
CancelRequested="CloseSkillModals" />
@if (ShowEditSkillModal)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Edit skill">
<h2>Edit Skill</h2>
@if (!string.IsNullOrWhiteSpace(EditSkillFormError))
{
<p class="form-error">@EditSkillFormError</p>
}
<form class="form-grid" @onsubmit="UpdateSkillAsync" @onsubmit:preventDefault>
<label for="skill-edit-name">Skill name</label>
<input id="skill-edit-name" @bind="EditSkillForm.Name" @bind:event="oninput" />
@if (EditSkillErrors.TryGetValue("name", out var editSkillNameError))
{
<p class="field-error">@editSkillNameError</p>
}
<label for="skill-edit-expression">Expression</label>
<input id="skill-edit-expression" @bind="EditSkillForm.DiceRollDefinition" @bind:event="oninput" />
@if (EditSkillErrors.TryGetValue("diceRollDefinition", out var editSkillExpressionError))
{
<p class="field-error">@editSkillExpressionError</p>
}
@if (IsSelectedCampaignD6)
{
<label for="skill-edit-wild-dice">Wild dice</label>
<input id="skill-edit-wild-dice" type="number" min="1" step="1" @bind="EditSkillForm.WildDice" />
@if (EditSkillErrors.TryGetValue("wildDice", out var editSkillWildDiceError))
{
<p class="field-error">@editSkillWildDiceError</p>
}
<label for="skill-edit-allow-fumble">Allow fumble</label>
<input id="skill-edit-allow-fumble" type="checkbox" @bind="EditSkillForm.AllowFumble" />
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">Save Skill</button>
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
</div>
</form>
</section>
</div>
}
<SkillFormModal
Visible="ShowEditSkillModal"
IsD6="IsSelectedCampaignD6"
Title="Edit Skill"
SubmitLabel="Save Skill"
NameInputId="skill-edit-name"
ExpressionInputId="skill-edit-expression"
WildDiceInputId="skill-edit-wild-dice"
AllowFumbleInputId="skill-edit-allow-fumble"
FormState="EditSkillState"
IsMutating="IsMutating"
SubmitRequested="UpdateSkillAsync"
CancelRequested="CloseSkillModals" />

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@attribute [ExcludeFromCodeCoverage]
<main class="auth-shell">
<h1>RpgRoller</h1>
<p class="auth-subtitle">Register or log in to join a campaign session.</p>
@if (!string.IsNullOrWhiteSpace(StatusMessage))
{
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
}
<div class="auth-grid">
<section class="card auth-card">
<h2>Register</h2>
@if (!string.IsNullOrWhiteSpace(RegisterState.ErrorMessage))
{
<p class="form-error">@RegisterState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitRegisterAsync" @onsubmit:preventDefault>
<label for="register-username">Username</label>
<input id="register-username" @bind="RegisterState.Model.Username" @bind:event="oninput" autocomplete="username" />
@if (RegisterState.Errors.TryGetValue("username", out var registerUsernameError))
{
<p class="field-error">@registerUsernameError</p>
}
<label for="register-display-name">Display name</label>
<input id="register-display-name" @bind="RegisterState.Model.DisplayName" @bind:event="oninput" autocomplete="name" />
@if (RegisterState.Errors.TryGetValue("displayName", out var registerDisplayNameError))
{
<p class="field-error">@registerDisplayNameError</p>
}
<label for="register-password">Password</label>
<input id="register-password" type="password" @bind="RegisterState.Model.Password" @bind:event="oninput" autocomplete="new-password" />
@if (RegisterState.Errors.TryGetValue("password", out var registerPasswordError))
{
<p class="field-error">@registerPasswordError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Registering..." : "Register")</button>
</form>
</section>
<section class="card auth-card">
<h2>Login</h2>
@if (!string.IsNullOrWhiteSpace(LoginState.ErrorMessage))
{
<p class="form-error">@LoginState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitLoginAsync" @onsubmit:preventDefault>
<label for="login-username">Username</label>
<input id="login-username" @bind="LoginState.Model.Username" @bind:event="oninput" autocomplete="username" />
@if (LoginState.Errors.TryGetValue("username", out var loginUsernameError))
{
<p class="field-error">@loginUsernameError</p>
}
<label for="login-password">Password</label>
<input id="login-password" type="password" @bind="LoginState.Model.Password" @bind:event="oninput" autocomplete="current-password" />
@if (LoginState.Errors.TryGetValue("password", out var loginPasswordError))
{
<p class="field-error">@loginPasswordError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Logging in..." : "Login")</button>
</form>
</section>
</div>
</main>
@code {
[Parameter]
public FormState<RegisterFormModel> RegisterState { get; set; } = new();
[Parameter]
public FormState<LoginFormModel> LoginState { get; set; } = new();
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public string? StatusMessage { get; set; }
[Parameter]
public bool StatusIsError { get; set; }
[Parameter]
public EventCallback RegisterSubmitted { get; set; }
[Parameter]
public EventCallback LoginSubmitted { get; set; }
private async Task SubmitRegisterAsync()
{
await RegisterSubmitted.InvokeAsync();
}
private async Task SubmitLoginAsync()
{
await LoginSubmitted.InvokeAsync();
}
}

View File

@@ -0,0 +1,139 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
<main class="management-screen">
<section class="card">
<h2>Campaign Selector</h2>
@if (Campaigns.Count == 0)
{
<p class="empty">No campaigns yet.</p>
}
else
{
<label for="campaign-select">Campaign</label>
<select id="campaign-select" @onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option>
}
</select>
}
<p class="muted">Current campaign in this tab: <strong>@(SelectedCampaignName ?? "None selected")</strong></p>
</section>
<section class="card">
<h2>Create Campaign</h2>
@if (!string.IsNullOrWhiteSpace(CampaignState.ErrorMessage))
{
<p class="form-error">@CampaignState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="CreateCampaignSubmitted" @onsubmit:preventDefault>
<label for="campaign-name">Campaign name</label>
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput" />
@if (CampaignState.Errors.TryGetValue("name", out var campaignNameError))
{
<p class="field-error">@campaignNameError</p>
}
<label for="campaign-ruleset">Ruleset</label>
<select id="campaign-ruleset" @bind="CampaignState.Model.RulesetId">
<option value="">Select ruleset</option>
@foreach (var ruleset in Rulesets)
{
<option value="@ruleset.Id">@ruleset.Name</option>
}
</select>
@if (CampaignState.Errors.TryGetValue("rulesetId", out var campaignRulesetError))
{
<p class="field-error">@campaignRulesetError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Creating..." : "Create Campaign")</button>
</form>
</section>
<section class="card">
<h2>Campaign Details</h2>
@if (SelectedCampaign is null)
{
<p class="empty">No campaign selected.</p>
}
else
{
<p>Name: <strong>@SelectedCampaign.Name</strong></p>
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p>
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
}
</section>
<section class="card">
<div class="section-head">
<h2>Character Management</h2>
<button type="button" disabled="@(IsMutating || SelectedCampaign is null)" @onclick="CreateCharacterRequested">Create Character</button>
</div>
@if (SelectedCampaign is null)
{
<p class="empty">Select a campaign first.</p>
}
else if (SelectedCampaign.Characters.Count == 0)
{
<p class="empty">No characters in this campaign yet.</p>
}
else
{
<ul class="management-list">
@foreach (var character in SelectedCampaign.Characters)
{
<li>
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit</button>
</div>
</li>
}
</ul>
}
</section>
</main>
@code {
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter]
public string? SelectedCampaignName { get; set; }
[Parameter]
public CampaignDetails? SelectedCampaign { get; set; }
[Parameter]
public FormState<CampaignFormModel> CampaignState { get; set; } = new();
[Parameter]
public IReadOnlyList<RulesetDefinition> Rulesets { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter]
public EventCallback CreateCampaignSubmitted { get; set; }
[Parameter]
public EventCallback CreateCharacterRequested { get; set; }
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
}

View File

@@ -0,0 +1,78 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
@if (Visible)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="@Title">
<h2>@Title</h2>
@if (!string.IsNullOrWhiteSpace(FormState.ErrorMessage))
{
<p class="form-error">@FormState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@NameInputId">Character name</label>
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput" />
@if (FormState.Errors.TryGetValue("name", out var nameError))
{
<p class="field-error">@nameError</p>
}
<label for="@CampaignInputId">Campaign</label>
<select id="@CampaignInputId" @bind="FormState.Model.CampaignId">
<option value="">Select campaign</option>
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id">@campaign.Name</option>
}
</select>
@if (FormState.Errors.TryGetValue("campaignId", out var campaignError))
{
<p class="field-error">@campaignError</p>
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">@SubmitLabel</button>
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
</div>
</form>
</section>
</div>
}
@code {
[Parameter]
public bool Visible { get; set; }
[Parameter]
public string Title { get; set; } = "Character";
[Parameter]
public string SubmitLabel { get; set; } = "Save";
[Parameter]
public string NameInputId { get; set; } = "character-name";
[Parameter]
public string CampaignInputId { get; set; } = "character-campaign";
[Parameter]
public FormState<CharacterFormModel> FormState { get; set; } = new();
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public EventCallback SubmitRequested { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
private async Task SubmitAsync()
{
await SubmitRequested.InvokeAsync();
}
}

View File

@@ -0,0 +1,88 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@attribute [ExcludeFromCodeCoverage]
@if (Visible)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="@Title">
<h2>@Title</h2>
@if (!string.IsNullOrWhiteSpace(FormState.ErrorMessage))
{
<p class="form-error">@FormState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@NameInputId">Skill name</label>
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput" />
@if (FormState.Errors.TryGetValue("name", out var skillNameError))
{
<p class="field-error">@skillNameError</p>
}
<label for="@ExpressionInputId">Expression</label>
<input id="@ExpressionInputId" @bind="FormState.Model.DiceRollDefinition" @bind:event="oninput" />
@if (FormState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
{
<p class="field-error">@expressionError</p>
}
@if (IsD6)
{
<label for="@WildDiceInputId">Wild dice</label>
<input id="@WildDiceInputId" type="number" min="1" step="1" @bind="FormState.Model.WildDice" />
@if (FormState.Errors.TryGetValue("wildDice", out var wildDiceError))
{
<p class="field-error">@wildDiceError</p>
}
<label for="@AllowFumbleInputId">Allow fumble</label>
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble" />
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">@SubmitLabel</button>
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
</div>
</form>
</section>
</div>
}
@code {
[Parameter]
public bool Visible { get; set; }
[Parameter]
public bool IsD6 { get; set; }
[Parameter]
public string Title { get; set; } = "Skill";
[Parameter]
public string SubmitLabel { get; set; } = "Save";
[Parameter]
public string NameInputId { get; set; } = "skill-name";
[Parameter]
public string ExpressionInputId { get; set; } = "skill-expression";
[Parameter]
public string WildDiceInputId { get; set; } = "skill-wild";
[Parameter]
public string AllowFumbleInputId { get; set; } = "skill-fumble";
[Parameter]
public FormState<SkillFormModel> FormState { get; set; } = new();
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public EventCallback SubmitRequested { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
private async Task SubmitAsync()
{
await SubmitRequested.InvokeAsync();
}
}