< Summary

Information
Class: Chronicis.Client.Components.Layout.AuthenticatedLayout
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Layout/AuthenticatedLayout.razor
Line coverage
100%
Covered lines: 51
Uncovered lines: 0
Coverable lines: 51
Total lines: 488
Line coverage: 100%
Branch coverage
100%
Covered branches: 8
Total branches: 8
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
OnCtrlM()100%11100%
OnCtrlT()100%11100%
OnCtrlShiftF()100%11100%
OnCtrlQ()100%11100%
OnCtrlS()100%11100%
ToggleDrawer()100%11100%
ToggleTutorialDrawer()100%11100%
OpenAdminUtilities()100%11100%
OpenFeaturesDrawer()100%11100%
get_ShouldRenderGlobalDrawerHost()100%11100%
IsArticleRoute()100%22100%
HandleLocationChanged(...)100%11100%
HandleAppContextChanged()100%11100%
IsEligibleForOnboardingTutorialAutoOpen()100%11100%
GetCurrentPathOnly()100%11100%
OnTreeSearchChanged()100%22100%
ClearSearch()100%11100%
OnGlobalSearchKeyDown(...)100%22100%
ExecuteGlobalSearch()100%22100%
BeginSignOut()100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Layout/AuthenticatedLayout.razor

#LineLine coverage
 1@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
 2@using Microsoft.AspNetCore.Components.Routing
 3@using Chronicis.Client.Components.Drawers
 4@using Chronicis.Client.Models
 5@using Chronicis.Shared.DTOs
 6@inherits LayoutComponentBase
 7@inject NavigationManager Navigation
 8@inject ITreeStateService TreeState
 9@inject IAuthService AuthService
 10@inject IArticleApiService ArticleApi
 11@inject ISnackbar Snackbar
 12@inject IJSRuntime JSRuntime
 13@inject AuthenticationStateProvider AuthenticationStateProvider
 14@inject MudTheme ChronicisTheme
 15@inject IMetadataDrawerService MetadataDrawerService
 16@inject IQuestDrawerService QuestDrawerService
 17@inject IKeyboardShortcutService KeyboardShortcutService
 18@inject IDrawerCoordinator DrawerCoordinator
 19@inject IAdminAuthService AdminAuthService
 20@inject IUserApiService UserApi
 21@inject IAppContextService AppContext
 22@inject ILogger<AuthenticatedLayout> Logger
 23@inject IVersionService VersionService
 24@implements IAsyncDisposable
 25
 26<MudThemeProvider Theme="ChronicisTheme" />
 27<MudPopoverProvider />
 28<MudDialogProvider />
 29<MudSnackbarProvider />
 30
 31<MudLayout>
 32    <MudAppBar Elevation="2">
 33        <MudIconButton Icon="@Icons.Material.Filled.Menu"
 34                       Color="Color.Inherit"
 35                       Edge="Edge.Start"
 36                       OnClick="@ToggleDrawer" />
 37
 38        <a href="/dashboard" class="d-flex align-center chronicis-home-link">
 39            <img src="/images/logo.png" alt="Chronicis" class="chronicis-logo" />
 40            <MudText Typo="Typo.h6" Class="chronicis-title">Chronicis</MudText>
 41        </a>
 42
 43        <MudSpacer />
 44
 45        <MudTextField @bind-Value="_globalSearchQuery"
 46                      Placeholder="Search content..."
 47                      Adornment="Adornment.Start"
 48                      AdornmentIcon="@Icons.Material.Filled.Search"
 49                      Variant="Variant.Outlined"
 50                      Margin="Margin.Dense"
 51                      Style="max-width: 400px; background-color: rgba(255,255,255,0.1);"
 52                      Class="global-search-input"
 53                      Immediate="true"
 54                      @onkeydown="OnGlobalSearchKeyDown" />
 55
 56        <MudTooltip Text="Tutorial (Ctrl+T)">
 57            <MudIconButton Icon="@Icons.Material.Filled.Help"
 58                           Color="Color.Inherit"
 59                           OnClick="ToggleTutorialDrawer" />
 60        </MudTooltip>
 61
 62        @if (_isSysAdmin)
 63        {
 64            <MudMenu Icon="@Icons.Material.Filled.AdminPanelSettings"
 65                     Color="Color.Inherit"
 66                     AnchorOrigin="Origin.BottomRight"
 67                     TransformOrigin="Origin.TopRight">
 68                <ChildContent>
 69                    <MudMenuItem Icon="@Icons.Material.Filled.AdminPanelSettings"
 70                                 IconColor="Color.Default"
 71                                 OnClick="OpenAdminUtilities">
 72                        Admin Utilities
 73                    </MudMenuItem>
 74                    <MudMenuItem Icon="@Icons.Material.Filled.AutoStories"
 75                                 IconColor="Color.Default"
 76                                 OnClick="OpenFeaturesDrawer">
 77                        Features
 78                    </MudMenuItem>
 79                </ChildContent>
 80            </MudMenu>
 81        }
 82
 83        <MudMenu Icon="@Icons.Material.Filled.AccountCircle"
 84                 Color="Color.Inherit"
 85                 AnchorOrigin="Origin.BottomRight"
 86                 TransformOrigin="Origin.TopRight">
 87            <ChildContent>
 88                <div class="user-info" style="padding: 12px 16px; min-width: 200px;">
 89                    @if (!string.IsNullOrEmpty(_currentUser?.AvatarUrl))
 90                    {
 91                        <MudImage Src="@_currentUser.AvatarUrl" Style="margin-bottom: 8px;" />
 92                    }
 93                    <div style="margin-top: 8px;">
 94                        <div style="font-weight: 600;">@_currentUser?.DisplayName</div>
 95                        <div style="font-size: 0.85rem; opacity: 0.8;">@_currentUser?.Email</div>
 96                    </div>
 97                </div>
 98                <MudDivider />
 99                <MudMenuItem Icon="@Icons.Material.Filled.Settings"
 100                    IconColor="Color.Default"
 101                    Href="/settings">
 102                    Settings
 103                </MudMenuItem>
 104                <MudMenuItem Icon="@Icons.Material.Filled.Logout"
 105                    IconColor="Color.Primary"
 106                    OnClick="BeginSignOut">
 107                    Logout
 108                </MudMenuItem>
 109            </ChildContent>
 110        </MudMenu>
 111    </MudAppBar>
 112
 113    <MudDrawer @bind-Open="_drawerOpen"
 114               Elevation="2"
 115               ClipMode="DrawerClipMode.Always"
 116               Width="320px">
 117
 118        <div class="chronicis-search-box">
 119            <MudTextField @bind-Value="_treeSearch"
 120                          @bind-Value:after="OnTreeSearchChanged"
 121                          Placeholder="Filter articles..."
 122                          Adornment="Adornment.End"
 123                          AdornmentIcon="@(_treeSearch?.Length > 0 ? Icons.Material.Filled.Clear : Icons.Material.Filled
 124                          AdornmentColor="Color.Primary"
 125                          OnAdornmentClick="ClearSearch"
 126                          Variant="Variant.Outlined"
 127                          Margin="Margin.Dense"
 128                          Immediate="true"
 129                          DebounceInterval="200"
 130                          Class="mb-2" />
 131        </div>
 132
 133        <QuickAddSession />
 134
 135        <ArticleTreeView />
 136    </MudDrawer>
 137
 138    <MudMainContent Class="mud-main-content">
 139        <div class="authenticated-content-wrapper">
 140            <MudContainer MaxWidth="MaxWidth.Large" Class="py-4">
 141                <ErrorBoundary>
 142                    <ChildContent>
 143                        @Body
 144                    </ChildContent>
 145                    <ErrorContent Context="exception">
 146                        <div class="chronicis-error-state">
 147                            <MudPaper Elevation="3" Class="pa-4">
 148                                <div class="chronicis-empty-state">
 149                                    <div class="chronicis-empty-state-icon">⚠️</div>
 150                                    <MudText Typo="Typo.h5" Class="chronicis-empty-state-title mb-3">
 151                                        Something went wrong
 152                                    </MudText>
 153                                    <MudText Typo="Typo.body1" Class="chronicis-empty-state-message mb-4">
 154                                        An unexpected error occurred. Please try refreshing the page.
 155                                    </MudText>
 156                                    <MudButton Variant="Variant.Filled"
 157                                               Color="Color.Primary"
 158                                               OnClick="@(() => Navigation.NavigateTo(Navigation.Uri, forceLoad: true))"
 159                                        Refresh Page
 160                                    </MudButton>
 161                                </div>
 162                            </MudPaper>
 163                        </div>
 164                    </ErrorContent>
 165                </ErrorBoundary>
 166            </MudContainer>
 167
 168            <PublicFooter />
 169        </div>
 170    </MudMainContent>
 171
 172    @if (ShouldRenderGlobalDrawerHost)
 173    {
 174        <DrawerHost />
 175    }
 176</MudLayout>
 177
 178@code {
 49179    private bool _drawerOpen = true;
 180    private string? _treeSearch;
 49181    private string _globalSearchQuery = string.Empty;
 182    private UserInfo? _currentUser;
 183    private UserProfileDto? _currentUserProfile;
 184    private bool _isSysAdmin;
 185    private bool _hasAutoOpenedTutorialForOnboarding;
 186    private DotNetObjectReference<AuthenticatedLayout>? _dotNetRef;
 187
 188    protected override async Task OnInitializedAsync()
 189    {
 190        _currentUser = await AuthService.GetCurrentUserAsync();
 191        _currentUserProfile = await UserApi.GetUserProfileAsync();
 192        _isSysAdmin = await AdminAuthService.IsSysAdminAsync();
 193        AuthenticationStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged;
 194        Navigation.LocationChanged += HandleLocationChanged;
 195        AppContext.OnContextChanged += HandleAppContextChanged;
 196        await EvaluateTutorialDrawerStateAsync(allowOnboardingAutoOpen: true);
 197    }
 198
 199    protected override async Task OnAfterRenderAsync(bool firstRender)
 200    {
 201        if (firstRender)
 202        {
 203            _dotNetRef = DotNetObjectReference.Create(this);
 204            await JSRuntime.InvokeVoidAsync("chronicisKeyboardShortcuts.initialize", _dotNetRef);
 205
 206            var buildInfo = await VersionService.GetBuildInfoAsync();
 207            await JSRuntime.InvokeVoidAsync("chronicisRum.setVersion", buildInfo.Version, buildInfo.Sha);
 208        }
 209    }
 210
 211    [JSInvokable]
 212    public void OnCtrlM()
 213    {
 1214        MetadataDrawerService.Toggle();
 1215    }
 216
 217    [JSInvokable]
 218    public void OnCtrlT()
 219    {
 1220        DrawerCoordinator.Toggle(DrawerType.Tutorial);
 1221    }
 222
 223    [JSInvokable]
 224    public void OnCtrlShiftF()
 225    {
 1226        OpenFeaturesDrawer();
 1227    }
 228
 229    [JSInvokable]
 230    public void OnCtrlQ()
 231    {
 1232        QuestDrawerService.Toggle();
 1233    }
 234
 235    [JSInvokable]
 236    public void OnCtrlS()
 237    {
 1238        KeyboardShortcutService.RequestSave();
 1239    }
 240
 241    [JSInvokable]
 242    public async Task OnCtrlN()
 243    {
 244        // Create a sibling article to the currently selected article
 245        var selectedId = TreeState.SelectedNodeId;
 246
 247        if (!selectedId.HasValue)
 248        {
 249            Snackbar.Add("Select an article first, then press Ctrl+N to create a sibling", Severity.Info);
 250            return;
 251        }
 252
 253        // Check if the selected node is an article
 254        if (!TreeState.TryGetNode(selectedId.Value, out var selectedNode) || selectedNode == null)
 255        {
 256            Snackbar.Add("Select an article first, then press Ctrl+N to create a sibling", Severity.Info);
 257            return;
 258        }
 259
 260        // Only allow Ctrl+N for Article nodes
 261        if (selectedNode.NodeType != TreeNodeType.Article)
 262        {
 263            var nodeTypeName = selectedNode.NodeType switch
 264            {
 265                TreeNodeType.World => "World",
 266                TreeNodeType.Campaign => "Campaign",
 267                TreeNodeType.Arc => "Arc",
 268                TreeNodeType.VirtualGroup => "folder",
 269                TreeNodeType.ExternalLink => "link",
 270                _ => "item"
 271            };
 272            Snackbar.Add($"Ctrl+N creates sibling articles. Select an article instead of a {nodeTypeName}.", Severity.In
 273            return;
 274        }
 275
 276        // Get the current article to find its parent
 277        var currentArticle = await ArticleApi.GetArticleDetailAsync(selectedId.Value);
 278        if (currentArticle == null)
 279        {
 280            Snackbar.Add("Could not load the selected article", Severity.Warning);
 281            return;
 282        }
 283
 284        Guid? parentId = currentArticle.ParentId;
 285
 286        // Create sibling (same parent, same world/campaign context)
 287        Guid? newArticleId;
 288        if (parentId.HasValue)
 289        {
 290            newArticleId = await TreeState.CreateChildArticleAsync(parentId.Value);
 291        }
 292        else
 293        {
 294            // Root-level article - create sibling with same context
 295            var createDto = new ArticleCreateDto
 296            {
 297                Title = string.Empty,
 298                Body = string.Empty,
 299                ParentId = null,
 300                WorldId = currentArticle.WorldId,
 301                CampaignId = currentArticle.CampaignId,
 302                ArcId = currentArticle.ArcId,
 303                Type = currentArticle.Type, // Use same type as current article (e.g., SessionNote)
 304                EffectiveDate = DateTime.Now
 305            };
 306
 307            var created = await ArticleApi.CreateArticleAsync(createDto);
 308            if (created != null)
 309            {
 310                await TreeState.RefreshAsync();
 311                TreeState.SelectNode(created.Id);
 312                newArticleId = created.Id;
 313            }
 314            else
 315            {
 316                newArticleId = null;
 317            }
 318        }
 319
 320        if (newArticleId.HasValue)
 321        {
 322            Snackbar.Add("Article created (Ctrl+N)", Severity.Success);
 323
 324            // Navigate to the new article
 325            var newArticle = await ArticleApi.GetArticleDetailAsync(newArticleId.Value);
 326            if (newArticle != null && newArticle.Breadcrumbs.Any())
 327            {
 328                var path = string.Join("/", newArticle.Breadcrumbs.Select(b => b.Slug));
 329                Navigation.NavigateTo($"/article/{path}");
 330            }
 331        }
 332        else
 333        {
 334            Snackbar.Add("Failed to create article", Severity.Error);
 335        }
 336    }
 337
 338    private async void OnAuthenticationStateChanged(Task<AuthenticationState> task)
 339    {
 340        _currentUser = await AuthService.GetCurrentUserAsync();
 341        _currentUserProfile = await UserApi.GetUserProfileAsync();
 342        _isSysAdmin = await AdminAuthService.IsSysAdminAsync();
 343        _hasAutoOpenedTutorialForOnboarding = false;
 344        await EvaluateTutorialDrawerStateAsync(allowOnboardingAutoOpen: true);
 345        await InvokeAsync(StateHasChanged);
 346    }
 347
 348    private void ToggleDrawer()
 349    {
 1350        _drawerOpen = !_drawerOpen;
 1351    }
 352
 353    private void ToggleTutorialDrawer()
 354    {
 1355        DrawerCoordinator.Toggle(DrawerType.Tutorial);
 1356    }
 357
 358    private void OpenAdminUtilities()
 359    {
 1360        Navigation.NavigateTo("/admin/utilities");
 1361    }
 362
 363    private void OpenFeaturesDrawer()
 364    {
 2365        DrawerCoordinator.Open(DrawerType.FeaturedArticle);
 2366    }
 367
 34368    private bool ShouldRenderGlobalDrawerHost => !IsArticleRoute();
 369
 370    private bool IsArticleRoute()
 371    {
 37372        var pathOnly = GetCurrentPathOnly();
 373
 37374        return pathOnly.Equals("article", StringComparison.OrdinalIgnoreCase)
 37375            || pathOnly.StartsWith("article/", StringComparison.OrdinalIgnoreCase);
 376    }
 377
 378    private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
 379    {
 3380        _ = InvokeAsync(async () =>
 3381        {
 3382            DrawerCoordinator.Close();
 3383            await EvaluateTutorialDrawerStateAsync(allowOnboardingAutoOpen: true);
 3384        });
 3385    }
 386
 387    private void HandleAppContextChanged()
 388    {
 1389        _ = InvokeAsync(() => EvaluateTutorialDrawerStateAsync(allowOnboardingAutoOpen: true));
 1390    }
 391
 392    private async Task EvaluateTutorialDrawerStateAsync(bool allowOnboardingAutoOpen)
 393    {
 394        var isTutorialWorld = AppContext.CurrentWorld?.IsTutorial == true;
 395        DrawerCoordinator.IsForcedOpen = false;
 396
 397        if (isTutorialWorld)
 398        {
 399            DrawerCoordinator.Open(DrawerType.Tutorial);
 400            return;
 401        }
 402
 403        if (!allowOnboardingAutoOpen
 404            || _hasAutoOpenedTutorialForOnboarding
 405            || _currentUserProfile?.HasCompletedOnboarding != false
 406            || !IsEligibleForOnboardingTutorialAutoOpen())
 407        {
 408            return;
 409        }
 410
 411        DrawerCoordinator.Open(DrawerType.Tutorial);
 412        _hasAutoOpenedTutorialForOnboarding = true;
 413        await InvokeAsync(StateHasChanged);
 414    }
 415
 416    private bool IsEligibleForOnboardingTutorialAutoOpen()
 417    {
 1418        var pathOnly = GetCurrentPathOnly();
 419
 1420        return !pathOnly.StartsWith("authentication/", StringComparison.OrdinalIgnoreCase);
 421    }
 422
 423    private string GetCurrentPathOnly()
 424    {
 38425        var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri);
 38426        return relativePath.Split('?', '#')[0];
 427    }
 428
 429    private void OnTreeSearchChanged()
 430    {
 431        // Real-time filtering as user types (debounced by MudTextField)
 2432        if (string.IsNullOrWhiteSpace(_treeSearch))
 433        {
 1434            TreeState.ClearSearch();
 435        }
 436        else
 437        {
 1438            TreeState.SetSearchQuery(_treeSearch);
 439        }
 1440    }
 441
 442    private void ClearSearch()
 443    {
 1444        _treeSearch = null;
 1445        TreeState.ClearSearch();
 1446    }
 447
 448    private void OnGlobalSearchKeyDown(KeyboardEventArgs e)
 449    {
 2450        if (e.Key == "Enter")
 451        {
 1452            ExecuteGlobalSearch();
 453        }
 2454    }
 455
 456    private void ExecuteGlobalSearch()
 457    {
 2458        if (!string.IsNullOrWhiteSpace(_globalSearchQuery))
 459        {
 1460            Navigation.NavigateTo($"/search?q={Uri.EscapeDataString(_globalSearchQuery)}");
 461        }
 2462    }
 463
 464    private void BeginSignOut()
 465    {
 1466        Navigation.NavigateToLogout("authentication/logout");
 1467    }
 468
 469    public async ValueTask DisposeAsync()
 470    {
 471        AuthenticationStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
 472        Navigation.LocationChanged -= HandleLocationChanged;
 473        AppContext.OnContextChanged -= HandleAppContextChanged;
 474
 475        if (_dotNetRef != null)
 476        {
 477            try
 478            {
 479                await JSRuntime.InvokeVoidAsync("chronicisKeyboardShortcuts.dispose");
 480            }
 481            catch
 482            {
 483                // Ignore errors during disposal (e.g., if JS context is gone)
 484            }
 485            _dotNetRef.Dispose();
 486        }
 487    }
 488}