Stage workspace controls after bootstrap
This commit is contained in:
@@ -1,218 +1,74 @@
|
|||||||
using Microsoft.AspNetCore.Http;
|
using System.Text.Json;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
using RpgRoller.Components;
|
using RpgRoller.Components;
|
||||||
|
|
||||||
namespace RpgRoller.Tests;
|
namespace RpgRoller.Tests;
|
||||||
|
|
||||||
public sealed class WorkspaceQueryServiceTests
|
public sealed class WorkspaceQueryServiceTests
|
||||||
{
|
{
|
||||||
private sealed class StubGameService : IGameService
|
private sealed class StubJsRuntime(Func<string, object?[]?, Type, object?> handler) : IJSRuntime
|
||||||
{
|
{
|
||||||
public IReadOnlyList<RulesetDefinition> GetRulesets()
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||||
{
|
{
|
||||||
throw new NotSupportedException();
|
return ValueTask.FromResult((TValue)handler(identifier, args, typeof(TValue))!);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<UserSummary> Register(string username, string password, string displayName)
|
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken,
|
||||||
|
object?[]? args)
|
||||||
{
|
{
|
||||||
throw new NotSupportedException();
|
return InvokeAsync<TValue>(identifier, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Logout(string sessionToken)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public UserSummary? GetUserBySession(string sessionToken)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<MeResponse> GetMe(string sessionToken)
|
|
||||||
{
|
|
||||||
return GetMeHandler(sessionToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
|
|
||||||
{
|
|
||||||
return GetCampaignsHandler(sessionToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid? campaignId, string? ownerUsername = null)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<SkillGroupSummary> UpdateSkillGroup(string sessionToken, Guid skillGroupId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble, Guid? skillGroupId = null, int? fumbleRange = null, bool rolemasterAutoRetry = false)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Func<string, ServiceResult<MeResponse>> GetMeHandler { get; init; } = _ => ServiceResult<MeResponse>.Failure("unexpected_call", "Unexpected GetMe call.");
|
|
||||||
|
|
||||||
public Func<string, ServiceResult<IReadOnlyList<CampaignSummary>>> GetCampaignsHandler { get; init; } = _ => ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unexpected_call", "Unexpected GetCampaigns call.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SessionTokenAccessor_ReadsSessionCookieFromHttpContext()
|
public async Task GetCampaignsAsync_UsesCampaignsApiEndpoint()
|
||||||
{
|
{
|
||||||
var httpContext = new DefaultHttpContext();
|
var queryService = new WorkspaceQueryService(new RpgRollerApiClient(
|
||||||
httpContext.Request.Headers.Cookie = "rpgroller_session=session-token";
|
new StubJsRuntime((identifier, args, returnType) =>
|
||||||
|
|
||||||
var accessor = new HttpContextAccessor { HttpContext = httpContext };
|
|
||||||
var sessionTokenAccessor = new WorkspaceSessionTokenAccessor(accessor);
|
|
||||||
|
|
||||||
Assert.Equal("session-token", sessionTokenAccessor.GetRequiredSessionToken());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetCampaignsAsync_UsesCapturedSessionToken()
|
|
||||||
{
|
|
||||||
var service = new StubGameService
|
|
||||||
{
|
|
||||||
GetCampaignsHandler = sessionToken =>
|
|
||||||
{
|
{
|
||||||
Assert.Equal("server-session", sessionToken);
|
Assert.Equal("rpgRollerApi.request", identifier);
|
||||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success([new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), 1)]);
|
Assert.Equal("GET", args![0]);
|
||||||
}
|
Assert.Equal("/api/campaigns", args[1]);
|
||||||
};
|
Assert.Null(args[2]);
|
||||||
|
|
||||||
|
return CreateJsApiResponse(args: new
|
||||||
|
{
|
||||||
|
ok = true,
|
||||||
|
status = 200,
|
||||||
|
data = new[]
|
||||||
|
{
|
||||||
|
new CampaignSummary(Guid.NewGuid(), "Alpha", "d6", new CampaignGmSummary(Guid.NewGuid(), "GM"),
|
||||||
|
1)
|
||||||
|
}
|
||||||
|
}, returnType);
|
||||||
|
})));
|
||||||
|
|
||||||
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("server-session"));
|
|
||||||
var campaigns = await queryService.GetCampaignsAsync();
|
var campaigns = await queryService.GetCampaignsAsync();
|
||||||
|
|
||||||
Assert.Single(campaigns);
|
var campaign = Assert.Single(campaigns);
|
||||||
|
Assert.Equal("Alpha", campaign.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetMeAsync_MapsUnauthorizedServiceResultToApiRequestException()
|
public async Task GetMeAsync_MapsUnauthorizedApiResponseToApiRequestException()
|
||||||
{
|
{
|
||||||
var service = new StubGameService { GetMeHandler = _ => ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.") };
|
var queryService = new WorkspaceQueryService(new RpgRollerApiClient(
|
||||||
|
new StubJsRuntime((identifier, args, returnType) =>
|
||||||
|
{
|
||||||
|
Assert.Equal("rpgRollerApi.request", identifier);
|
||||||
|
Assert.Equal("GET", args![0]);
|
||||||
|
Assert.Equal("/api/me", args[1]);
|
||||||
|
|
||||||
|
return CreateJsApiResponse(args: new
|
||||||
|
{
|
||||||
|
ok = false,
|
||||||
|
status = 401,
|
||||||
|
error = "You must be logged in.",
|
||||||
|
code = "unauthorized",
|
||||||
|
data = (object?)null
|
||||||
|
}, returnType);
|
||||||
|
})));
|
||||||
|
|
||||||
var queryService = new WorkspaceQueryService(service, CreateSessionTokenAccessor("expired-session"));
|
|
||||||
var exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync);
|
var exception = await Assert.ThrowsAsync<ApiRequestException>(queryService.GetMeAsync);
|
||||||
|
|
||||||
Assert.Equal(401, exception.StatusCode);
|
Assert.Equal(401, exception.StatusCode);
|
||||||
@@ -220,10 +76,11 @@ public sealed class WorkspaceQueryServiceTests
|
|||||||
Assert.Equal("unauthorized", exception.ErrorCode);
|
Assert.Equal("unauthorized", exception.ErrorCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WorkspaceSessionTokenAccessor CreateSessionTokenAccessor(string sessionToken)
|
private static object CreateJsApiResponse(object args, Type returnType)
|
||||||
{
|
{
|
||||||
var httpContext = new DefaultHttpContext();
|
var json = JsonSerializer.Serialize(args);
|
||||||
httpContext.Request.Headers.Cookie = $"rpgroller_session={sessionToken}";
|
return JsonSerializer.Deserialize(json, returnType, JsonOptions)!;
|
||||||
return new(new HttpContextAccessor { HttpContext = httpContext });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
}
|
}
|
||||||
@@ -82,30 +82,43 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="custom-roll-panel" aria-label="Custom roll panel">
|
<section class="custom-roll-panel" aria-label="Custom roll panel">
|
||||||
<form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault>
|
@if (EnableCustomRollComposer)
|
||||||
<div class="custom-roll-composer-head">
|
{
|
||||||
<label for="custom-roll-expression" class="custom-roll-label">Custom roll</label>
|
<form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault>
|
||||||
<span class="muted">@CustomRollStatusText</span>
|
<div class="custom-roll-composer-head">
|
||||||
|
<label for="custom-roll-expression" class="custom-roll-label">Custom roll</label>
|
||||||
|
<span class="muted">@CustomRollStatusText</span>
|
||||||
|
</div>
|
||||||
|
<div class="custom-roll-composer-row">
|
||||||
|
<input id="custom-roll-expression"
|
||||||
|
@key="CustomRollInputVersion"
|
||||||
|
@ref="CustomRollInputRef"
|
||||||
|
class="@CustomRollInputCssClass"
|
||||||
|
@bind="CustomRollExpression"
|
||||||
|
@bind:event="oninput"
|
||||||
|
placeholder="@CustomRollPlaceholder"
|
||||||
|
title="@CustomRollInputTitle"
|
||||||
|
aria-invalid="@HasCustomRollError"
|
||||||
|
aria-describedby="@CustomRollInputDescribedBy"
|
||||||
|
disabled="@IsCustomRollDisabled"/>
|
||||||
|
<button type="submit" disabled="@IsCustomRollDisabled">Roll</button>
|
||||||
|
</div>
|
||||||
|
<p class="field-help">@CustomRollHelpText</p>
|
||||||
|
@if (HasCustomRollError)
|
||||||
|
{
|
||||||
|
<p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="custom-roll-composer">
|
||||||
|
<div class="custom-roll-composer-head">
|
||||||
|
<span class="custom-roll-label">Custom roll</span>
|
||||||
|
<span class="muted">@CustomRollStatusText</span>
|
||||||
|
</div>
|
||||||
|
<p class="field-help">Loading roll composer...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="custom-roll-composer-row">
|
}
|
||||||
<input id="custom-roll-expression"
|
|
||||||
@key="CustomRollInputVersion"
|
|
||||||
@ref="CustomRollInputRef"
|
|
||||||
class="@CustomRollInputCssClass"
|
|
||||||
@bind="CustomRollExpression"
|
|
||||||
@bind:event="oninput"
|
|
||||||
placeholder="@CustomRollPlaceholder"
|
|
||||||
title="@CustomRollInputTitle"
|
|
||||||
aria-invalid="@HasCustomRollError"
|
|
||||||
aria-describedby="@CustomRollInputDescribedBy"
|
|
||||||
disabled="@IsCustomRollDisabled"/>
|
|
||||||
<button type="submit" disabled="@IsCustomRollDisabled">Roll</button>
|
|
||||||
</div>
|
|
||||||
<p class="field-help">@CustomRollHelpText</p>
|
|
||||||
@if (HasCustomRollError)
|
|
||||||
{
|
|
||||||
<p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p>
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -29,7 +29,8 @@ public partial class CampaignLogPanel
|
|||||||
catch (JSDisconnectedException)
|
catch (JSDisconnectedException)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase))
|
catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered",
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,11 +59,12 @@ public partial class CampaignLogPanel
|
|||||||
IsSubmittingCustomRoll = true;
|
IsSubmittingCustomRoll = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var roll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new
|
var roll = await ApiClient.RequestAsync<RollResult>("POST",
|
||||||
{
|
$"/api/characters/{SelectedCharacterId.Value}/custom-rolls", new
|
||||||
expression,
|
{
|
||||||
visibility = NormalizedRollVisibility
|
expression,
|
||||||
});
|
visibility = NormalizedRollVisibility
|
||||||
|
});
|
||||||
|
|
||||||
CustomRollState.Model.Expression = string.Empty;
|
CustomRollState.Model.Expression = string.Empty;
|
||||||
CustomRollState.ResetValidation();
|
CustomRollState.ResetValidation();
|
||||||
@@ -71,7 +73,8 @@ public partial class CampaignLogPanel
|
|||||||
await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef);
|
await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef);
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
catch (ApiRequestException ex) when (string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal))
|
catch (ApiRequestException ex) when
|
||||||
|
(string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
SetCustomRollError(ex.Message);
|
SetCustomRollError(ex.Message);
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
@@ -93,7 +96,8 @@ public partial class CampaignLogPanel
|
|||||||
|
|
||||||
private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry)
|
private static IReadOnlyList<EventBadgeView> GetEventBadges(CampaignLogListEntry entry)
|
||||||
{
|
{
|
||||||
return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null).Cast<EventBadgeView>().ToArray();
|
return (entry.EventBadges ?? []).Select(ToEventBadgeView).Where(badge => badge is not null)
|
||||||
|
.Cast<EventBadgeView>().ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool HasSummary(CampaignLogListEntry entry)
|
private static bool HasSummary(CampaignLogListEntry entry)
|
||||||
@@ -105,16 +109,16 @@ public partial class CampaignLogPanel
|
|||||||
{
|
{
|
||||||
return code switch
|
return code switch
|
||||||
{
|
{
|
||||||
"w6" => new("Wild 6", "positive"),
|
"w6" => new("Wild 6", "positive"),
|
||||||
"w1" => new("Wild 1", "danger"),
|
"w1" => new("Wild 1", "danger"),
|
||||||
"n20" => new("Nat 20", "positive"),
|
"n20" => new("Nat 20", "positive"),
|
||||||
"n1" => new("Nat 1", "danger"),
|
"n1" => new("Nat 1", "danger"),
|
||||||
"rf" => new("Fumble", "danger"),
|
"rf" => new("Fumble", "danger"),
|
||||||
"r100" => new("100", "rare"),
|
"r100" => new("100", "rare"),
|
||||||
"r66" => new("66", "rare"),
|
"r66" => new("66", "rare"),
|
||||||
"rs5" => new("Retry +5", "rare"),
|
"rs5" => new("Retry +5", "rare"),
|
||||||
"rs10" => new("Retry +10", "rare"),
|
"rs10" => new("Retry +10", "rare"),
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,11 +134,9 @@ public partial class CampaignLogPanel
|
|||||||
return string.Join(" ", classes);
|
return string.Join(" ", classes);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Inject]
|
[Inject] private IJSRuntime JS { get; set; } = null!;
|
||||||
private IJSRuntime JS { get; set; } = null!;
|
|
||||||
|
|
||||||
[Inject]
|
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
|
||||||
|
|
||||||
private ElementReference LogPanelRef { get; set; }
|
private ElementReference LogPanelRef { get; set; }
|
||||||
private ElementReference LogFeedRef { get; set; }
|
private ElementReference LogFeedRef { get; set; }
|
||||||
@@ -145,54 +147,44 @@ public partial class CampaignLogPanel
|
|||||||
private FormState<CustomRollFormModel> CustomRollState { get; } = new();
|
private FormState<CustomRollFormModel> CustomRollState { get; } = new();
|
||||||
private bool IsSubmittingCustomRoll { get; set; }
|
private bool IsSubmittingCustomRoll { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public bool IsCampaignDataLoading { get; set; }
|
||||||
public bool IsCampaignDataLoading { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
|
||||||
public IReadOnlyList<CampaignLogListEntry> CampaignLog { get; set; } = [];
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public Guid? ExpandedRollId { get; set; }
|
||||||
public Guid? ExpandedRollId { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public Guid? FreshRollId { get; set; }
|
||||||
public Guid? FreshRollId { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
|
||||||
public EventCallback<Guid> ToggleRollDetailRequested { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public Func<Guid, CampaignRollDetail?> ResolveRollDetail { get; set; } = _ => null;
|
||||||
public Func<Guid, CampaignRollDetail?> ResolveRollDetail { get; set; } = _ => null;
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public Func<Guid, bool> IsRollDetailLoading { get; set; } = _ => false;
|
||||||
public Func<Guid, bool> IsRollDetailLoading { get; set; } = _ => false;
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
|
||||||
public Func<Guid, string?> GetRollDetailError { get; set; } = _ => null;
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public Guid? SelectedCharacterId { get; set; }
|
||||||
public Guid? SelectedCharacterId { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public string? SelectedCharacterName { get; set; }
|
||||||
public string? SelectedCharacterName { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public string SelectedCampaignRulesetId { get; set; } = string.Empty;
|
||||||
public string SelectedCampaignRulesetId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public string RollVisibility { get; set; } = "public";
|
||||||
public string RollVisibility { get; set; } = "public";
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public bool EnableCustomRollComposer { get; set; }
|
||||||
public bool IsMutating { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public bool IsMutating { get; set; }
|
||||||
public EventCallback<RollResult> CustomRollCreated { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<RollResult> CustomRollCreated { get; set; }
|
||||||
public EventCallback<string> ErrorOccurred { get; set; }
|
|
||||||
|
[Parameter] public EventCallback<string> ErrorOccurred { get; set; }
|
||||||
|
|
||||||
private bool HasCustomRollError => CustomRollState.Errors.ContainsKey("expression");
|
private bool HasCustomRollError => CustomRollState.Errors.ContainsKey("expression");
|
||||||
private string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression");
|
private string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression");
|
||||||
private bool IsCustomRollDisabled => IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue;
|
|
||||||
|
private bool IsCustomRollDisabled =>
|
||||||
|
IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue;
|
||||||
|
|
||||||
private string CustomRollInputCssClass => HasCustomRollError ? "custom-roll-input error" : "custom-roll-input";
|
private string CustomRollInputCssClass => HasCustomRollError ? "custom-roll-input error" : "custom-roll-input";
|
||||||
private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null;
|
private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null;
|
||||||
private string CustomRollErrorElementId => "custom-roll-expression-error";
|
private string CustomRollErrorElementId => "custom-roll-expression-error";
|
||||||
@@ -200,23 +192,31 @@ public partial class CampaignLogPanel
|
|||||||
|
|
||||||
private string CustomRollPlaceholder => SelectedCampaignRulesetId.ToLowerInvariant() switch
|
private string CustomRollPlaceholder => SelectedCampaignRulesetId.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
RulesetFormHelpers.RulesetIds.D6 => "e.g. 5D+4",
|
RulesetFormHelpers.RulesetIds.D6 => "e.g. 5D+4",
|
||||||
RulesetFormHelpers.RulesetIds.Dnd5e => "e.g. 2d12+2",
|
RulesetFormHelpers.RulesetIds.Dnd5e => "e.g. 2d12+2",
|
||||||
RulesetFormHelpers.RulesetIds.Rolemaster => "e.g. d10, 15d10, d100!+85",
|
RulesetFormHelpers.RulesetIds.Rolemaster => "e.g. d10, 15d10, d100!+85",
|
||||||
_ => "Enter a roll expression"
|
_ => "Enter a roll expression"
|
||||||
};
|
};
|
||||||
|
|
||||||
private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName) ? $"For {SelectedCharacterName} • {RollVisibilityLabel}" : "Select a character to enable";
|
private string CustomRollStatusText =>
|
||||||
|
SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName)
|
||||||
|
? $"For {SelectedCharacterName} • {RollVisibilityLabel}"
|
||||||
|
: "Select a character to enable";
|
||||||
|
|
||||||
private string CustomRollHelpText => SelectedCampaignRulesetId.ToLowerInvariant() switch
|
private string CustomRollHelpText => SelectedCampaignRulesetId.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
RulesetFormHelpers.RulesetIds.D6 => "Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.",
|
RulesetFormHelpers.RulesetIds.D6 =>
|
||||||
RulesetFormHelpers.RulesetIds.Rolemaster => $"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.",
|
"Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.",
|
||||||
_ => "Uses the selected campaign ruleset and current visibility."
|
RulesetFormHelpers.RulesetIds.Rolemaster =>
|
||||||
|
$"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.",
|
||||||
|
_ => "Uses the selected campaign ruleset and current visibility."
|
||||||
};
|
};
|
||||||
|
|
||||||
private string RollVisibilityLabel => string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public";
|
private string RollVisibilityLabel =>
|
||||||
private string NormalizedRollVisibility => string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
|
string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public";
|
||||||
|
|
||||||
|
private string NormalizedRollVisibility =>
|
||||||
|
string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
|
||||||
|
|
||||||
private string CustomRollExpression
|
private string CustomRollExpression
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,20 +49,34 @@
|
|||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="skill-filter-wrap">
|
<div class="skill-filter-wrap">
|
||||||
<label class="sr-only" for="skill-filter-input">Filter skills</label>
|
@if (EnableInteractiveControls)
|
||||||
<input id="skill-filter-input"
|
{
|
||||||
class="skill-filter-input"
|
<label class="sr-only" for="skill-filter-input">Filter skills</label>
|
||||||
type="search"
|
<input id="skill-filter-input"
|
||||||
placeholder="Filter skills"
|
class="skill-filter-input"
|
||||||
@bind="SkillFilterText"
|
type="search"
|
||||||
@bind:event="oninput"/>
|
placeholder="Filter skills"
|
||||||
|
@bind="SkillFilterText"
|
||||||
|
@bind:event="oninput"/>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="muted">Loading skill controls...</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="chip-toolbar">
|
<div class="chip-toolbar">
|
||||||
<label class="visibility-control" for="roll-visibility">Visibility</label>
|
@if (EnableInteractiveControls)
|
||||||
<select id="roll-visibility" value="@(RollVisibility == "private" ? "private" : "public")" @onchange="OnRollVisibilityChangedAsync">
|
{
|
||||||
<option value="public">Public</option>
|
<label class="visibility-control" for="roll-visibility">Visibility</label>
|
||||||
<option value="private">Private</option>
|
<select id="roll-visibility" value="@(RollVisibility == "private" ? "private" : "public")" @onchange="OnRollVisibilityChangedAsync">
|
||||||
</select>
|
<option value="public">Public</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="muted">Visibility: @(RollVisibility == "private" ? "Private" : "Public")</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@{
|
@{
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ public partial class CharacterPanel
|
|||||||
{
|
{
|
||||||
private void OpenCreateSkillModal(Guid? skillGroupId = null)
|
private void OpenCreateSkillModal(Guid? skillGroupId = null)
|
||||||
{
|
{
|
||||||
var selectedGroup = skillGroupId.HasValue ? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value) : null;
|
var selectedGroup = skillGroupId.HasValue
|
||||||
|
? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value)
|
||||||
|
: null;
|
||||||
|
|
||||||
CreateSkillInitialModel = new()
|
CreateSkillInitialModel = new()
|
||||||
{
|
{
|
||||||
@@ -176,7 +178,11 @@ public partial class CharacterPanel
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var selectedCharacterId = SelectedCharacterId!.Value;
|
var selectedCharacterId = SelectedCharacterId!.Value;
|
||||||
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>("POST", $"/api/characters/{selectedCharacterId}/skill-groups", new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
|
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>("POST",
|
||||||
|
$"/api/characters/{selectedCharacterId}/skill-groups",
|
||||||
|
new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim(),
|
||||||
|
SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice,
|
||||||
|
SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
|
||||||
CloseSkillGroupModals();
|
CloseSkillGroupModals();
|
||||||
await SkillGroupCreated.InvokeAsync(createdGroup.Id);
|
await SkillGroupCreated.InvokeAsync(createdGroup.Id);
|
||||||
}
|
}
|
||||||
@@ -230,7 +236,11 @@ public partial class CharacterPanel
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var editingSkillGroupId = EditingSkillGroupId!.Value;
|
var editingSkillGroupId = EditingSkillGroupId!.Value;
|
||||||
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>("PUT", $"/api/skill-groups/{editingSkillGroupId}", new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
|
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>("PUT",
|
||||||
|
$"/api/skill-groups/{editingSkillGroupId}",
|
||||||
|
new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim(),
|
||||||
|
SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice,
|
||||||
|
SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
|
||||||
CloseSkillGroupModals();
|
CloseSkillGroupModals();
|
||||||
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
|
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
|
||||||
}
|
}
|
||||||
@@ -276,7 +286,8 @@ public partial class CharacterPanel
|
|||||||
return true;
|
return true;
|
||||||
|
|
||||||
var filter = SkillFilterText.Trim();
|
var filter = SkillFilterText.Trim();
|
||||||
return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string InitialsFor(string value)
|
private static string InitialsFor(string value)
|
||||||
@@ -317,9 +328,13 @@ public partial class CharacterPanel
|
|||||||
|
|
||||||
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId);
|
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId);
|
||||||
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId);
|
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId);
|
||||||
private bool IsSkillGroupRolemasterOpenEnded => RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition);
|
|
||||||
|
|
||||||
private string SkillGroupExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the default expression for skills created in this group.";
|
private bool IsSkillGroupRolemasterOpenEnded =>
|
||||||
|
RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition);
|
||||||
|
|
||||||
|
private string SkillGroupExpressionHelpText => IsRolemasterRuleset
|
||||||
|
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed."
|
||||||
|
: "Enter the default expression for skills created in this group.";
|
||||||
|
|
||||||
private bool ShowCreateSkillModal { get; set; }
|
private bool ShowCreateSkillModal { get; set; }
|
||||||
private bool ShowEditSkillModal { get; set; }
|
private bool ShowEditSkillModal { get; set; }
|
||||||
@@ -335,78 +350,55 @@ public partial class CharacterPanel
|
|||||||
private bool IsSubmittingSkillGroup { get; set; }
|
private bool IsSubmittingSkillGroup { get; set; }
|
||||||
private string SkillFilterText { get; set; } = string.Empty;
|
private string SkillFilterText { get; set; } = string.Empty;
|
||||||
|
|
||||||
[Inject]
|
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public bool IsCampaignDataLoading { get; set; }
|
||||||
public bool IsCampaignDataLoading { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public CampaignRoster? SelectedCampaign { get; set; }
|
||||||
public CampaignRoster? SelectedCampaign { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public Guid? SelectedCharacterId { get; set; }
|
||||||
public Guid? SelectedCharacterId { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public CharacterSummary? SelectedCharacter { get; set; }
|
||||||
public CharacterSummary? SelectedCharacter { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public bool IsMutating { get; set; }
|
||||||
public bool IsMutating { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public IReadOnlyList<CharacterSheetSkill> SelectedCharacterSkills { get; set; } = [];
|
||||||
public IReadOnlyList<CharacterSheetSkill> SelectedCharacterSkills { get; set; } = [];
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
|
||||||
public IReadOnlyList<CharacterSheetSkillGroup> SelectedCharacterSkillGroups { get; set; } = [];
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public string SelectedCampaignRulesetId { get; set; } = string.Empty;
|
||||||
public string SelectedCampaignRulesetId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public string RollVisibility { get; set; } = "public";
|
||||||
public string RollVisibility { get; set; } = "public";
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public bool EnableInteractiveControls { get; set; }
|
||||||
public EventCallback<string> RollVisibilityChanged { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<string> RollVisibilityChanged { get; set; }
|
||||||
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
|
||||||
public Func<CharacterSheetSkill, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public Func<CharacterSheetSkill, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
|
||||||
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
||||||
public Func<CharacterSheetSkill, bool> CanEditSkill { get; set; } = _ => false;
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public Func<CharacterSheetSkill, bool> CanEditSkill { get; set; } = _ => false;
|
||||||
public EventCallback<Guid> CharacterSelected { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<Guid> CharacterSelected { get; set; }
|
||||||
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
|
||||||
public EventCallback<Guid> SkillCreated { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<Guid> SkillCreated { get; set; }
|
||||||
public EventCallback<Guid> SkillUpdated { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<Guid> SkillUpdated { get; set; }
|
||||||
public EventCallback<Guid> SkillGroupCreated { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<Guid> SkillGroupCreated { get; set; }
|
||||||
public EventCallback<Guid> SkillGroupUpdated { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<Guid> SkillGroupUpdated { get; set; }
|
||||||
public EventCallback<Guid> SkillDeleted { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<Guid> SkillDeleted { get; set; }
|
||||||
public EventCallback<Guid> SkillGroupDeleted { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<Guid> SkillGroupDeleted { get; set; }
|
||||||
public EventCallback<string> ErrorOccurred { get; set; }
|
|
||||||
|
|
||||||
[Parameter]
|
[Parameter] public EventCallback<string> ErrorOccurred { get; set; }
|
||||||
public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
|
|
||||||
|
[Parameter] public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,244 +1,233 @@
|
|||||||
@using RpgRoller.Components.Pages.HomeControls
|
@using RpgRoller.Components.Pages.HomeControls
|
||||||
<div class="@State.AppCssClass">
|
<div class="@State.AppCssClass">
|
||||||
@if (!IsInitialized)
|
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
|
||||||
|
|
||||||
|
@if (State.HasHealthIssue)
|
||||||
{
|
{
|
||||||
<main class="loading-shell" aria-busy="true" aria-live="polite">
|
<section class="health-banner" role="alert">
|
||||||
<h1>RpgRoller</h1>
|
<div>
|
||||||
<p>Loading workspace...</p>
|
<strong>API currently unavailable.</strong>
|
||||||
</main>
|
<p>@State.HealthIssueMessage</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" @onclick="Session.RetryAfterHealthIssueAsync">Retry</button>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
|
|
||||||
|
|
||||||
@if (State.HasHealthIssue)
|
<div class="workspace-shell">
|
||||||
|
<AppHeader
|
||||||
|
User="State.User"
|
||||||
|
ShowCampaign="true"
|
||||||
|
CampaignName="@State.SelectedCampaignName"
|
||||||
|
ShowConnectionState="true"
|
||||||
|
ConnectionStateLabel="@State.ConnectionStateLabel"
|
||||||
|
ConnectionStateCssClass="@State.ConnectionStateCssClass"
|
||||||
|
IsMenuOpen="State.IsScreenMenuOpen"
|
||||||
|
MenuButtonId="workspace-screen-menu-button"
|
||||||
|
MenuId="workspace-screen-menu"
|
||||||
|
MenuItems="HeaderMenuItems"
|
||||||
|
ToggleMenuRequested="ToggleScreenMenu"
|
||||||
|
LogoutRequested="Session.LogoutAsync"/>
|
||||||
|
|
||||||
|
@if (State.IsPlayScreen)
|
||||||
{
|
{
|
||||||
<section class="health-banner" role="alert">
|
<main class="play-screen @(State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||||
<div>
|
<CharacterPanel
|
||||||
<strong>API currently unavailable.</strong>
|
IsCampaignDataLoading="State.IsCampaignDataLoading"
|
||||||
<p>@State.HealthIssueMessage</p>
|
SelectedCampaign="State.PlaySelectedCampaign"
|
||||||
</div>
|
SelectedCharacterId="State.PlaySelectedCharacterId"
|
||||||
<button type="button" @onclick="Session.RetryAfterHealthIssueAsync">Retry</button>
|
SelectedCharacter="State.PlaySelectedCharacter"
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="workspace-shell">
|
|
||||||
<AppHeader
|
|
||||||
User="State.User"
|
|
||||||
ShowCampaign="true"
|
|
||||||
CampaignName="@State.SelectedCampaignName"
|
|
||||||
ShowConnectionState="true"
|
|
||||||
ConnectionStateLabel="@State.ConnectionStateLabel"
|
|
||||||
ConnectionStateCssClass="@State.ConnectionStateCssClass"
|
|
||||||
IsMenuOpen="State.IsScreenMenuOpen"
|
|
||||||
MenuButtonId="workspace-screen-menu-button"
|
|
||||||
MenuId="workspace-screen-menu"
|
|
||||||
MenuItems="HeaderMenuItems"
|
|
||||||
ToggleMenuRequested="ToggleScreenMenu"
|
|
||||||
LogoutRequested="Session.LogoutAsync"/>
|
|
||||||
|
|
||||||
@if (State.IsPlayScreen)
|
|
||||||
{
|
|
||||||
<main class="play-screen @(State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
|
||||||
<CharacterPanel
|
|
||||||
IsCampaignDataLoading="State.IsCampaignDataLoading"
|
|
||||||
SelectedCampaign="State.PlaySelectedCampaign"
|
|
||||||
SelectedCharacterId="State.PlaySelectedCharacterId"
|
|
||||||
SelectedCharacter="State.PlaySelectedCharacter"
|
|
||||||
IsMutating="State.IsMutating"
|
|
||||||
SelectedCharacterSkills="State.PlaySelectedCharacterSkills"
|
|
||||||
SelectedCharacterSkillGroups="State.PlaySelectedCharacterSkillGroups"
|
|
||||||
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
|
||||||
RollVisibility="State.RollVisibility"
|
|
||||||
RollVisibilityChanged="Session.OnRollVisibilityChangedAsync"
|
|
||||||
OwnerLabel="State.OwnerLabel"
|
|
||||||
SkillDefinitionLabel="State.SkillDefinitionLabel"
|
|
||||||
CanEditCharacter="Campaigns.CanEditCharacter"
|
|
||||||
CanEditSkill="Play.CanEditSkill"
|
|
||||||
CharacterSelected="Play.SelectCharacterAsync"
|
|
||||||
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
|
|
||||||
SkillCreated="Play.OnSkillCreatedAsync"
|
|
||||||
SkillUpdated="Play.OnSkillUpdatedAsync"
|
|
||||||
SkillGroupCreated="Play.OnSkillGroupCreatedAsync"
|
|
||||||
SkillGroupUpdated="Play.OnSkillGroupUpdatedAsync"
|
|
||||||
SkillDeleted="Play.OnSkillDeletedAsync"
|
|
||||||
SkillGroupDeleted="Play.OnSkillGroupDeletedAsync"
|
|
||||||
ErrorOccurred="Play.OnCharacterPanelErrorAsync"
|
|
||||||
RollRequested="Play.RollSkillAsync"/>
|
|
||||||
|
|
||||||
<CampaignLogPanel
|
|
||||||
IsCampaignDataLoading="State.IsCampaignDataLoading"
|
|
||||||
CampaignLog="State.PlayVisibleCampaignLog"
|
|
||||||
ExpandedRollId="State.ExpandedCampaignLogRollId"
|
|
||||||
FreshRollId="State.FreshCampaignLogRollId"
|
|
||||||
SelectedCharacterId="State.PlaySelectedCharacterId"
|
|
||||||
SelectedCharacterName="@(State.PlaySelectedCharacter?.Name)"
|
|
||||||
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
|
||||||
RollVisibility="State.RollVisibility"
|
|
||||||
IsMutating="State.IsMutating"
|
|
||||||
ToggleRollDetailRequested="Play.ToggleRollDetailAsync"
|
|
||||||
ResolveRollDetail="Play.ResolveRollDetail"
|
|
||||||
IsRollDetailLoading="Play.IsRollDetailLoading"
|
|
||||||
GetRollDetailError="Play.GetRollDetailError"
|
|
||||||
CustomRollCreated="Play.OnCustomRollCreatedAsync"
|
|
||||||
ErrorOccurred="Play.OnCampaignLogPanelErrorAsync"/>
|
|
||||||
</main>
|
|
||||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
|
||||||
<button type="button" class="switch @(State.MobilePanel == "character" ? "active" : string.Empty)"
|
|
||||||
@onclick='() => Scope.SetMobilePanelAsync("character")'>
|
|
||||||
Character
|
|
||||||
</button>
|
|
||||||
<button type="button" class="switch @(State.MobilePanel == "log" ? "active" : string.Empty)"
|
|
||||||
@onclick='() => Scope.SetMobilePanelAsync("log")'>
|
|
||||||
Log
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
}
|
|
||||||
else if (State.IsManagementScreen)
|
|
||||||
{
|
|
||||||
<CampaignManagementPanel
|
|
||||||
Campaigns="State.Campaigns"
|
|
||||||
SelectedCampaignId="State.SelectedCampaignId"
|
|
||||||
SelectedCampaign="State.SelectedCampaign"
|
|
||||||
Rulesets="State.Rulesets"
|
|
||||||
IsMutating="State.IsMutating"
|
IsMutating="State.IsMutating"
|
||||||
|
SelectedCharacterSkills="State.PlaySelectedCharacterSkills"
|
||||||
|
SelectedCharacterSkillGroups="State.PlaySelectedCharacterSkillGroups"
|
||||||
|
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
||||||
|
RollVisibility="State.RollVisibility"
|
||||||
|
EnableInteractiveControls="EnableCharacterControls"
|
||||||
|
RollVisibilityChanged="Session.OnRollVisibilityChangedAsync"
|
||||||
OwnerLabel="State.OwnerLabel"
|
OwnerLabel="State.OwnerLabel"
|
||||||
|
SkillDefinitionLabel="State.SkillDefinitionLabel"
|
||||||
CanEditCharacter="Campaigns.CanEditCharacter"
|
CanEditCharacter="Campaigns.CanEditCharacter"
|
||||||
CanDeleteCharacter="Campaigns.CanDeleteCharacter"
|
CanEditSkill="Play.CanEditSkill"
|
||||||
CanDeleteCampaign="State.CanDeleteSelectedCampaign"
|
CharacterSelected="Play.SelectCharacterAsync"
|
||||||
CampaignSelectionChanged="Campaigns.OnCampaignSelectionChangedAsync"
|
|
||||||
CampaignCreated="Campaigns.OnCampaignCreatedAsync"
|
|
||||||
DeleteCampaignRequested="Campaigns.DeleteSelectedCampaignAsync"
|
|
||||||
CreateCharacterRequested="Campaigns.OpenCreateCharacterModal"
|
|
||||||
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
|
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
|
||||||
DeleteCharacterRequested="Campaigns.DeleteCharacterAsync"/>
|
SkillCreated="Play.OnSkillCreatedAsync"
|
||||||
}
|
SkillUpdated="Play.OnSkillUpdatedAsync"
|
||||||
else if (State.IsAdminScreen)
|
SkillGroupCreated="Play.OnSkillGroupCreatedAsync"
|
||||||
{
|
SkillGroupUpdated="Play.OnSkillGroupUpdatedAsync"
|
||||||
<main class="management-screen">
|
SkillDeleted="Play.OnSkillDeletedAsync"
|
||||||
@if (State.IsCurrentUserAdmin)
|
SkillGroupDeleted="Play.OnSkillGroupDeletedAsync"
|
||||||
{
|
ErrorOccurred="Play.OnCharacterPanelErrorAsync"
|
||||||
<section class="card">
|
RollRequested="Play.RollSkillAsync"/>
|
||||||
<div class="section-head">
|
|
||||||
<h2>Database</h2>
|
<CampaignLogPanel
|
||||||
</div>
|
IsCampaignDataLoading="State.IsCampaignDataLoading"
|
||||||
<p class="muted">Download the current SQLite file for backup or offline inspection.</p>
|
CampaignLog="State.PlayVisibleCampaignLog"
|
||||||
<div class="management-actions">
|
ExpandedRollId="State.ExpandedCampaignLogRollId"
|
||||||
<a class="action-link" href="@AdminDatabaseDownloadUrl" download>Download SQLite database</a>
|
FreshRollId="State.FreshCampaignLogRollId"
|
||||||
</div>
|
SelectedCharacterId="State.PlaySelectedCharacterId"
|
||||||
</section>
|
SelectedCharacterName="@(State.PlaySelectedCharacter?.Name)"
|
||||||
}
|
SelectedCampaignRulesetId="@(State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
||||||
|
RollVisibility="State.RollVisibility"
|
||||||
|
EnableCustomRollComposer="EnableCustomRollComposer"
|
||||||
|
IsMutating="State.IsMutating"
|
||||||
|
ToggleRollDetailRequested="Play.ToggleRollDetailAsync"
|
||||||
|
ResolveRollDetail="Play.ResolveRollDetail"
|
||||||
|
IsRollDetailLoading="Play.IsRollDetailLoading"
|
||||||
|
GetRollDetailError="Play.GetRollDetailError"
|
||||||
|
CustomRollCreated="Play.OnCustomRollCreatedAsync"
|
||||||
|
ErrorOccurred="Play.OnCampaignLogPanelErrorAsync"/>
|
||||||
|
</main>
|
||||||
|
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||||
|
<button type="button" class="switch @(State.MobilePanel == "character" ? "active" : string.Empty)"
|
||||||
|
@onclick='() => Scope.SetMobilePanelAsync("character")'>
|
||||||
|
Character
|
||||||
|
</button>
|
||||||
|
<button type="button" class="switch @(State.MobilePanel == "log" ? "active" : string.Empty)"
|
||||||
|
@onclick='() => Scope.SetMobilePanelAsync("log")'>
|
||||||
|
Log
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
else if (State.IsManagementScreen)
|
||||||
|
{
|
||||||
|
<CampaignManagementPanel
|
||||||
|
Campaigns="State.Campaigns"
|
||||||
|
SelectedCampaignId="State.SelectedCampaignId"
|
||||||
|
SelectedCampaign="State.SelectedCampaign"
|
||||||
|
Rulesets="State.Rulesets"
|
||||||
|
IsMutating="State.IsMutating"
|
||||||
|
OwnerLabel="State.OwnerLabel"
|
||||||
|
CanEditCharacter="Campaigns.CanEditCharacter"
|
||||||
|
CanDeleteCharacter="Campaigns.CanDeleteCharacter"
|
||||||
|
CanDeleteCampaign="State.CanDeleteSelectedCampaign"
|
||||||
|
CampaignSelectionChanged="Campaigns.OnCampaignSelectionChangedAsync"
|
||||||
|
CampaignCreated="Campaigns.OnCampaignCreatedAsync"
|
||||||
|
DeleteCampaignRequested="Campaigns.DeleteSelectedCampaignAsync"
|
||||||
|
CreateCharacterRequested="Campaigns.OpenCreateCharacterModal"
|
||||||
|
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
|
||||||
|
DeleteCharacterRequested="Campaigns.DeleteCharacterAsync"/>
|
||||||
|
}
|
||||||
|
else if (State.IsAdminScreen)
|
||||||
|
{
|
||||||
|
<main class="management-screen">
|
||||||
|
@if (State.IsCurrentUserAdmin)
|
||||||
|
{
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>User Management</h2>
|
<h2>Database</h2>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Download the current SQLite file for backup or offline inspection.</p>
|
||||||
|
<div class="management-actions">
|
||||||
|
<a class="action-link" href="@AdminDatabaseDownloadUrl" download>Download SQLite database</a>
|
||||||
</div>
|
</div>
|
||||||
@if (State.IsAdminDataLoading)
|
|
||||||
{
|
|
||||||
<p class="empty">Loading users...</p>
|
|
||||||
}
|
|
||||||
else if (!State.IsCurrentUserAdmin)
|
|
||||||
{
|
|
||||||
<p class="empty">Admin role is required to manage users.</p>
|
|
||||||
}
|
|
||||||
else if (State.AdminUsers.Count == 0)
|
|
||||||
{
|
|
||||||
<p class="empty">No users found.</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<ul class="management-list">
|
|
||||||
@foreach (var user in State.AdminUsers)
|
|
||||||
{
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
<strong>@user.Username</strong>
|
|
||||||
<p class="muted">@user.DisplayName</p>
|
|
||||||
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
|
|
||||||
</div>
|
|
||||||
<div class="skill-chip-actions">
|
|
||||||
<button type="button"
|
|
||||||
class="chip-button"
|
|
||||||
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
|
|
||||||
@onclick="() => Admin.ToggleAdminRoleAsync(user)">
|
|
||||||
<span aria-hidden="true" class="emoji">🛡️</span>
|
|
||||||
<span class="sr-only">Toggle admin role for @user.Username</span>
|
|
||||||
</button>
|
|
||||||
<button type="button"
|
|
||||||
class="chip-button"
|
|
||||||
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
|
|
||||||
@onclick="() => Admin.DeleteUserAsync(user)">
|
|
||||||
<span aria-hidden="true" class="emoji">🗑️</span>
|
|
||||||
<span class="sr-only">Delete user @user.Username</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
}
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>User Management</h2>
|
||||||
|
</div>
|
||||||
|
@if (State.IsAdminDataLoading)
|
||||||
|
{
|
||||||
|
<p class="empty">Loading users...</p>
|
||||||
|
}
|
||||||
|
else if (!State.IsCurrentUserAdmin)
|
||||||
|
{
|
||||||
|
<p class="empty">Admin role is required to manage users.</p>
|
||||||
|
}
|
||||||
|
else if (State.AdminUsers.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="empty">No users found.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="management-list">
|
||||||
|
@foreach (var user in State.AdminUsers)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<div>
|
||||||
|
<strong>@user.Username</strong>
|
||||||
|
<p class="muted">@user.DisplayName</p>
|
||||||
|
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
|
||||||
|
</div>
|
||||||
|
<div class="skill-chip-actions">
|
||||||
|
<button type="button"
|
||||||
|
class="chip-button"
|
||||||
|
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
|
||||||
|
@onclick="() => Admin.ToggleAdminRoleAsync(user)">
|
||||||
|
<span aria-hidden="true" class="emoji">🛡️</span>
|
||||||
|
<span class="sr-only">Toggle admin role for @user.Username</span>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="chip-button"
|
||||||
|
disabled="@(State.IsMutating || user.Id == State.User?.Id)"
|
||||||
|
@onclick="() => Admin.DeleteUserAsync(user)">
|
||||||
|
<span aria-hidden="true" class="emoji">🗑️</span>
|
||||||
|
<span class="sr-only">Delete user @user.Username</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (State.Toasts.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="toast-stack" aria-live="polite" aria-atomic="false">
|
||||||
|
@foreach (var toast in State.Toasts)
|
||||||
|
{
|
||||||
|
<div class="toast @(toast.IsError ? "error" : "success")" role="status">
|
||||||
|
<p>@toast.Message</p>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (State.Toasts.Count > 0)
|
|
||||||
{
|
|
||||||
<div class="toast-stack" aria-live="polite" aria-atomic="false">
|
|
||||||
@foreach (var toast in State.Toasts)
|
|
||||||
{
|
|
||||||
<div class="toast @(toast.IsError ? "error" : "success")" role="status">
|
|
||||||
<p>@toast.Message</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (IsInitialized)
|
<CharacterFormModal
|
||||||
{
|
Visible="State.ShowCreateCharacterModal"
|
||||||
<CharacterFormModal
|
Title="Create Character"
|
||||||
Visible="State.ShowCreateCharacterModal"
|
SubmitLabel="Create Character"
|
||||||
Title="Create Character"
|
NameInputId="character-create-name"
|
||||||
SubmitLabel="Create Character"
|
CampaignInputId="character-create-campaign"
|
||||||
NameInputId="character-create-name"
|
OwnerUsernameInputId="character-create-owner"
|
||||||
CampaignInputId="character-create-campaign"
|
InitialModel="State.CreateCharacterInitialModel"
|
||||||
OwnerUsernameInputId="character-create-owner"
|
FormVersion="State.CreateCharacterFormVersion"
|
||||||
InitialModel="State.CreateCharacterInitialModel"
|
EditingCharacterId="null"
|
||||||
FormVersion="State.CreateCharacterFormVersion"
|
CampaignOptions="State.CharacterCampaignOptions"
|
||||||
EditingCharacterId="null"
|
IsMutating="State.IsMutating"
|
||||||
CampaignOptions="State.CharacterCampaignOptions"
|
AllowOwnerEdit="false"
|
||||||
IsMutating="State.IsMutating"
|
AvailableUsernames="State.KnownUsernames"
|
||||||
AllowOwnerEdit="false"
|
CharacterSaved="Campaigns.OnCharacterCreatedAsync"
|
||||||
AvailableUsernames="State.KnownUsernames"
|
CancelRequested="Campaigns.CloseCharacterModals"/>
|
||||||
CharacterSaved="Campaigns.OnCharacterCreatedAsync"
|
|
||||||
CancelRequested="Campaigns.CloseCharacterModals"/>
|
|
||||||
|
|
||||||
<CharacterFormModal
|
<CharacterFormModal
|
||||||
Visible="State.ShowEditCharacterModal"
|
Visible="State.ShowEditCharacterModal"
|
||||||
Title="Edit Character"
|
Title="Edit Character"
|
||||||
SubmitLabel="Save Character"
|
SubmitLabel="Save Character"
|
||||||
NameInputId="character-edit-name"
|
NameInputId="character-edit-name"
|
||||||
CampaignInputId="character-edit-campaign"
|
CampaignInputId="character-edit-campaign"
|
||||||
OwnerUsernameInputId="character-edit-owner"
|
OwnerUsernameInputId="character-edit-owner"
|
||||||
InitialModel="State.EditCharacterInitialModel"
|
InitialModel="State.EditCharacterInitialModel"
|
||||||
FormVersion="State.EditCharacterFormVersion"
|
FormVersion="State.EditCharacterFormVersion"
|
||||||
EditingCharacterId="State.EditingCharacterId"
|
EditingCharacterId="State.EditingCharacterId"
|
||||||
CampaignOptions="State.CharacterCampaignOptions"
|
CampaignOptions="State.CharacterCampaignOptions"
|
||||||
IsMutating="State.IsMutating"
|
IsMutating="State.IsMutating"
|
||||||
AllowOwnerEdit="State.CanEditCharacterOwner"
|
AllowOwnerEdit="State.CanEditCharacterOwner"
|
||||||
AvailableUsernames="State.KnownUsernames"
|
AvailableUsernames="State.KnownUsernames"
|
||||||
CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
|
CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
|
||||||
CancelRequested="Campaigns.CloseCharacterModals"/>
|
CancelRequested="Campaigns.CloseCharacterModals"/>
|
||||||
|
|
||||||
<RolemasterSkillRollModal
|
<RolemasterSkillRollModal
|
||||||
Visible="State.ShowRolemasterSkillRollModal"
|
Visible="State.ShowRolemasterSkillRollModal"
|
||||||
SkillName="@(State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
|
SkillName="@(State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
|
||||||
Expression="@(State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
|
Expression="@(State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
|
||||||
ModifierText="@State.PendingRolemasterSituationalModifier"
|
ModifierText="@State.PendingRolemasterSituationalModifier"
|
||||||
ModifierTextChanged="@(text => State.PendingRolemasterSituationalModifier = text)"
|
ModifierTextChanged="@(text => State.PendingRolemasterSituationalModifier = text)"
|
||||||
ErrorMessage="@State.PendingRolemasterSkillRollError"
|
ErrorMessage="@State.PendingRolemasterSkillRollError"
|
||||||
IsMutating="State.IsMutating"
|
IsMutating="State.IsMutating"
|
||||||
IsSubmitting="State.IsSubmittingRolemasterSkillRoll"
|
IsSubmitting="State.IsSubmittingRolemasterSkillRoll"
|
||||||
ConfirmRequested="Play.SubmitRolemasterSkillRollAsync"
|
ConfirmRequested="Play.SubmitRolemasterSkillRollAsync"
|
||||||
CancelRequested="Play.CancelRolemasterSkillRollAsync"/>
|
CancelRequested="Play.CancelRolemasterSkillRollAsync"/>
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,11 +12,28 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
State.HasInteractiveRenderStarted = true;
|
State.HasInteractiveRenderStarted = true;
|
||||||
if (!firstRender)
|
if (firstRender)
|
||||||
|
{
|
||||||
|
await Session.InitializeAsync();
|
||||||
|
HasSessionInitialized = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasSessionInitialized)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await Session.InitializeAsync();
|
if (!EnableCharacterControls)
|
||||||
IsInitialized = true;
|
{
|
||||||
|
EnableCharacterControls = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EnableCustomRollComposer)
|
||||||
|
return;
|
||||||
|
|
||||||
|
EnableCustomRollComposer = true;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,8 +115,10 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
|
|
||||||
[Parameter] public EventCallback<string?> LoggedOut { get; set; }
|
[Parameter] public EventCallback<string?> LoggedOut { get; set; }
|
||||||
|
|
||||||
private bool IsInitialized { get; set; }
|
|
||||||
private WorkspaceState State { get; } = new();
|
private WorkspaceState State { get; } = new();
|
||||||
|
private bool HasSessionInitialized { get; set; }
|
||||||
|
private bool EnableCharacterControls { get; set; }
|
||||||
|
private bool EnableCustomRollComposer { get; set; }
|
||||||
|
|
||||||
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery,
|
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery,
|
||||||
Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync,
|
Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync,
|
||||||
|
|||||||
@@ -91,6 +91,89 @@ test("successful login transitions to play workspace", async ({ page, context })
|
|||||||
await expect(page.locator("#login-username")).toHaveCount(0);
|
await expect(page.locator("#login-username")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("workspace stays usable when input controls are DOM-wrapped during mount", async ({ page, context }) => {
|
||||||
|
const username = `wrapped-${Date.now()}`;
|
||||||
|
await registerAndLogin(context.request, username, "Wrapped Inputs");
|
||||||
|
|
||||||
|
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||||
|
name: "Wrapped Inputs",
|
||||||
|
rulesetId: "d6"
|
||||||
|
});
|
||||||
|
const character = await postJson(context.request, "/api/characters", {
|
||||||
|
name: "Wrapper Hero",
|
||||||
|
campaignId: campaign.id
|
||||||
|
});
|
||||||
|
await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||||
|
name: "Stealth",
|
||||||
|
diceRollDefinition: "2D+1",
|
||||||
|
wildDice: 1,
|
||||||
|
allowFumble: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const wrappedMarker = "rrWrappedByTest";
|
||||||
|
|
||||||
|
function wrapControl(element) {
|
||||||
|
if (!(element instanceof HTMLElement) || !element.isConnected || element.dataset[wrappedMarker] === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = element.parentNode;
|
||||||
|
if (!parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = document.createElement("span");
|
||||||
|
wrapper.dataset[wrappedMarker] = "1";
|
||||||
|
element.dataset[wrappedMarker] = "1";
|
||||||
|
parent.insertBefore(wrapper, element);
|
||||||
|
wrapper.appendChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueWrap(node) {
|
||||||
|
if (!(node instanceof Element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.matches("input, select")) {
|
||||||
|
queueMicrotask(() => wrapControl(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
node.querySelectorAll("input, select").forEach((element) => {
|
||||||
|
queueMicrotask(() => wrapControl(element));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
mutation.addedNodes.forEach(queueWrap);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
document.querySelectorAll("input, select").forEach((element) => queueWrap(element));
|
||||||
|
});
|
||||||
|
|
||||||
|
const blazorErrors = [];
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() !== "error") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = message.text();
|
||||||
|
if (/error applying batch|unhandled exception on the current circuit/i.test(text)) {
|
||||||
|
blazorErrors.push(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||||
|
await expect(page.locator("#skill-filter-input")).toBeVisible();
|
||||||
|
await expect(page.locator("#custom-roll-expression")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Roll Stealth" })).toBeVisible();
|
||||||
|
expect(blazorErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("Rolemaster open-ended roll detail renders specialized dice chips", async ({ page, context }) => {
|
test("Rolemaster open-ended roll detail renders specialized dice chips", async ({ page, context }) => {
|
||||||
const username = `rm-${Date.now()}`;
|
const username = `rm-${Date.now()}`;
|
||||||
const displayName = "Rolemaster Smoke";
|
const displayName = "Rolemaster Smoke";
|
||||||
|
|||||||
Reference in New Issue
Block a user