Move concern workflows from Home into concern controls
This commit is contained in:
2
FAQ.md
2
FAQ.md
@@ -74,4 +74,4 @@ There is no separate activate button in Play. The selected character in the char
|
||||
|
||||
## Why is auth form state kept in `AuthSection` instead of `Home`?
|
||||
|
||||
Auth inputs and inline validation are transient UI concerns, so they now live in `AuthSection`. `Home` keeps only session/workspace state and API workflows, and returns `FormSubmissionResult` back to controls for display.
|
||||
Auth inputs, validation, and submit workflows are transient UI concerns, so they now live in `AuthSection`. `Home` keeps shared session/workspace state and cross-control refresh/orchestration only.
|
||||
|
||||
@@ -8,6 +8,7 @@ Tracking against `UX.md` tasks and decisions.
|
||||
- Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`)
|
||||
- Legacy TypeScript frontend/runtime artifacts: removed
|
||||
- Home page orchestration split by concern (`Home.*.cs` partials + `HomeControls/*`) to reduce merge churn and keep auth/campaign/character/skill flows isolated.
|
||||
- Concern controls now own their local form state and mutation workflows; `Home` handles shared cross-control state refresh.
|
||||
|
||||
## UX Checklist
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ Frontend:
|
||||
- `RpgRoller/Components/Pages/Home.*.cs`: concern-based partial class split (`State`, `Lifecycle`, `Auth`, `Campaign`, `Character`, `Skill`, `Realtime`, `Api`, `Presentation`, `Validation`)
|
||||
- `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState<TModel>` + page form models
|
||||
- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor`
|
||||
- Form ownership model: controls own transient form/error state; `Home` receives typed submissions and returns `FormSubmissionResult` for server/business validation feedback
|
||||
- Form ownership model: controls own transient form/error state and execute their concern-specific API mutations directly
|
||||
- `RpgRoller/Components/RpgRollerApiClient.cs`: shared browser API client used by `Home` and leaf controls
|
||||
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor
|
||||
- `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens
|
||||
|
||||
|
||||
@@ -1,51 +1,20 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.JSInterop;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Components;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
private async Task<T> RequestAsync<T>(string method, string path, object? payload = null)
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = default!;
|
||||
|
||||
private 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)!;
|
||||
return ApiClient.RequestAsync<T>(method, path, payload);
|
||||
}
|
||||
|
||||
private async Task RequestWithoutPayloadAsync(string method, string path)
|
||||
private 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; }
|
||||
return ApiClient.RequestWithoutPayloadAsync(method, path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,19 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Components;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public partial class Home
|
||||
{
|
||||
private async Task<FormSubmissionResult> RegisterAsync(RegisterFormModel model)
|
||||
private async Task OnLoggedInAsync()
|
||||
{
|
||||
var validationErrors = new Dictionary<string, string>();
|
||||
|
||||
AddRequiredError(validationErrors, "username", model.Username, "Username is required.");
|
||||
AddRequiredError(validationErrors, "displayName", model.DisplayName, "Display name is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8)
|
||||
{
|
||||
validationErrors["password"] = "Password must be at least 8 characters.";
|
||||
}
|
||||
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
return new FormSubmissionResult
|
||||
{
|
||||
ErrorMessage = "Resolve validation issues before submitting.",
|
||||
Errors = validationErrors
|
||||
};
|
||||
}
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
_ = await RequestAsync<UserSummary>(
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
|
||||
|
||||
SetStatus("Registration successful. You can log in now.", false);
|
||||
return new FormSubmissionResult();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new FormSubmissionResult
|
||||
{
|
||||
Errors = new Dictionary<string, string>
|
||||
{
|
||||
["username"] = "Username is already taken. Choose another one."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FormSubmissionResult> LoginAsync(LoginFormModel model)
|
||||
{
|
||||
var validationErrors = new Dictionary<string, string>();
|
||||
|
||||
AddRequiredError(validationErrors, "username", model.Username, "Username is required.");
|
||||
AddRequiredError(validationErrors, "password", model.Password, "Password is required.");
|
||||
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
return new FormSubmissionResult
|
||||
{
|
||||
ErrorMessage = "Resolve validation issues before submitting.",
|
||||
Errors = validationErrors
|
||||
};
|
||||
}
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
_ = await RequestAsync<UserSummary>(
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
new LoginRequest(model.Username.Trim(), model.Password));
|
||||
|
||||
await ReloadAuthenticatedSessionAsync(null);
|
||||
SetStatus("Logged in.", false);
|
||||
return new FormSubmissionResult();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
@@ -51,43 +50,11 @@ public partial class Home
|
||||
await SyncStateEventsAsync();
|
||||
}
|
||||
|
||||
private async Task<FormSubmissionResult> CreateCampaignAsync(CampaignFormModel model)
|
||||
private async Task OnCampaignCreatedAsync(Guid campaignId)
|
||||
{
|
||||
var validationErrors = new Dictionary<string, string>();
|
||||
|
||||
AddRequiredError(validationErrors, "name", model.Name, "Campaign name is required.");
|
||||
AddRequiredError(validationErrors, "rulesetId", model.RulesetId, "Ruleset is required.");
|
||||
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
return new FormSubmissionResult
|
||||
{
|
||||
ErrorMessage = "Resolve validation issues before submitting.",
|
||||
Errors = validationErrors
|
||||
};
|
||||
}
|
||||
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
var campaign = await RequestAsync<CampaignSummary>(
|
||||
"POST",
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest(model.Name.Trim(), model.RulesetId));
|
||||
|
||||
await ReloadCampaignsAsync(campaign.Id);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
SetStatus("Campaign created.", false);
|
||||
return new FormSubmissionResult();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
await ReloadCampaignsAsync(campaignId);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
SetStatus("Campaign created.", false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,100 +35,22 @@ public partial class Home
|
||||
EditingCharacterId = null;
|
||||
}
|
||||
|
||||
private async Task<FormSubmissionResult> CreateCharacterAsync(CharacterFormModel model)
|
||||
private async Task OnCharacterCreatedAsync(Guid campaignId)
|
||||
{
|
||||
var validationErrors = new Dictionary<string, string>();
|
||||
|
||||
AddRequiredError(validationErrors, "name", model.Name, "Character name is required.");
|
||||
var hasCampaignId = TryParseGuid(
|
||||
model.CampaignId,
|
||||
validationErrors,
|
||||
"campaignId",
|
||||
"Campaign is required.",
|
||||
out var campaignId);
|
||||
|
||||
if (validationErrors.Count > 0 || !hasCampaignId)
|
||||
{
|
||||
return new FormSubmissionResult
|
||||
{
|
||||
ErrorMessage = "Resolve validation issues before submitting.",
|
||||
Errors = validationErrors
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
return new FormSubmissionResult();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
CloseCharacterModals();
|
||||
await ReloadCampaignsAsync(campaignId);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
SetStatus("Character created.", false);
|
||||
}
|
||||
|
||||
private async Task<FormSubmissionResult> UpdateCharacterAsync(CharacterFormModel model)
|
||||
private async Task OnCharacterUpdatedAsync(Guid campaignId)
|
||||
{
|
||||
if (!EditingCharacterId.HasValue)
|
||||
{
|
||||
return new FormSubmissionResult { ErrorMessage = "No character selected." };
|
||||
}
|
||||
|
||||
var validationErrors = new Dictionary<string, string>();
|
||||
AddRequiredError(validationErrors, "name", model.Name, "Character name is required.");
|
||||
var hasCampaignId = TryParseGuid(
|
||||
model.CampaignId,
|
||||
validationErrors,
|
||||
"campaignId",
|
||||
"Campaign is required.",
|
||||
out var campaignId);
|
||||
|
||||
if (validationErrors.Count > 0 || !hasCampaignId)
|
||||
{
|
||||
return new FormSubmissionResult
|
||||
{
|
||||
ErrorMessage = "Resolve validation issues before submitting.",
|
||||
Errors = validationErrors
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
return new FormSubmissionResult();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
CloseCharacterModals();
|
||||
await ReloadCampaignsAsync(campaignId);
|
||||
await RefreshCampaignScopeAsync();
|
||||
await SyncStateEventsAsync();
|
||||
SetStatus("Character updated.", false);
|
||||
}
|
||||
|
||||
private async Task SelectCharacterAsync(Guid characterId)
|
||||
|
||||
@@ -14,13 +14,6 @@ public sealed class FormState<TModel>
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FormSubmissionResult
|
||||
{
|
||||
public Dictionary<string, string> Errors { get; init; } = [];
|
||||
public string? ErrorMessage { get; init; }
|
||||
public bool IsSuccess => Errors.Count == 0 && string.IsNullOrWhiteSpace(ErrorMessage);
|
||||
}
|
||||
|
||||
public sealed class RegisterFormModel
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
@@ -44,101 +44,19 @@ public partial class Home
|
||||
EditingSkillId = null;
|
||||
}
|
||||
|
||||
private async Task<FormSubmissionResult> CreateSkillAsync(SkillFormModel model)
|
||||
private async Task OnSkillCreatedAsync(Guid _)
|
||||
{
|
||||
if (SelectedCharacter is null)
|
||||
{
|
||||
return new FormSubmissionResult { ErrorMessage = "Select a character first." };
|
||||
}
|
||||
|
||||
var validationErrors = new Dictionary<string, string>();
|
||||
AddRequiredError(validationErrors, "name", model.Name, "Skill name is required.");
|
||||
AddRequiredError(validationErrors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required.");
|
||||
|
||||
if (IsSelectedCampaignD6 && model.WildDice < 1)
|
||||
{
|
||||
validationErrors["wildDice"] = "D6 skills require at least one wild die.";
|
||||
}
|
||||
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
return new FormSubmissionResult
|
||||
{
|
||||
ErrorMessage = "Resolve validation issues before submitting.",
|
||||
Errors = validationErrors
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
return new FormSubmissionResult();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
CloseSkillModals();
|
||||
await RefreshCampaignScopeAsync();
|
||||
SetStatus("Skill created.", false);
|
||||
}
|
||||
|
||||
private async Task<FormSubmissionResult> UpdateSkillAsync(SkillFormModel model)
|
||||
private async Task OnSkillUpdatedAsync(Guid skillId)
|
||||
{
|
||||
if (!EditingSkillId.HasValue)
|
||||
{
|
||||
return new FormSubmissionResult { ErrorMessage = "No skill selected." };
|
||||
}
|
||||
|
||||
var validationErrors = new Dictionary<string, string>();
|
||||
AddRequiredError(validationErrors, "name", model.Name, "Skill name is required.");
|
||||
AddRequiredError(validationErrors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required.");
|
||||
|
||||
if (IsSelectedCampaignD6 && model.WildDice < 1)
|
||||
{
|
||||
validationErrors["wildDice"] = "D6 skills require at least one wild die.";
|
||||
}
|
||||
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
return new FormSubmissionResult
|
||||
{
|
||||
ErrorMessage = "Resolve validation issues before submitting.",
|
||||
Errors = validationErrors
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
return new FormSubmissionResult();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
return new FormSubmissionResult { ErrorMessage = ex.Message };
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
SelectedSkillId = skillId;
|
||||
CloseSkillModals();
|
||||
await RefreshCampaignScopeAsync();
|
||||
SetStatus("Skill updated.", false);
|
||||
}
|
||||
|
||||
private async Task RollSelectedSkillAsync()
|
||||
|
||||
@@ -2,7 +2,6 @@ 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;
|
||||
@@ -15,7 +14,6 @@ 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 UserSummary? User { get; set; }
|
||||
private Guid? ActiveCharacterId { get; set; }
|
||||
|
||||
@@ -2,22 +2,4 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,9 @@
|
||||
|
||||
case HomeViewMode.Anonymous:
|
||||
<AuthSection
|
||||
IsMutating="IsMutating"
|
||||
StatusMessage="StatusMessage"
|
||||
StatusIsError="StatusIsError"
|
||||
RegisterSubmitted="RegisterAsync"
|
||||
LoginSubmitted="LoginAsync" />
|
||||
LoggedIn="OnLoggedInAsync" />
|
||||
break;
|
||||
|
||||
case HomeViewMode.Workspace:
|
||||
@@ -119,7 +117,7 @@
|
||||
OwnerLabel="OwnerLabel"
|
||||
CanEditCharacter="CanEditCharacter"
|
||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
||||
CreateCampaignSubmitted="CreateCampaignAsync"
|
||||
CampaignCreated="OnCampaignCreatedAsync"
|
||||
CreateCharacterRequested="OpenCreateCharacterModal"
|
||||
EditCharacterRequested="OpenEditCharacterModal" />
|
||||
}
|
||||
@@ -136,9 +134,10 @@
|
||||
CampaignInputId="character-create-campaign"
|
||||
InitialModel="CreateCharacterInitialModel"
|
||||
FormVersion="CreateCharacterFormVersion"
|
||||
EditingCharacterId="null"
|
||||
Campaigns="Campaigns"
|
||||
IsMutating="IsMutating"
|
||||
SubmitRequested="CreateCharacterAsync"
|
||||
CharacterSaved="OnCharacterCreatedAsync"
|
||||
CancelRequested="CloseCharacterModals" />
|
||||
|
||||
<CharacterFormModal
|
||||
@@ -149,9 +148,10 @@
|
||||
CampaignInputId="character-edit-campaign"
|
||||
InitialModel="EditCharacterInitialModel"
|
||||
FormVersion="EditCharacterFormVersion"
|
||||
EditingCharacterId="EditingCharacterId"
|
||||
Campaigns="Campaigns"
|
||||
IsMutating="IsMutating"
|
||||
SubmitRequested="UpdateCharacterAsync"
|
||||
CharacterSaved="OnCharacterUpdatedAsync"
|
||||
CancelRequested="CloseCharacterModals" />
|
||||
|
||||
<SkillFormModal
|
||||
@@ -165,8 +165,10 @@
|
||||
AllowFumbleInputId="skill-create-allow-fumble"
|
||||
InitialModel="CreateSkillInitialModel"
|
||||
FormVersion="CreateSkillFormVersion"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
EditingSkillId="null"
|
||||
IsMutating="IsMutating"
|
||||
SubmitRequested="CreateSkillAsync"
|
||||
SkillSaved="OnSkillCreatedAsync"
|
||||
CancelRequested="CloseSkillModals" />
|
||||
|
||||
<SkillFormModal
|
||||
@@ -180,6 +182,8 @@
|
||||
AllowFumbleInputId="skill-edit-allow-fumble"
|
||||
InitialModel="EditSkillInitialModel"
|
||||
FormVersion="EditSkillFormVersion"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
EditingSkillId="EditingSkillId"
|
||||
IsMutating="IsMutating"
|
||||
SubmitRequested="UpdateSkillAsync"
|
||||
SkillSaved="OnSkillUpdatedAsync"
|
||||
CancelRequested="CloseSkillModals" />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using RpgRoller.Components
|
||||
@using RpgRoller.Components.Pages
|
||||
@using RpgRoller.Contracts
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
@inject RpgRollerApiClient ApiClient
|
||||
|
||||
<main class="auth-shell">
|
||||
<h1>RpgRoller</h1>
|
||||
@@ -35,7 +38,7 @@
|
||||
{
|
||||
<p class="field-error">@registerPasswordError</p>
|
||||
}
|
||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Registering..." : "Register")</button>
|
||||
<button type="submit" disabled="@IsSubmitting">@(IsSubmitting ? "Registering..." : "Register")</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -58,7 +61,7 @@
|
||||
{
|
||||
<p class="field-error">@loginPasswordError</p>
|
||||
}
|
||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Logging in..." : "Login")</button>
|
||||
<button type="submit" disabled="@IsSubmitting">@(IsSubmitting ? "Logging in..." : "Login")</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@@ -67,9 +70,7 @@
|
||||
@code {
|
||||
private FormState<RegisterFormModel> RegisterState { get; } = new();
|
||||
private FormState<LoginFormModel> LoginState { get; } = new();
|
||||
|
||||
[Parameter]
|
||||
public bool IsMutating { get; set; }
|
||||
private bool IsSubmitting { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? StatusMessage { get; set; }
|
||||
@@ -78,26 +79,59 @@
|
||||
public bool StatusIsError { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<RegisterFormModel, Task<FormSubmissionResult>> RegisterSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
|
||||
|
||||
[Parameter]
|
||||
public Func<LoginFormModel, Task<FormSubmissionResult>> LoginSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
|
||||
public EventCallback LoggedIn { get; set; }
|
||||
|
||||
private async Task SubmitRegisterAsync()
|
||||
{
|
||||
RegisterState.ResetValidation();
|
||||
|
||||
var result = await RegisterSubmitted.Invoke(new RegisterFormModel
|
||||
var model = RegisterState.Model;
|
||||
if (string.IsNullOrWhiteSpace(model.Username))
|
||||
{
|
||||
Username = RegisterState.Model.Username,
|
||||
DisplayName = RegisterState.Model.DisplayName,
|
||||
Password = RegisterState.Model.Password
|
||||
});
|
||||
RegisterState.Errors["username"] = "Username is required.";
|
||||
}
|
||||
|
||||
ApplyResult(RegisterState, result);
|
||||
if (result.IsSuccess)
|
||||
if (string.IsNullOrWhiteSpace(model.DisplayName))
|
||||
{
|
||||
RegisterState.Model.Password = string.Empty;
|
||||
RegisterState.Errors["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;
|
||||
}
|
||||
|
||||
IsSubmitting = true;
|
||||
try
|
||||
{
|
||||
_ = await ApiClient.RequestAsync<UserSummary>(
|
||||
"POST",
|
||||
"/api/auth/register",
|
||||
new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
|
||||
|
||||
model.Password = string.Empty;
|
||||
RegisterState.ErrorMessage = "Registration successful. You can log in now.";
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
RegisterState.Errors["username"] = "Username is already taken. Choose another one.";
|
||||
}
|
||||
else
|
||||
{
|
||||
RegisterState.ErrorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,28 +139,41 @@
|
||||
{
|
||||
LoginState.ResetValidation();
|
||||
|
||||
var result = await LoginSubmitted.Invoke(new LoginFormModel
|
||||
var model = LoginState.Model;
|
||||
if (string.IsNullOrWhiteSpace(model.Username))
|
||||
{
|
||||
Username = LoginState.Model.Username,
|
||||
Password = LoginState.Model.Password
|
||||
});
|
||||
|
||||
ApplyResult(LoginState, result);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
LoginState.Model.Password = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyResult<TModel>(FormState<TModel> state, FormSubmissionResult result)
|
||||
where TModel : new()
|
||||
{
|
||||
state.Errors.Clear();
|
||||
foreach (var (key, value) in result.Errors)
|
||||
{
|
||||
state.Errors[key] = value;
|
||||
LoginState.Errors["username"] = "Username is required.";
|
||||
}
|
||||
|
||||
state.ErrorMessage = result.ErrorMessage;
|
||||
if (string.IsNullOrWhiteSpace(model.Password))
|
||||
{
|
||||
LoginState.Errors["password"] = "Password is required.";
|
||||
}
|
||||
|
||||
if (LoginState.Errors.Count > 0)
|
||||
{
|
||||
LoginState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmitting = true;
|
||||
try
|
||||
{
|
||||
_ = await ApiClient.RequestAsync<UserSummary>(
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
new LoginRequest(model.Username.Trim(), model.Password));
|
||||
|
||||
model.Password = string.Empty;
|
||||
await LoggedIn.InvokeAsync();
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
LoginState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using RpgRoller.Components
|
||||
@using RpgRoller.Components.Pages
|
||||
@using RpgRoller.Contracts
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
@inject RpgRollerApiClient ApiClient
|
||||
|
||||
<main class="management-screen">
|
||||
<section class="card">
|
||||
@@ -48,7 +50,7 @@
|
||||
{
|
||||
<p class="field-error">@campaignRulesetError</p>
|
||||
}
|
||||
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Creating..." : "Create Campaign")</button>
|
||||
<button type="submit" disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -70,7 +72,7 @@
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>Character Management</h2>
|
||||
<button type="button" disabled="@(IsMutating || SelectedCampaign is null)" @onclick="CreateCharacterRequested">Create Character</button>
|
||||
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || SelectedCampaign is null)" @onclick="CreateCharacterRequested">Create Character</button>
|
||||
</div>
|
||||
@if (SelectedCampaign is null)
|
||||
{
|
||||
@@ -88,7 +90,7 @@
|
||||
<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>
|
||||
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))" @onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@@ -99,6 +101,7 @@
|
||||
|
||||
@code {
|
||||
private FormState<CampaignFormModel> CampaignState { get; } = new();
|
||||
private bool IsCreatingCampaign { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
||||
@@ -128,7 +131,7 @@
|
||||
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<CampaignFormModel, Task<FormSubmissionResult>> CreateCampaignSubmitted { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
|
||||
public EventCallback<Guid> CampaignCreated { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CreateCharacterRequested { get; set; }
|
||||
@@ -148,22 +151,40 @@
|
||||
{
|
||||
CampaignState.ResetValidation();
|
||||
|
||||
var result = await CreateCampaignSubmitted.Invoke(new CampaignFormModel
|
||||
if (string.IsNullOrWhiteSpace(CampaignState.Model.Name))
|
||||
{
|
||||
Name = CampaignState.Model.Name,
|
||||
RulesetId = CampaignState.Model.RulesetId
|
||||
});
|
||||
|
||||
CampaignState.Errors.Clear();
|
||||
foreach (var (key, value) in result.Errors)
|
||||
{
|
||||
CampaignState.Errors[key] = value;
|
||||
CampaignState.Errors["name"] = "Campaign name is required.";
|
||||
}
|
||||
|
||||
CampaignState.ErrorMessage = result.ErrorMessage;
|
||||
if (result.IsSuccess)
|
||||
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId))
|
||||
{
|
||||
CampaignState.Errors["rulesetId"] = "Ruleset is required.";
|
||||
}
|
||||
|
||||
if (CampaignState.Errors.Count > 0)
|
||||
{
|
||||
CampaignState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsCreatingCampaign = true;
|
||||
try
|
||||
{
|
||||
var campaign = await ApiClient.RequestAsync<CampaignSummary>(
|
||||
"POST",
|
||||
"/api/campaigns",
|
||||
new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
|
||||
|
||||
CampaignState.Model.Name = string.Empty;
|
||||
await CampaignCreated.InvokeAsync(campaign.Id);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
CampaignState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsCreatingCampaign = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using RpgRoller.Components
|
||||
@using RpgRoller.Components.Pages
|
||||
@using RpgRoller.Contracts
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
@inject RpgRollerApiClient ApiClient
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
@@ -32,7 +34,7 @@
|
||||
<p class="field-error">@campaignError</p>
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@IsMutating">@SubmitLabel</button>
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
|
||||
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -43,6 +45,7 @@
|
||||
@code {
|
||||
private FormState<CharacterFormModel> FormState { get; } = new();
|
||||
private int AppliedFormVersion { get; set; } = -1;
|
||||
private bool IsSubmitting { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool Visible { get; set; }
|
||||
@@ -65,6 +68,9 @@
|
||||
[Parameter]
|
||||
public int FormVersion { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Guid? EditingCharacterId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
||||
|
||||
@@ -72,7 +78,7 @@
|
||||
public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<CharacterFormModel, Task<FormSubmissionResult>> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
|
||||
public EventCallback<Guid> CharacterSaved { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CancelRequested { get; set; }
|
||||
@@ -94,17 +100,50 @@
|
||||
{
|
||||
FormState.ResetValidation();
|
||||
|
||||
var result = await SubmitRequested.Invoke(new CharacterFormModel
|
||||
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
|
||||
{
|
||||
Name = FormState.Model.Name,
|
||||
CampaignId = FormState.Model.CampaignId
|
||||
});
|
||||
|
||||
foreach (var (key, value) in result.Errors)
|
||||
{
|
||||
FormState.Errors[key] = value;
|
||||
FormState.Errors["name"] = "Character name is required.";
|
||||
}
|
||||
|
||||
FormState.ErrorMessage = result.ErrorMessage;
|
||||
if (!Guid.TryParse(FormState.Model.CampaignId, out var campaignId))
|
||||
{
|
||||
FormState.Errors["campaignId"] = "Campaign is required.";
|
||||
}
|
||||
|
||||
if (FormState.Errors.Count > 0)
|
||||
{
|
||||
FormState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmitting = true;
|
||||
try
|
||||
{
|
||||
CharacterSummary character;
|
||||
if (EditingCharacterId.HasValue)
|
||||
{
|
||||
character = await ApiClient.RequestAsync<CharacterSummary>(
|
||||
"PUT",
|
||||
$"/api/characters/{EditingCharacterId.Value}",
|
||||
new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
|
||||
}
|
||||
else
|
||||
{
|
||||
character = await ApiClient.RequestAsync<CharacterSummary>(
|
||||
"POST",
|
||||
"/api/characters",
|
||||
new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
|
||||
}
|
||||
|
||||
await CharacterSaved.InvokeAsync(character.CampaignId);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
FormState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using RpgRoller.Components
|
||||
@using RpgRoller.Components.Pages
|
||||
@using RpgRoller.Contracts
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
@inject RpgRollerApiClient ApiClient
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
@@ -36,7 +39,7 @@
|
||||
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble" />
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@IsMutating">@SubmitLabel</button>
|
||||
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>
|
||||
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -47,6 +50,7 @@
|
||||
@code {
|
||||
private FormState<SkillFormModel> FormState { get; } = new();
|
||||
private int AppliedFormVersion { get; set; } = -1;
|
||||
private bool IsSubmitting { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool Visible { get; set; }
|
||||
@@ -78,11 +82,17 @@
|
||||
[Parameter]
|
||||
public int FormVersion { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Guid? SelectedCharacterId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Guid? EditingSkillId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<SkillFormModel, Task<FormSubmissionResult>> SubmitRequested { get; set; } = _ => Task.FromResult(new FormSubmissionResult());
|
||||
public EventCallback<Guid> SkillSaved { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CancelRequested { get; set; }
|
||||
@@ -106,19 +116,69 @@
|
||||
{
|
||||
FormState.ResetValidation();
|
||||
|
||||
var result = await SubmitRequested.Invoke(new SkillFormModel
|
||||
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
|
||||
{
|
||||
Name = FormState.Model.Name,
|
||||
DiceRollDefinition = FormState.Model.DiceRollDefinition,
|
||||
WildDice = FormState.Model.WildDice,
|
||||
AllowFumble = FormState.Model.AllowFumble
|
||||
});
|
||||
|
||||
foreach (var (key, value) in result.Errors)
|
||||
{
|
||||
FormState.Errors[key] = value;
|
||||
FormState.Errors["name"] = "Skill name is required.";
|
||||
}
|
||||
|
||||
FormState.ErrorMessage = result.ErrorMessage;
|
||||
if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition))
|
||||
{
|
||||
FormState.Errors["diceRollDefinition"] = "Expression is required.";
|
||||
}
|
||||
|
||||
if (IsD6 && FormState.Model.WildDice < 1)
|
||||
{
|
||||
FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
|
||||
}
|
||||
|
||||
if (FormState.Errors.Count > 0)
|
||||
{
|
||||
FormState.ErrorMessage = "Resolve validation issues before submitting.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmitting = true;
|
||||
try
|
||||
{
|
||||
SkillSummary skill;
|
||||
if (EditingSkillId.HasValue)
|
||||
{
|
||||
skill = await ApiClient.RequestAsync<SkillSummary>(
|
||||
"PUT",
|
||||
$"/api/skills/{EditingSkillId.Value}",
|
||||
new UpdateSkillRequest(
|
||||
FormState.Model.Name.Trim(),
|
||||
FormState.Model.DiceRollDefinition.Trim(),
|
||||
FormState.Model.WildDice,
|
||||
FormState.Model.AllowFumble));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!SelectedCharacterId.HasValue)
|
||||
{
|
||||
FormState.ErrorMessage = "Select a character first.";
|
||||
return;
|
||||
}
|
||||
|
||||
skill = await ApiClient.RequestAsync<SkillSummary>(
|
||||
"POST",
|
||||
$"/api/characters/{SelectedCharacterId.Value}/skills",
|
||||
new CreateSkillRequest(
|
||||
FormState.Model.Name.Trim(),
|
||||
FormState.Model.DiceRollDefinition.Trim(),
|
||||
FormState.Model.WildDice,
|
||||
FormState.Model.AllowFumble));
|
||||
}
|
||||
|
||||
await SkillSaved.InvokeAsync(skill.Id);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
FormState.ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59
RpgRoller/Components/RpgRollerApiClient.cs
Normal file
59
RpgRoller/Components/RpgRollerApiClient.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace RpgRoller.Components;
|
||||
|
||||
public sealed class RpgRollerApiClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly IJSRuntime m_Js;
|
||||
|
||||
public RpgRollerApiClient(IJSRuntime js)
|
||||
{
|
||||
m_Js = js;
|
||||
}
|
||||
|
||||
public async Task<T> RequestAsync<T>(string method, string path, object? payload = null)
|
||||
{
|
||||
var response = await m_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)!;
|
||||
}
|
||||
|
||||
public async Task RequestWithoutPayloadAsync(string method, string path)
|
||||
{
|
||||
var response = await m_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; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ApiRequestException : Exception
|
||||
{
|
||||
public ApiRequestException(int statusCode, string message)
|
||||
: base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public int StatusCode { get; }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
builder.Services.AddScoped<RpgRoller.Components.RpgRollerApiClient>();
|
||||
|
||||
var app = builder.Build();
|
||||
app.InitializeRpgRollerState();
|
||||
|
||||
3
TECH.md
3
TECH.md
@@ -94,7 +94,8 @@ This pattern is a strong baseline for low to medium scale and should be the defa
|
||||
- Blazor component tree rooted in `Components/App.razor` and `Components/Pages/Home.razor`.
|
||||
- Home page logic split by concern with partials (`Home.State/Auth/Campaign/Character/Skill/Lifecycle/Realtime/Api/Presentation/Validation.cs`) to keep churn localized.
|
||||
- Form UX state uses reusable `FormState<TModel>` containers in leaf controls (`HomeControls/*`) rather than parallel form/error/message property sets in `Home`.
|
||||
- `Home` workflows return `FormSubmissionResult` so controls can render field and summary errors without parent-owned form wiring.
|
||||
- Concern controls execute their own auth/campaign/character/skill mutation workflows and notify `Home` only for shared-state refresh/orchestration.
|
||||
- Shared browser API interop is centralized in `RpgRollerApiClient` and reused by `Home` plus concern controls.
|
||||
- Browser API calls and SSE are handled via `wwwroot/js/rpgroller-api.js` interop.
|
||||
- UI state is maintained server-side per circuit with session/tab persistence for campaign + screen selection.
|
||||
- SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback.
|
||||
|
||||
Reference in New Issue
Block a user