Refactor management UX and move workspace status to toasts
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
<main class="management-screen">
|
||||
<section class="card">
|
||||
<h2>Campaign Selector</h2>
|
||||
<div class="section-head">
|
||||
<h2>Campaign</h2>
|
||||
</div>
|
||||
@if (Campaigns.Count == 0)
|
||||
{
|
||||
<p class="empty">No campaigns yet.</p>
|
||||
@@ -11,47 +13,11 @@
|
||||
<select id="campaign-select" @onchange="CampaignSelectionChanged">
|
||||
@foreach (var campaign in Campaigns)
|
||||
{
|
||||
<option value="@campaign.Id"
|
||||
selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)
|
||||
</option>
|
||||
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
<p class="muted">Current campaign in this tab: <strong>@(SelectedCampaignName ?? "None selected")</strong></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Create Campaign</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(CampaignState.ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@CampaignState.ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="SubmitCreateCampaignAsync" @onsubmit:preventDefault>
|
||||
<label for="campaign-name">Campaign name</label>
|
||||
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput"/>
|
||||
@if (CampaignState.Errors.TryGetValue("name", out var campaignNameError))
|
||||
{
|
||||
<p class="field-error">@campaignNameError</p>
|
||||
}
|
||||
<label for="campaign-ruleset">Ruleset</label>
|
||||
<select id="campaign-ruleset" @bind="CampaignState.Model.RulesetId">
|
||||
<option value="">Select ruleset</option>
|
||||
@foreach (var ruleset in Rulesets)
|
||||
{
|
||||
<option value="@ruleset.Id">@ruleset.Name</option>
|
||||
}
|
||||
</select>
|
||||
@if (CampaignState.Errors.TryGetValue("rulesetId", out var campaignRulesetError))
|
||||
{
|
||||
<p class="field-error">@campaignRulesetError</p>
|
||||
}
|
||||
<button type="submit"
|
||||
disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Campaign Details</h2>
|
||||
@if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">No campaign selected.</p>
|
||||
@@ -60,44 +26,102 @@
|
||||
{
|
||||
<p>Name: <strong>@SelectedCampaign.Name</strong></p>
|
||||
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
|
||||
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span
|
||||
class="muted">(@SelectedCampaign.Gm.Username)</span></p>
|
||||
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p>
|
||||
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
|
||||
}
|
||||
|
||||
<button type="button"
|
||||
class="add-row-button"
|
||||
disabled="@(IsMutating || IsCreatingCampaign)"
|
||||
@onclick="OpenCreateCampaignModal">
|
||||
<span class="add-row-icon" aria-hidden="true">+</span>
|
||||
<span>Add campaign</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>Character Management</h2>
|
||||
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || SelectedCampaign is null)"
|
||||
@onclick="CreateCharacterRequested">Create Character
|
||||
</button>
|
||||
</div>
|
||||
@if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">Select a campaign first.</p>
|
||||
}
|
||||
else if (SelectedCampaign.Characters.Count == 0)
|
||||
{
|
||||
<p class="empty">No characters in this campaign yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="management-list">
|
||||
@foreach (var character in SelectedCampaign.Characters)
|
||||
{
|
||||
<li>
|
||||
<div><strong>@character.Name</strong>
|
||||
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
|
||||
<div class="inline-actions">
|
||||
@if (SelectedCampaign.Characters.Count == 0)
|
||||
{
|
||||
<p class="empty">No characters in this campaign yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="management-list">
|
||||
@foreach (var character in SelectedCampaign.Characters)
|
||||
{
|
||||
<li>
|
||||
<div>
|
||||
<strong>@character.Name</strong>
|
||||
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="chip-button"
|
||||
title="Edit character"
|
||||
disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))"
|
||||
@onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit
|
||||
@onclick="() => EditCharacterRequested.InvokeAsync(character)">
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span class="sr-only">Edit @character.Name</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<button type="button"
|
||||
class="add-row-button"
|
||||
disabled="@(IsMutating || IsCreatingCampaign)"
|
||||
@onclick="CreateCharacterRequested">
|
||||
<span class="add-row-icon" aria-hidden="true">+</span>
|
||||
<span>Add character</span>
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@if (ShowCreateCampaignModal)
|
||||
{
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Create Campaign">
|
||||
<h2>Create Campaign</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(CampaignState.ErrorMessage))
|
||||
{
|
||||
<p class="form-error">@CampaignState.ErrorMessage</p>
|
||||
}
|
||||
<form class="form-grid" @onsubmit="SubmitCreateCampaignAsync" @onsubmit:preventDefault>
|
||||
<label for="campaign-name">Campaign name</label>
|
||||
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput"/>
|
||||
@if (CampaignState.Errors.TryGetValue("name", out var campaignNameError))
|
||||
{
|
||||
<p class="field-error">@campaignNameError</p>
|
||||
}
|
||||
|
||||
<label for="campaign-ruleset">Ruleset</label>
|
||||
<select id="campaign-ruleset" @bind="CampaignState.Model.RulesetId">
|
||||
<option value="">Select ruleset</option>
|
||||
@foreach (var ruleset in Rulesets)
|
||||
{
|
||||
<option value="@ruleset.Id">@ruleset.Name</option>
|
||||
}
|
||||
</select>
|
||||
@if (CampaignState.Errors.TryGetValue("rulesetId", out var campaignRulesetError))
|
||||
{
|
||||
<p class="field-error">@campaignRulesetError</p>
|
||||
}
|
||||
|
||||
<div class="inline-actions">
|
||||
<button type="submit" disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
|
||||
<button type="button" class="ghost" disabled="@(IsMutating || IsCreatingCampaign)" @onclick="CloseCreateCampaignModal">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -13,6 +13,22 @@ public partial class CampaignManagementPanel
|
||||
CampaignState.Model.RulesetId = Rulesets[0].Id;
|
||||
}
|
||||
|
||||
private void OpenCreateCampaignModal()
|
||||
{
|
||||
CampaignState.Model.Name = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0)
|
||||
CampaignState.Model.RulesetId = Rulesets[0].Id;
|
||||
|
||||
CampaignState.ResetValidation();
|
||||
ShowCreateCampaignModal = true;
|
||||
}
|
||||
|
||||
private void CloseCreateCampaignModal()
|
||||
{
|
||||
CampaignState.ResetValidation();
|
||||
ShowCreateCampaignModal = false;
|
||||
}
|
||||
|
||||
private async Task SubmitCreateCampaignAsync()
|
||||
{
|
||||
CampaignState.ResetValidation();
|
||||
@@ -35,6 +51,7 @@ public partial class CampaignManagementPanel
|
||||
var campaign = await ApiClient.RequestAsync<CampaignSummary>("POST", "/api/campaigns", new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
|
||||
|
||||
CampaignState.Model.Name = string.Empty;
|
||||
ShowCreateCampaignModal = false;
|
||||
await CampaignCreated.InvokeAsync(campaign.Id);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
@@ -52,6 +69,7 @@ public partial class CampaignManagementPanel
|
||||
|
||||
private FormState<CampaignFormModel> CampaignState { get; } = new();
|
||||
private bool IsCreatingCampaign { get; set; }
|
||||
private bool ShowCreateCampaignModal { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
|
||||
@@ -59,9 +77,6 @@ public partial class CampaignManagementPanel
|
||||
[Parameter]
|
||||
public Guid? SelectedCampaignId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? SelectedCampaignName { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public CampaignDetails? SelectedCampaign { get; set; }
|
||||
|
||||
@@ -88,4 +103,4 @@ public partial class CampaignManagementPanel
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,11 +60,6 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(StatusMessage))
|
||||
{
|
||||
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
|
||||
}
|
||||
|
||||
@if (IsPlayScreen)
|
||||
{
|
||||
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||
@@ -114,7 +109,6 @@
|
||||
<CampaignManagementPanel
|
||||
Campaigns="Campaigns"
|
||||
SelectedCampaignId="SelectedCampaignId"
|
||||
SelectedCampaignName="SelectedCampaignName"
|
||||
SelectedCampaign="SelectedCampaign"
|
||||
Rulesets="Rulesets"
|
||||
IsMutating="IsMutating"
|
||||
@@ -126,6 +120,18 @@
|
||||
EditCharacterRequested="OpenEditCharacterModal"/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Toasts.Count > 0)
|
||||
{
|
||||
<div class="toast-stack" aria-live="polite" aria-atomic="false">
|
||||
@foreach (var toast in Toasts)
|
||||
{
|
||||
<div class="toast @(toast.IsError ? "error" : "success")" role="status">
|
||||
<p>@toast.Message</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<CharacterFormModal
|
||||
|
||||
@@ -622,12 +622,12 @@ public partial class Workspace : IAsyncDisposable
|
||||
EditCharacterInitialModel = new();
|
||||
CreateCharacterFormVersion = 0;
|
||||
EditCharacterFormVersion = 0;
|
||||
Toasts.Clear();
|
||||
}
|
||||
|
||||
private void SetStatus(string message, bool isError)
|
||||
{
|
||||
StatusMessage = message;
|
||||
StatusIsError = isError;
|
||||
AddToast(message, isError);
|
||||
Announce(message);
|
||||
}
|
||||
|
||||
@@ -636,6 +636,29 @@ public partial class Workspace : IAsyncDisposable
|
||||
LiveAnnouncement = message;
|
||||
}
|
||||
|
||||
private void AddToast(string message, bool isError)
|
||||
{
|
||||
var toastId = Guid.NewGuid();
|
||||
Toasts.Add(new WorkspaceToast(toastId, message, isError));
|
||||
_ = DismissToastLaterAsync(toastId);
|
||||
}
|
||||
|
||||
private async Task DismissToastLaterAsync(Guid toastId)
|
||||
{
|
||||
await Task.Delay(ToastDurationMs);
|
||||
|
||||
if (Toasts.RemoveAll(toast => toast.Id == toastId) == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleScreenMenu()
|
||||
{
|
||||
IsScreenMenuOpen = !IsScreenMenuOpen;
|
||||
@@ -662,8 +685,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
private bool IsCampaignDataLoading { get; set; }
|
||||
private bool HasHealthIssue { get; set; }
|
||||
private string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
|
||||
private string? StatusMessage { get; set; }
|
||||
private bool StatusIsError { get; set; }
|
||||
private List<WorkspaceToast> Toasts { get; } = [];
|
||||
private string CurrentScreen { get; set; } = "play";
|
||||
private string MobilePanel { get; set; } = "character";
|
||||
private string ConnectionState { get; set; } = "offline";
|
||||
@@ -721,4 +743,7 @@ public partial class Workspace : IAsyncDisposable
|
||||
private const string ScreenSessionKey = "screen";
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
private const string MobilePanelSessionKey = "play-panel";
|
||||
private const int ToastDurationMs = 3200;
|
||||
|
||||
private sealed record WorkspaceToast(Guid Id, string Message, bool IsError);
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ select:focus-visible {
|
||||
|
||||
.management-screen {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -666,6 +666,31 @@ select:focus-visible {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.management-list li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-row-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
align-self: flex-start;
|
||||
background: #f9f2e2;
|
||||
color: var(--text);
|
||||
border: 1px solid #b39f79;
|
||||
}
|
||||
|
||||
.add-row-icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border: 1px solid #8e7b57;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -699,6 +724,41 @@ select:focus-visible {
|
||||
border-top: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 60;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
width: min(22rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.toast {
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid;
|
||||
padding: 0.55rem 0.7rem;
|
||||
box-shadow: 0 6px 14px rgba(34, 24, 9, 0.22);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.toast p {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: #e8f7e8;
|
||||
border-color: #78a978;
|
||||
color: #1f5425;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: #ffe9e5;
|
||||
border-color: #bb6e62;
|
||||
color: #7f2015;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
Reference in New Issue
Block a user