Refactor Home page into concern-based components and partials
This commit is contained in:
51
RpgRoller/Components/Pages/Home.Api.cs
Normal file
51
RpgRoller/Components/Pages/Home.Api.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
113
RpgRoller/Components/Pages/Home.Auth.cs
Normal file
113
RpgRoller/Components/Pages/Home.Auth.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
91
RpgRoller/Components/Pages/Home.Campaign.cs
Normal file
91
RpgRoller/Components/Pages/Home.Campaign.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
167
RpgRoller/Components/Pages/Home.Character.cs
Normal file
167
RpgRoller/Components/Pages/Home.Character.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
206
RpgRoller/Components/Pages/Home.Lifecycle.cs
Normal file
206
RpgRoller/Components/Pages/Home.Lifecycle.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
55
RpgRoller/Components/Pages/Home.Models.cs
Normal file
55
RpgRoller/Components/Pages/Home.Models.cs
Normal 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
|
||||
}
|
||||
171
RpgRoller/Components/Pages/Home.Presentation.cs
Normal file
171
RpgRoller/Components/Pages/Home.Presentation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
93
RpgRoller/Components/Pages/Home.Realtime.cs
Normal file
93
RpgRoller/Components/Pages/Home.Realtime.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
196
RpgRoller/Components/Pages/Home.Skill.cs
Normal file
196
RpgRoller/Components/Pages/Home.Skill.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
116
RpgRoller/Components/Pages/Home.State.cs
Normal file
116
RpgRoller/Components/Pages/Home.State.cs
Normal 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";
|
||||
}
|
||||
23
RpgRoller/Components/Pages/Home.Validation.cs
Normal file
23
RpgRoller/Components/Pages/Home.Validation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
98
RpgRoller/Components/Pages/HomeControls/AuthSection.razor
Normal file
98
RpgRoller/Components/Pages/HomeControls/AuthSection.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
88
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor
Normal file
88
RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user