diff --git a/FAQ.md b/FAQ.md index 83b00ad..ed3297d 100644 --- a/FAQ.md +++ b/FAQ.md @@ -77,6 +77,10 @@ Authenticated application state and behavior were moved into `Components/Pages/W `Workspace` initializes authenticated session data after the first render (`OnAfterRenderAsync`). During that first render pass, the header now intentionally shows a null-safe fallback label instead of dereferencing user fields before `/api/me` has been loaded. +## Where did workspace success/error messages go? + +Inline status message rows in the workspace were replaced with timed toast notifications. This keeps the main layout compact while still surfacing operation outcomes. + ## Where did Play/Campaign Management switching move? Screen switching is now inside the header hamburger menu. The menu exposes `Play` and `Campaign Management` options while keeping the top bar compact. @@ -110,3 +114,7 @@ Auth inputs, validation, and submit workflows are transient UI concerns, so they ## Why are there `.razor.cs` files next to Razor components? Component behavior was moved out of inline `@code` blocks into code-behind classes so `.razor` files stay markup-focused while state, parameters, handlers, and injected services live in typed C# files. + +## How do I add campaigns and characters in Campaign Management now? + +Campaign creation is launched from a compact `Add campaign` row button that opens a modal form. Character management is directly below the campaign card and uses per-row edit chips plus an `Add character` row button for create flow. diff --git a/FRONTEND_PROGRESS.md b/FRONTEND_PROGRESS.md index 2883a15..2625c80 100644 --- a/FRONTEND_PROGRESS.md +++ b/FRONTEND_PROGRESS.md @@ -12,6 +12,7 @@ Tracking against `UX.md` tasks and decisions. - Workspace header user identity rendering is now null-safe during first render (`Loading user...` fallback until `/api/me` loads). - Workspace header was compacted into a single horizontal row with hamburger menu screen switching and link-style logout. - Header alignment was tightened so connection status occupies the growing middle cell and the hamburger menu remains pinned to the right edge. +- Workspace status/error feedback moved from inline messages to timed toast notifications. - Concern controls now own their local form state and mutation workflows; the workspace host handles shared cross-control state refresh. - Skill create/edit flow is now owned by `CharacterPanel` (where characters and their skills are presented together). - Skill interactions are now row-local chip actions (edit/roll) with an inline dummy `+` row for create-skill. @@ -29,7 +30,7 @@ Tracking against `UX.md` tasks and decisions. | 9.3 Shared authenticated header | Implemented | Compact single-row header with user/campaign context, growing connection-status cell, right-aligned hamburger screen switch, and link-style logout. | | 9.4 Play screen character column | Implemented | Compact character picker, merged character+skills header row, modal edit/create flows, inline per-skill edit/roll chips, d6 skill options (wild/fumble), and no separate last-roll panel. | | 9.5 Play screen log column | Implemented | Chronological feed, private/public badges, private perspective styles (roller vs GM), per-entry dice visualization with die-state flags, local time + ISO tooltip. | -| 9.6 Campaign management screen | Implemented | Campaign selector/summary, create form, details card, character management actions with modal edit pattern. | +| 9.6 Campaign management screen | Implemented | Campaign selector and details are merged into one card, campaign create moved behind an inline `Add campaign` row opening a modal, and character management sits beneath with chip-style edit actions plus an inline `Add character` row. | | 9.7 Tablet/mobile bottom bar | Implemented | `Character` / `Log` panel switch in play screen and per-tab session persistence. | | 10 Validation and error UX | Partially implemented | Required-field and common API errors are mapped; message/code-specific mapping is limited by current API exposing only text messages. | | 11 Empty/loading/disabled states | Implemented | Empty states, skeleton placeholders, mutation button disabling. | diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor index c02b496..9e4da3d 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor @@ -1,6 +1,8 @@
-

Campaign Selector

+
+

Campaign

+
@if (Campaigns.Count == 0) {

No campaigns yet.

@@ -11,47 +13,11 @@ } -

Current campaign in this tab: @(SelectedCampaignName ?? "None selected")

-
-
-

Create Campaign

- @if (!string.IsNullOrWhiteSpace(CampaignState.ErrorMessage)) - { -

@CampaignState.ErrorMessage

- } -
- - - @if (CampaignState.Errors.TryGetValue("name", out var campaignNameError)) - { -

@campaignNameError

- } - - - @if (CampaignState.Errors.TryGetValue("rulesetId", out var campaignRulesetError)) - { -

@campaignRulesetError

- } - -
-
- -
-

Campaign Details

@if (SelectedCampaign is null) {

No campaign selected.

@@ -60,44 +26,102 @@ {

Name: @SelectedCampaign.Name

Ruleset: @SelectedCampaign.RulesetId

-

GM: @SelectedCampaign.Gm.DisplayName (@SelectedCampaign.Gm.Username)

+

GM: @SelectedCampaign.Gm.DisplayName (@SelectedCampaign.Gm.Username)

Characters visible: @SelectedCampaign.Characters.Count

} + +

Character Management

-
@if (SelectedCampaign is null) {

Select a campaign first.

} - else if (SelectedCampaign.Characters.Count == 0) - { -

No characters in this campaign yet.

- } else { - + + } + + } + + }
+ +@if (ShowCreateCampaignModal) +{ + +} diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs index e2024be..c9326dc 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor.cs @@ -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("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 CampaignState { get; } = new(); private bool IsCreatingCampaign { get; set; } + private bool ShowCreateCampaignModal { get; set; } [Parameter] public IReadOnlyList 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 EditCharacterRequested { get; set; } -} \ No newline at end of file +} diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index 838f938..d176111 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -60,11 +60,6 @@ - @if (!string.IsNullOrWhiteSpace(StatusMessage)) - { -

@StatusMessage

- } - @if (IsPlayScreen) {
@@ -114,7 +109,6 @@ } + + @if (Toasts.Count > 0) + { +
+ @foreach (var toast in Toasts) + { +
+

@toast.Message

+
+ } +
+ } 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 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); } diff --git a/RpgRoller/wwwroot/styles.css b/RpgRoller/wwwroot/styles.css index 0068fdc..a37e7a1 100644 --- a/RpgRoller/wwwroot/styles.css +++ b/RpgRoller/wwwroot/styles.css @@ -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;