Extract shared header and skill group block components

This commit is contained in:
2026-02-26 18:07:01 +01:00
parent 51d04fcdc5
commit a56b3fc451
11 changed files with 434 additions and 262 deletions

View File

@@ -56,6 +56,7 @@ Gameplay capabilities now include:
- Campaign management owner labels use account display names (no GUID fallback rendering)
- Character edit flow supports unlinking from campaigns (owner/GM/admin) and assigning to any existing campaign via expanded campaign options
- Campaign management supports character deletion by character owner or admin
- Shared top header control across workspace and admin views (consistent navigation/logout behavior)
## Prerequisites

View File

@@ -1,32 +1,27 @@
<div class="rr-app">
<div class="workspace-shell">
<AppHeader
User="CurrentUser"
ShowCampaign="false"
ShowConnectionState="true"
ConnectionStateLabel="@(!IsLoading && CurrentUser is not null ? "Connected" : "Offline fallback")"
ConnectionStateCssClass="@(!IsLoading && CurrentUser is not null ? "ok" : "offline")"
IsMenuOpen="IsScreenMenuOpen"
MenuButtonId="admin-screen-menu-button"
MenuId="admin-screen-menu"
MenuItems="HeaderMenuItems"
ToggleMenuRequested="ToggleScreenMenu"
LogoutRequested="LogoutAsync"/>
<main class="management-screen">
<section class="card">
<div class="section-head">
<h2>Admin</h2>
</div>
@if (CurrentUser is null)
{
<p class="empty">Loading admin session...</p>
}
else
{
<p class="muted"><strong>@CurrentUser.DisplayName</strong> (@CurrentUser.Username)</p>
}
<div class="inline-actions">
<button type="button" class="ghost" disabled="@(IsMutating || IsLoading)" @onclick="BackToWorkspaceAsync">Back to workspace</button>
<a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutAsync">Logout</a>
<h2>User Management</h2>
</div>
@if (!string.IsNullOrWhiteSpace(StatusMessage))
{
<p class="@(StatusIsError ? "form-error" : "muted")">@StatusMessage</p>
}
</section>
<section class="card">
<div class="section-head">
<h2>User Management</h2>
</div>
@if (IsLoading)
{
<p class="empty">Loading users...</p>
@@ -44,7 +39,6 @@
<ul class="management-list">
@foreach (var user in Users)
{
var userIsAdmin = HasAdminRole(user);
<li>
<div>
<strong>@user.Username</strong>
@@ -74,3 +68,4 @@
</section>
</main>
</div>
</div>

View File

@@ -46,11 +46,18 @@ public partial class AdminHome
}
}
private async Task BackToWorkspaceAsync()
private async Task OpenWorkspaceAsync()
{
IsScreenMenuOpen = false;
await WorkspaceRequested.InvokeAsync();
}
private Task OpenAdminAsync()
{
IsScreenMenuOpen = false;
return Task.CompletedTask;
}
private async Task LogoutAsync()
{
if (IsMutating)
@@ -148,6 +155,11 @@ public partial class AdminHome
StatusIsError = isError;
}
private void ToggleScreenMenu()
{
IsScreenMenuOpen = !IsScreenMenuOpen;
}
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
@@ -156,11 +168,23 @@ public partial class AdminHome
private bool IsLoading { get; set; } = true;
private bool IsMutating { get; set; }
private bool IsScreenMenuOpen { get; set; }
private bool IsCurrentUserAdmin { get; set; }
private UserSummary? CurrentUser { get; set; }
private List<AdminUserSummary> Users { get; set; } = [];
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
{
get
{
return
[
new AppHeaderMenuItem { Label = "Workspace", IsActive = false, OnSelected = OpenWorkspaceAsync },
new AppHeaderMenuItem { Label = "Admin", IsActive = true, OnSelected = OpenAdminAsync }
];
}
}
[Parameter]
public EventCallback<string?> LoggedOut { get; set; }

View File

@@ -0,0 +1,53 @@
<header class="workspace-header">
<div class="header-row">
<h1>@Title</h1>
@if (User is null)
{
<p class="header-identity"><strong>Loading user...</strong></p>
}
else
{
<p class="header-identity"><strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
}
@if (ShowCampaign)
{
<p class="header-campaign">Campaign: <strong>@(CampaignName ?? "No campaign selected")</strong></p>
}
@if (ShowConnectionState)
{
<div class="header-connection-cell">
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
</div>
}
<a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutRequested">Logout</a>
@if (MenuItems.Count > 0)
{
<div class="header-menu-wrap">
<button
id="@MenuButtonId"
type="button"
class="menu-toggle"
aria-haspopup="true"
aria-expanded="@IsMenuOpen"
aria-controls="@MenuId"
@onclick="ToggleMenuRequested">
<span aria-hidden="true">☰</span>
</button>
@if (IsMenuOpen)
{
<div id="@MenuId" class="screen-menu" role="menu" aria-labelledby="@MenuButtonId">
@foreach (var item in MenuItems)
{
<button type="button"
class="menu-item @(item.IsActive ? "active" : string.Empty)"
role="menuitem"
@onclick="() => SelectMenuItemAsync(item)">
@item.Label
</button>
}
</div>
}
</div>
}
</div>
</header>

View File

@@ -0,0 +1,60 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class AppHeader
{
private Task SelectMenuItemAsync(AppHeaderMenuItem item)
{
return item.OnSelected?.Invoke() ?? Task.CompletedTask;
}
[Parameter]
public string Title { get; set; } = "RpgRoller";
[Parameter]
public UserSummary? User { get; set; }
[Parameter]
public bool ShowCampaign { get; set; }
[Parameter]
public string? CampaignName { get; set; }
[Parameter]
public bool ShowConnectionState { get; set; } = true;
[Parameter]
public string ConnectionStateLabel { get; set; } = "Offline fallback";
[Parameter]
public string ConnectionStateCssClass { get; set; } = "offline";
[Parameter]
public bool IsMenuOpen { get; set; }
[Parameter]
public string MenuButtonId { get; set; } = "screen-menu-button";
[Parameter]
public string MenuId { get; set; } = "screen-menu";
[Parameter]
public IReadOnlyList<AppHeaderMenuItem> MenuItems { get; set; } = [];
[Parameter]
public EventCallback ToggleMenuRequested { get; set; }
[Parameter]
public EventCallback LogoutRequested { get; set; }
}
public sealed class AppHeaderMenuItem
{
public string Label { get; init; } = string.Empty;
public bool IsActive { get; init; }
public Func<Task>? OnSelected { get; init; }
}

View File

@@ -79,143 +79,45 @@
@foreach (var group in visibleSkillGroups)
{
var groupSkills = filteredSkills.Where(skill => skill.SkillGroupId == group.Id).ToList();
<div class="skill-group-block">
<div class="skill-group-head">
<strong>@group.Name</strong>
<div class="skill-chip-actions">
<button
type="button"
class="chip-button"
title="Edit skill group"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => OpenEditSkillGroupModal(group)">
<span aria-hidden="true" class="emoji">✏️</span>
<span class="sr-only">Edit @group.Name</span>
</button>
<button
type="button"
class="chip-button"
title="Delete skill group"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => DeleteSkillGroupAsync(group.Id)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete @group.Name</span>
</button>
</div>
</div>
@if (!hasSkillFilter && groupSkills.Count == 0)
{
<p class="empty">No skills in this group yet.</p>
}
<div class="skill-list">
@foreach (var skill in groupSkills)
{
<div class="skill-item">
<div class="skill-details">
<strong>@skill.Name</strong>
<span>@SkillDefinitionLabel(skill)</span>
</div>
<div class="skill-chip-actions">
<button
type="button"
class="chip-button"
title="Edit skill"
disabled="@(IsMutating || !CanEditSkill(skill))"
@onclick="() => OpenEditSkillModal(skill)">
<span aria-hidden="true" class="emoji">✏️</span>
<span class="sr-only">Edit @skill.Name</span>
</button>
<button
type="button"
class="chip-button"
title="Roll skill"
disabled="@(IsMutating)"
@onclick="() => RollSkillAsync(skill.Id)">
<span aria-hidden="true" class="emoji">🎲</span>
<span class="sr-only">Roll @skill.Name</span>
</button>
<button
type="button"
class="chip-button"
title="Delete skill"
disabled="@(IsMutating || !CanEditSkill(skill))"
@onclick="() => DeleteSkillAsync(skill.Id)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete @skill.Name</span>
</button>
</div>
</div>
}
<button
type="button"
class="skill-item create-skill-item"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => OpenCreateSkillModal(group.Id)">
<span class="skill-create-icon" aria-hidden="true">+</span>
<span>Add skill</span>
</button>
</div>
</div>
<SkillGroupBlock
Title="@group.Name"
SkillGroupId="group.Id"
Skills="groupSkills"
IsMutating="IsMutating"
CanEditGroup="CanEditCharacter(SelectedCharacter)"
CanCreateSkill="CanEditCharacter(SelectedCharacter)"
HasSkillFilter="hasSkillFilter"
EmptyMessage="No skills in this group yet."
ShowGroupActions="true"
CanEditSkill="CanEditSkill"
SkillDefinitionLabel="SkillDefinitionLabel"
AddSkillRequested="OnAddSkillRequestedAsync"
EditSkillRequested="OnEditSkillRequestedAsync"
RollSkillRequested="RollSkillAsync"
DeleteSkillRequested="DeleteSkillAsync"
EditGroupRequested="OnEditSkillGroupRequestedAsync"
DeleteGroupRequested="DeleteSkillGroupAsync"/>
}
@if (!hasSkillFilter || ungroupedSkills.Count > 0)
{
<div class="skill-group-block">
<div class="skill-group-head">
<strong>Ungrouped</strong>
</div>
@if (!hasSkillFilter && ungroupedSkills.Count == 0)
{
<p class="empty">No ungrouped skills.</p>
}
<div class="skill-list">
@foreach (var skill in ungroupedSkills)
{
<div class="skill-item">
<div class="skill-details">
<strong>@skill.Name</strong>
<span>@SkillDefinitionLabel(skill)</span>
</div>
<div class="skill-chip-actions">
<button
type="button"
class="chip-button"
title="Edit skill"
disabled="@(IsMutating || !CanEditSkill(skill))"
@onclick="() => OpenEditSkillModal(skill)">
<span aria-hidden="true" class="emoji">✏️</span>
<span class="sr-only">Edit @skill.Name</span>
</button>
<button
type="button"
class="chip-button"
title="Roll skill"
disabled="@(IsMutating)"
@onclick="() => RollSkillAsync(skill.Id)">
<span aria-hidden="true" class="emoji">🎲</span>
<span class="sr-only">Roll @skill.Name</span>
</button>
<button
type="button"
class="chip-button"
title="Delete skill"
disabled="@(IsMutating || !CanEditSkill(skill))"
@onclick="() => DeleteSkillAsync(skill.Id)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete @skill.Name</span>
</button>
</div>
</div>
}
<button
type="button"
class="skill-item create-skill-item"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => OpenCreateSkillModal(null)">
<span class="skill-create-icon" aria-hidden="true">+</span>
<span>Add skill</span>
</button>
</div>
</div>
<SkillGroupBlock
Title="Ungrouped"
SkillGroupId="@((Guid?)null)"
Skills="ungroupedSkills"
IsMutating="IsMutating"
CanEditGroup="false"
CanCreateSkill="CanEditCharacter(SelectedCharacter)"
HasSkillFilter="hasSkillFilter"
EmptyMessage="No ungrouped skills."
ShowGroupActions="false"
CanEditSkill="CanEditSkill"
SkillDefinitionLabel="SkillDefinitionLabel"
AddSkillRequested="OnAddSkillRequestedAsync"
EditSkillRequested="OnEditSkillRequestedAsync"
RollSkillRequested="RollSkillAsync"
DeleteSkillRequested="DeleteSkillAsync"
EditGroupRequested="OnEditSkillGroupRequestedAsync"
DeleteGroupRequested="DeleteSkillGroupAsync"/>
}
<button

View File

@@ -72,6 +72,27 @@ public partial class CharacterPanel
await RollRequested.InvokeAsync(skillId);
}
private Task OnAddSkillRequestedAsync(Guid? skillGroupId)
{
OpenCreateSkillModal(skillGroupId);
return Task.CompletedTask;
}
private Task OnEditSkillRequestedAsync(SkillSummary skill)
{
OpenEditSkillModal(skill);
return Task.CompletedTask;
}
private Task OnEditSkillGroupRequestedAsync(Guid skillGroupId)
{
var skillGroup = SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId);
if (skillGroup is not null)
OpenEditSkillGroupModal(skillGroup);
return Task.CompletedTask;
}
private void OpenCreateSkillGroupModal()
{
SkillGroupState.Model.Name = string.Empty;

View File

@@ -0,0 +1,80 @@
<div class="skill-group-block">
<div class="skill-group-head">
<strong>@Title</strong>
@if (ShowGroupActions && SkillGroupId.HasValue)
{
<div class="skill-chip-actions">
<button
type="button"
class="chip-button"
title="Edit skill group"
disabled="@(IsMutating || !CanEditGroup)"
@onclick="() => EditGroupRequested.InvokeAsync(SkillGroupId.Value)">
<span aria-hidden="true" class="emoji">✏️</span>
<span class="sr-only">Edit @Title</span>
</button>
<button
type="button"
class="chip-button"
title="Delete skill group"
disabled="@(IsMutating || !CanEditGroup)"
@onclick="() => DeleteGroupRequested.InvokeAsync(SkillGroupId.Value)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete @Title</span>
</button>
</div>
}
</div>
@if (!HasSkillFilter && Skills.Count == 0)
{
<p class="empty">@EmptyMessage</p>
}
<div class="skill-list">
@foreach (var skill in Skills)
{
<div class="skill-item">
<div class="skill-details">
<strong>@skill.Name</strong>
<span>@SkillDefinitionLabel(skill)</span>
</div>
<div class="skill-chip-actions">
<button
type="button"
class="chip-button"
title="Edit skill"
disabled="@(IsMutating || !CanEditSkill(skill))"
@onclick="() => EditSkillRequested.InvokeAsync(skill)">
<span aria-hidden="true" class="emoji">✏️</span>
<span class="sr-only">Edit @skill.Name</span>
</button>
<button
type="button"
class="chip-button"
title="Roll skill"
disabled="@(IsMutating)"
@onclick="() => RollSkillRequested.InvokeAsync(skill.Id)">
<span aria-hidden="true" class="emoji">🎲</span>
<span class="sr-only">Roll @skill.Name</span>
</button>
<button
type="button"
class="chip-button"
title="Delete skill"
disabled="@(IsMutating || !CanEditSkill(skill))"
@onclick="() => DeleteSkillRequested.InvokeAsync(skill.Id)">
<span aria-hidden="true" class="emoji">🗑️</span>
<span class="sr-only">Delete @skill.Name</span>
</button>
</div>
</div>
}
<button
type="button"
class="skill-item create-skill-item"
disabled="@(IsMutating || !CanCreateSkill)"
@onclick="() => AddSkillRequested.InvokeAsync(SkillGroupId)">
<span class="skill-create-icon" aria-hidden="true">+</span>
<span>Add skill</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,60 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class SkillGroupBlock
{
[Parameter]
public string Title { get; set; } = string.Empty;
[Parameter]
public Guid? SkillGroupId { get; set; }
[Parameter]
public IReadOnlyList<SkillSummary> Skills { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public bool CanEditGroup { get; set; }
[Parameter]
public bool CanCreateSkill { get; set; }
[Parameter]
public bool HasSkillFilter { get; set; }
[Parameter]
public string EmptyMessage { get; set; } = "No skills in this group yet.";
[Parameter]
public bool ShowGroupActions { get; set; }
[Parameter]
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
[Parameter]
public EventCallback<Guid?> AddSkillRequested { get; set; }
[Parameter]
public EventCallback<SkillSummary> EditSkillRequested { get; set; }
[Parameter]
public EventCallback<Guid> RollSkillRequested { get; set; }
[Parameter]
public EventCallback<Guid> DeleteSkillRequested { get; set; }
[Parameter]
public EventCallback<Guid> EditGroupRequested { get; set; }
[Parameter]
public EventCallback<Guid> DeleteGroupRequested { get; set; }
}

View File

@@ -14,59 +14,19 @@
}
<div class="workspace-shell">
<header class="workspace-header">
<div class="header-row">
<h1>RpgRoller</h1>
@if (User is null)
{
<p class="header-identity"><strong>Loading user...</strong></p>
}
else
{
<p class="header-identity"><strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
}
<p class="header-campaign">Campaign: <strong>@(SelectedCampaignName ?? "No campaign selected")</strong></p>
<div class="header-connection-cell">
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
</div>
<a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutAsync">Logout</a>
<div class="header-menu-wrap">
<button
id="screen-menu-button"
type="button"
class="menu-toggle"
aria-haspopup="true"
aria-expanded="@IsScreenMenuOpen"
aria-controls="screen-menu"
@onclick="ToggleScreenMenu">
<span aria-hidden="true">☰</span>
</button>
@if (IsScreenMenuOpen)
{
<div id="screen-menu" class="screen-menu" role="menu" aria-labelledby="screen-menu-button">
<button type="button"
class="menu-item @(IsPlayScreen ? "active" : string.Empty)"
role="menuitem"
@onclick="SwitchToPlayAsync">Play
</button>
<button type="button"
class="menu-item @(IsManagementScreen ? "active" : string.Empty)"
role="menuitem"
@onclick="SwitchToManagementAsync">Campaign Management
</button>
@if (IsCurrentUserAdmin)
{
<button type="button"
class="menu-item"
role="menuitem"
@onclick="OpenAdminAsync">Admin
</button>
}
</div>
}
</div>
</div>
</header>
<AppHeader
User="User"
ShowCampaign="true"
CampaignName="SelectedCampaignName"
ShowConnectionState="true"
ConnectionStateLabel="ConnectionStateLabel"
ConnectionStateCssClass="ConnectionStateCssClass"
IsMenuOpen="IsScreenMenuOpen"
MenuButtonId="workspace-screen-menu-button"
MenuId="workspace-screen-menu"
MenuItems="HeaderMenuItems"
ToggleMenuRequested="ToggleScreenMenu"
LogoutRequested="LogoutAsync"/>
@if (IsPlayScreen)
{

View File

@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Contracts;
using RpgRoller.Domain;
@@ -846,7 +847,22 @@ public partial class Workspace : IAsyncDisposable
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
private bool IsManagementScreen => !IsPlayScreen;
private string CurrentScreenLabel => IsPlayScreen ? "Play" : "Campaign Management";
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
{
get
{
var items = new List<AppHeaderMenuItem>
{
new() { Label = "Play", IsActive = IsPlayScreen, OnSelected = SwitchToPlayAsync },
new() { Label = "Campaign Management", IsActive = IsManagementScreen, OnSelected = SwitchToManagementAsync }
};
if (IsCurrentUserAdmin)
items.Add(new AppHeaderMenuItem { Label = "Admin", IsActive = false, OnSelected = OpenAdminAsync });
return items;
}
}
private string ConnectionStateLabel => ConnectionState switch
{